fix(audit): restore broken upload pipeline + role-based E2E audit fixes
A comprehensive role-based E2E audit (guest/host/admin, across browser sessions) surfaced one critical and several smaller issues; this addresses them and hardens the tests that missed them. Critical - The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade opened a *new* transaction inside the upgrade callback, which throws during a version-change transaction and aborted the whole upgrade, leaving the queue object store uncreated -- so no UI upload ever fired. Reuse the version-change transaction the callback provides, and bump the DB to v3 with a contains() guard so installs already corrupted by the shipped bug self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test. High / Medium - Event lock is uploads-only again: likes, comments and browsing stay open while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly freezing social interaction. Updated the event-lock spec accordingly. - get_original now excludes soft-deleted and ban-hidden uploads, and direct /media/originals/** serving is blocked, so a hidden user's originals can no longer be pulled by UUID (all originals go through the checked alias). - The upload handler reads the file field with an early-abort size cap chosen from the declared content-type, instead of buffering the entire body before the size check. Low - unban_user mirrors the ban role guard (a host can no longer unban a host/admin banned by an admin). - reset_user_pin's UPDATE is event-scoped. - Admin login returns and stores a real identity (user_id + display name) instead of a blank session. - The host user list no longer renders target-actions (ban/promote/demote/PIN) on the caller's own row, where the backend always rejected them. - /diashow gains a client-side auth guard like the other protected routes. - The join page shows the event name via a new public GET /api/v1/event. Verified: backend cargo build clean, frontend svelte-check 0 errors, full Playwright E2E suite 144 passed / 1 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,16 +33,23 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
||||
// persisted across logouts before this version.
|
||||
db = await openDB(DB_NAME, 2, {
|
||||
upgrade(database, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
|
||||
// opened a *new* transaction inside the callback, which throws InvalidStateError
|
||||
// ("A version change transaction is running") and aborts the whole upgrade —
|
||||
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
|
||||
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
|
||||
// those installs; the contains() guard recreates the missing store instead of
|
||||
// assuming createObjectStore only ever runs on a brand-new DB.
|
||||
db = await openDB(DB_NAME, 3, {
|
||||
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
// Wipe any pre-v2 entries — they have no userId field and would belong
|
||||
// to a now-indeterminate user. Safer to drop than to misattribute.
|
||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
} else if (oldVersion < 2) {
|
||||
// Existing v1 store: its entries predate the `userId` field, so drop them
|
||||
// rather than misattribute them to whoever is signed in now. Reuse the
|
||||
// active version-change transaction (never open a new one here — see above).
|
||||
// Skipped when we just created the store, which is already empty.
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,9 +18,14 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.post<{ jwt: string }>('/admin/login', { password });
|
||||
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN
|
||||
setAuth(res.jwt, null, '');
|
||||
const res = await api.post<{ jwt: string; user_id: string; display_name: string }>(
|
||||
'/admin/login',
|
||||
{ password }
|
||||
);
|
||||
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN.
|
||||
// Persist the real user id + name so the admin has an identity (own-post
|
||||
// affordances on the feed, a name on the Account page rather than "Unbekannt").
|
||||
setAuth(res.jwt, null, res.user_id, res.display_name);
|
||||
goto('/admin');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -151,6 +152,13 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Auth guard — mirror the other protected routes. Without this an
|
||||
// unauthenticated visitor lands on a permanently-loading empty slideshow
|
||||
// instead of being sent to /join.
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
showBottomNav.set(false);
|
||||
void acquireWakeLock();
|
||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken, getRole } from '$lib/auth';
|
||||
import { getToken, getRole, getUserId } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -57,6 +57,7 @@
|
||||
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
||||
|
||||
const myRole = getRole();
|
||||
const myUserId = getUserId();
|
||||
|
||||
// Generic confirm-then-run for the irreversible / privilege-changing actions
|
||||
// (promote, demote, unban, release gallery) that previously fired on one tap.
|
||||
@@ -486,7 +487,11 @@
|
||||
>
|
||||
Entsperren
|
||||
</button>
|
||||
{:else}
|
||||
{:else if user.id !== myUserId}
|
||||
<!-- Never render target-actions (promote/demote/PIN/ban) on the
|
||||
caller's own row: the backend rejects every self-action
|
||||
(self-ban / self-demote / self-PIN) with a 400, so the button
|
||||
would only ever fail. -->
|
||||
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
||||
<button
|
||||
onclick={() => (confirmAction = {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { setAuth } from '$lib/auth';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
|
||||
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
|
||||
let eventName = $state('');
|
||||
onMount(async () => {
|
||||
try {
|
||||
const ev = await api.get<{ name: string; slug: string }>('/event');
|
||||
eventName = ev.name;
|
||||
} catch {
|
||||
// Non-fatal — fall back to the generic heading if the lookup fails.
|
||||
}
|
||||
});
|
||||
|
||||
let displayName = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
@@ -164,6 +176,9 @@
|
||||
{:else}
|
||||
<!-- Normal join form -->
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
|
||||
{#if eventName}
|
||||
<p class="mb-1 text-center text-lg font-semibold text-blue-600 dark:text-blue-400" data-testid="join-event-name">{eventName}</p>
|
||||
{/if}
|
||||
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
||||
|
||||
Reference in New Issue
Block a user