fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session

Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:

HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
  no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
  banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
  /feed/delta; feed + diashow evict those users. Applied even on a truncated delta.

MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
  terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
  the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
  in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
  race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
  hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.

Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.

LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.

Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.

Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-13 21:06:28 +02:00
parent 641174717c
commit 36fe59caa5
28 changed files with 1185 additions and 302 deletions

View File

@@ -15,9 +15,19 @@ export const isAuthenticated = writable(false);
*/
export const currentPin = writable<string | null>(null);
export function getToken(): string | null {
// Guest auth lives in localStorage (persists across sessions — a guest returning to the
// event days later stays signed in). The ADMIN token lives in sessionStorage instead
// (USER_JOURNEYS §11.1): its tighter 1-day lifetime is meant to bound exposure on a shared
// "event laptop", and sessionStorage clears on tab/browser close, which localStorage defeats.
// Reads check sessionStorage FIRST so an active admin token wins over any leftover guest
// token on the same device.
function readAuth(key: string): string | null {
if (!browser) return null;
return localStorage.getItem(TOKEN_KEY);
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
}
export function getToken(): string | null {
return readAuth(TOKEN_KEY);
}
export function getPin(): string | null {
@@ -37,13 +47,11 @@ export function clearPin(): void {
}
export function getUserId(): string | null {
if (!browser) return null;
return localStorage.getItem(USER_ID_KEY);
return readAuth(USER_ID_KEY);
}
export function getDisplayName(): string | null {
if (!browser) return null;
return localStorage.getItem(DISPLAY_NAME_KEY);
return readAuth(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
@@ -69,6 +77,20 @@ export function setAuth(jwt: string, pin: string | null, userId: string, display
isAuthenticated.set(true);
}
/**
* Persist an ADMIN session in sessionStorage (USER_JOURNEYS §11.1) rather than localStorage,
* so the elevated credential does not survive a tab/browser close on a shared device — the
* point of the tighter 1-day admin token. Admins have no recovery PIN. `getToken` reads
* sessionStorage first, so this wins over any leftover guest token on the same device.
*/
export function setAdminAuth(jwt: string, userId: string, displayName?: string): void {
if (!browser) return;
sessionStorage.setItem(TOKEN_KEY, jwt);
sessionStorage.setItem(USER_ID_KEY, userId);
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
}
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
// here at import-time so they get reset on every clearAuth path — both the
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
@@ -81,11 +103,15 @@ export function onClearAuth(fn: () => void): void {
export function clearAuth(): void {
if (!browser) return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_ID_KEY);
// Clear the display name too — on a shared device the next user shouldn't see
// the previous guest's name pre-filled on /join.
localStorage.removeItem(DISPLAY_NAME_KEY);
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in
// sessionStorage; either could be present, and a stale one must not linger.
for (const store of [localStorage, sessionStorage]) {
store.removeItem(TOKEN_KEY);
store.removeItem(USER_ID_KEY);
// Clear the display name too — on a shared device the next user shouldn't see
// the previous guest's name pre-filled on /join.
store.removeItem(DISPLAY_NAME_KEY);
}
// PIN is intentionally kept so the user can recover
isAuthenticated.set(false);
// Hooks fire in registration order. Keep them dependency-free of each other —

View File

@@ -12,7 +12,7 @@
// delta into its in-memory list.
import { getToken } from './auth';
import { api } from './api';
import { api, ApiError } from './api';
import type { DeltaResponse } from './types';
type StreamTicketResponse = { ticket: string; server_time: string };
@@ -45,7 +45,9 @@ const KNOWN_EVENTS = [
'event-updated',
'export-progress',
'export-available',
'pin-reset'
'pin-reset',
// A guest asked a host to reset their PIN — hosts refresh their pending-request badge.
'pin-reset-requested'
] as const;
/**
@@ -197,7 +199,7 @@ function extractCreatedAt(data: string): string | undefined {
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
* SSE event will keep the feed moving.
*/
async function deltaFetchAndFan(since: string): Promise<void> {
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
try {
const response = await api.get<DeltaResponse>(
`/feed/delta?since=${encodeURIComponent(since)}`
@@ -206,11 +208,24 @@ async function deltaFetchAndFan(since: string): Promise<void> {
// reconnect resumes exactly where the server left off (no browser-clock skew).
lastEventTime = response.server_time;
dispatch('feed-delta', JSON.stringify(response));
} catch {
// non-fatal
} catch (e) {
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
// lose it. Retry the SAME `since` with backoff so the gap [since, now] is still
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
}
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
}
}
/** Bounded backoff for a rate-limited reconnect delta (see `deltaFetchAndFan`). */
const MAX_DELTA_RETRIES = 4;
const DELTA_RETRY_BASE_MS = 2000;
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
// `onopen` runs the delta fetch.
function handleVisibilityChange() {

View File

@@ -27,6 +27,10 @@ export interface FeedResponse {
export interface DeltaResponse {
uploads: FeedUpload[];
deleted_ids: string[];
// Users hidden (banned / uploads_hidden) since the cursor. A ban is not a soft-delete so
// it's absent from deleted_ids; the client evicts all uploads from these users, which
// replays a ban the client missed while disconnected (esp. the unattended projector).
hidden_user_ids: string[];
// True when the delta hit the backend cap and is only the newest slice of the
// gap — the client must full-refresh rather than merge (see feed-delta handler).
truncated: boolean;

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { classifyUploadStatus } from './upload-queue';
/**
* Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out:
* every 4xx except 429 was classified terminal, and a terminal item has its blob PURGED
* from IndexedDB. A 401 (a sliding session that lapsed, or a host PIN-reset) is a 4xx, so a
* guest's queued photos were irrecoverably destroyed the moment the `online` auto-resume
* fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`.
*/
describe('classifyUploadStatus', () => {
it('2xx → success', () => {
expect(classifyUploadStatus(200)).toBe('success');
expect(classifyUploadStatus(201)).toBe('success');
expect(classifyUploadStatus(299)).toBe('success');
});
it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
expect(classifyUploadStatus(401)).toBe('auth');
});
it('429 → rate_limit (back off, auto-resume)', () => {
expect(classifyUploadStatus(429)).toBe('rate_limit');
});
it('408 → transient (request timeout is retryable, not terminal)', () => {
expect(classifyUploadStatus(408)).toBe('transient');
});
it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
expect(classifyUploadStatus(400)).toBe('terminal');
expect(classifyUploadStatus(404)).toBe('terminal');
});
it('5xx / unexpected → transient (retryable, blob kept)', () => {
expect(classifyUploadStatus(500)).toBe('transient');
expect(classifyUploadStatus(502)).toBe('transient');
expect(classifyUploadStatus(503)).toBe('transient');
});
});

View File

@@ -1,6 +1,7 @@
import { openDB, type IDBPDatabase } from 'idb';
import { writable, get } from 'svelte/store';
import { getToken, getUserId } from './auth';
import { getToken, getUserId, clearAuth } from './auth';
import { onSseEvent } from './sse';
import { refreshQuota } from './quota-store';
export interface QueueItem {
@@ -8,6 +9,9 @@ export interface QueueItem {
userId: string;
fileName: string;
fileSize: number;
/** Source file's last-modified ms; part of the dedup key so two distinct photos that
* happen to share a name and byte length don't collapse into one. */
lastModified?: number;
mimeType: string;
caption: string;
hashtags: string;
@@ -50,6 +54,23 @@ function bindOnline(): void {
}
bindOnline();
// Resume the queue when the host reopens the event. A queued upload that hit a locked/
// released event kept its blob and parked as a retryable `error` (LockedError); the
// `event-opened` SSE flips it back to pending and re-drains, so a photo staged during a
// lock isn't lost across a reopen. One-way import (sse.ts never imports this module).
let sseBound = false;
function bindSse(): void {
if (sseBound || typeof window === 'undefined') return;
onSseEvent('event-opened', () => {
void (async () => {
await requeueRetriable();
await processQueue();
})();
});
sseBound = true;
}
bindSse();
/**
* Flip transient `error` items (5xx / a network drop that got marked before we could
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
@@ -141,6 +162,47 @@ class TerminalError extends Error {
*/
class NetworkError extends Error {}
/**
* The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is
* KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the
* instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume
* fires). We mark the item retryable and route to re-auth; once the user signs back in with
* the same identity (via /recover), `loadQueue` re-associates these entries by userId and
* they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob.
*/
class AuthError extends Error {}
/**
* A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen
* it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden`
* like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened`
* SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged
* as terminal and lost the moment the host reopened.
*/
class LockedError extends Error {}
/** Retry policy for an upload response status. */
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
/**
* Classify an upload HTTP status into a retry policy. Pure + exported so the
* data-loss-critical rules are unit-testable without an XHR/IndexedDB harness:
* - 401 → `auth` (session gone: KEEP the blob, re-auth — NEVER purge it)
* - 408 → `transient` (request timeout: retryable)
* - 429 → `rate_limit`(back off, auto-resume)
* - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges)
* - 2xx → `success`; 5xx/other → `transient`
* The one rule that must never regress: a 401 is `auth`, not `terminal`.
*/
export function classifyUploadStatus(status: number): UploadOutcome {
if (status >= 200 && status < 300) return 'success';
if (status === 429) return 'rate_limit';
if (status === 401) return 'auth';
if (status === 408) return 'transient';
if (status >= 400 && status < 500) return 'terminal';
return 'transient';
}
export async function loadQueue(): Promise<void> {
const database = await getDb();
const myUserId = getUserId();
@@ -173,29 +235,40 @@ export async function loadQueue(): Promise<void> {
})();
}
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
export async function addToQueue(
file: File,
caption: string,
hashtags: string
): Promise<void> {
): Promise<EnqueueResult> {
const database = await getDb();
const userId = getUserId();
if (!userId) return; // not authenticated — nothing to do
// Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than
// 'full' so the caller doesn't show a misleading "queue full" toast. Practically
// unreachable: the upload view sits behind a token guard.
if (!userId) return 'duplicate';
// Dedup: don't queue the same file twice while an identical one is still unsent
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
// (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user
// — including lastModified so two genuinely different photos sharing a name and byte
// length aren't silently collapsed into one.
const mine = get(queueItems).filter((i) => i.userId === userId);
const dup = mine.some(
(i) =>
i.fileName === file.name &&
i.fileSize === file.size &&
(i.lastModified ?? 0) === file.lastModified &&
(i.status === 'pending' || i.status === 'uploading')
);
if (dup) return;
if (dup) return 'duplicate';
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
// refuse silently rather than pile on.
// refuse and tell the caller so it can surface a "queue full" message (silent loss of a
// photo the user believed was queued is the worst outcome here).
if (mine.length >= MAX_QUEUE_ITEMS) {
const evictable = get(queueItems).find(
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
@@ -204,7 +277,7 @@ export async function addToQueue(
await database.delete(STORE_NAME, evictable.id);
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
} else {
return;
return 'full';
}
}
@@ -214,6 +287,7 @@ export async function addToQueue(
userId,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
mimeType: file.type,
caption,
hashtags,
@@ -229,6 +303,7 @@ export async function addToQueue(
userId,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
mimeType: file.type,
caption,
hashtags,
@@ -238,6 +313,7 @@ export async function addToQueue(
]);
processQueue();
return 'queued';
}
export async function retryItem(id: string): Promise<void> {
@@ -305,9 +381,20 @@ async function processQueue(): Promise<void> {
}, e.retryAfterSecs * 1000);
break;
}
if (e instanceof AuthError) {
// Dead session — stop the batch (every further item would 401 too). Items
// stay retryable with blobs intact; the re-auth flow (clearAuth) takes over.
break;
}
if (e instanceof LockedError) {
// Event locked — stop the batch (every item would hit the same lock). Items
// stay retryable with blobs intact; the `event-opened` SSE resumes them.
break;
}
if (e instanceof NetworkError) {
// Connectivity dropped mid-flight the item is back to 'pending'. Stop the
// loop; the `online` listener resumes it. No error state, no manual tap.
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
// and the `online` listener resumes it; if the failure hit while nominally
// online it's now a retryable 'error'. Either way, stop hammering here.
break;
}
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
@@ -364,37 +451,50 @@ async function uploadItem(id: string): Promise<void> {
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else if (xhr.status === 429) {
// Rate limit only (quota-full is now a distinct 413, handled below as
// terminal) — back off and auto-resume when the window lifts.
const body = (() => {
try {
const body = JSON.parse(xhr.responseText);
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
return JSON.parse(xhr.responseText);
} catch {
return null;
}
})();
switch (classifyUploadStatus(xhr.status)) {
case 'success':
resolve();
break;
case 'rate_limit': {
// Back off and auto-resume when the window lifts (quota-full is a distinct
// 413, classified 'terminal' below).
const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60;
reject(new RateLimitError(secs));
} catch {
reject(new RateLimitError(60));
break;
}
} else if (xhr.status >= 400 && xhr.status < 500) {
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
// Retrying just re-hits the same rejection forever, so mark it terminal.
let msg = 'Upload nicht möglich.';
try {
const body = JSON.parse(xhr.responseText);
if (body.message) msg = body.message;
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
} catch {
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
}
reject(new TerminalError(msg));
} else {
// 5xx / unexpected — transient, keep it retryable.
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.message || `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
case 'auth':
// Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping
// this into the terminal bucket (which purges the blob) irrecoverably
// destroyed queued photos whenever a sliding session lapsed or a host reset
// the PIN — precisely when the `online` auto-resume kicks in.
reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.'));
break;
case 'transient':
// 408 (request timeout) behaves like a network blip so it stays retryable;
// 5xx is a generic retryable error. Neither purges the blob.
if (xhr.status === 408) reject(new NetworkError('Zeitüberschreitung'));
else reject(new Error(body?.message || `HTTP ${xhr.status}`));
break;
case 'terminal': {
// A REVERSIBLE lock (event closed / gallery released) is tagged
// `uploads_locked` by the backend — keep the blob and park it retryable so
// a host reopen resumes it, instead of purging it like a permanent 4xx.
if (body?.error === 'uploads_locked') {
reject(new LockedError(body?.message || 'Event ist geschlossen.'));
break;
}
// Any other 4xx the server will keep rejecting (banned / quota).
let msg = body?.message || 'Upload nicht möglich.';
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
reject(new TerminalError(msg));
break;
}
}
});
@@ -420,12 +520,48 @@ async function uploadItem(id: string): Promise<void> {
updateItemStatus(id, 'pending');
throw e; // Propagate to processQueue for scheduling
}
if (e instanceof NetworkError) {
// No connectivity — keep the item pending and propagate so processQueue stops
// the loop (no point hammering while offline). The `online` listener resumes it.
entry.status = 'pending';
if (e instanceof LockedError) {
// Event closed / gallery released, but a host can reopen — KEEP the blob and park
// the item as retryable so it survives until reopen. The `event-opened` SSE
// (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here.
entry.status = 'error';
entry.error = e.message;
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
updateItemStatus(id, 'error', e.message);
throw e;
}
if (e instanceof AuthError) {
// Dead session — KEEP the blob (never purge on auth failure) and mark retryable so
// the file survives re-auth. Route through the app's single de-auth path (matches
// api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the
// user signs back in with the SAME identity (via /recover), the next time the upload
// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
entry.status = 'error';
entry.error = e.message;
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'error', e.message);
clearAuth();
throw e;
}
if (e instanceof NetworkError) {
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
if (offline) {
// Genuinely offline — keep the item pending; the `online` listener resumes it
// automatically with no user action.
entry.status = 'pending';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
} else {
// Network-level failure while the OS still reports online (server down,
// connection refused, TLS error, captive portal). The `online` event will
// NEVER fire in this case, so leaving it 'pending' would strand the item with
// no retry path and no spinner. Mark it retryable 'error' so the user gets a
// working "Erneut" button and a later real reconnect requeues it.
entry.status = 'error';
entry.error = 'Netzwerkfehler. Erneut versuchen.';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'error', 'Netzwerkfehler. Erneut versuchen.');
}
throw e;
}
if (e instanceof TerminalError) {

View File

@@ -15,7 +15,7 @@
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened } from '$lib/event-state-store';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
let { children } = $props();
@@ -69,7 +69,14 @@
}),
// Reflect a host closing/reopening uploads live, so the composer switches to a
// locked state immediately instead of a guest finding out via a rejected upload.
onSseEvent('event-closed', () => markClosed()),
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
// which markClosed can't tell apart — follow it with a server refresh so a release
// correctly flips `galleryReleased` too (else the composer briefly mislabels it as a
// plain lock until the next navigation).
onSseEvent('event-closed', () => {
markClosed();
void refreshEventState();
}),
onSseEvent('event-opened', () => markOpened())
);
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getRole } from '$lib/auth';
import { setAdminAuth, getRole } from '$lib/auth';
import { browser } from '$app/environment';
// Already logged in as admin → go straight to dashboard
@@ -22,10 +22,10 @@
'/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);
// Admin session goes in sessionStorage (bounded to the tab; §11.1), with 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"). No PIN for admins.
setAdminAuth(res.jwt, res.user_id, res.display_name);
goto('/admin');
} catch (e) {
if (e instanceof ApiError) {

View File

@@ -75,18 +75,33 @@
// to `new-upload` here — its payload arrives before compression finishes
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
// preview would never be picked up if we enqueued the pre-processed version first.
async function handleUploadProcessed(data: string) {
// COALESCE the fetch: a host bulk-uploading fires dozens of `upload-processed` events in
// seconds; one `/feed` fetch per event trips the per-user feed rate limit (429) and drops
// slides. Debounce into a single fetch of the newest slice instead.
let processedDebounce: ReturnType<typeof setTimeout> | null = null;
function handleUploadProcessed(data: string) {
try {
const payload = JSON.parse(data) as { upload_id: string };
if (!payload.upload_id) return;
const feed = await api.get<FeedResponse>('/feed?limit=20');
const found = feed.uploads.find((u) => u.id === payload.upload_id);
if (found) {
queue.pushLive(found);
if (!current) advance();
}
const { upload_id } = JSON.parse(data) as { upload_id: string };
if (!upload_id) return;
} catch {
// ignore — silent recovery; the next event will retry
return;
}
if (processedDebounce) clearTimeout(processedDebounce);
processedDebounce = setTimeout(() => {
processedDebounce = null;
void refreshRecentFromFeed();
}, 500);
}
async function refreshRecentFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=50');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — silent recovery; the next event or reconnect delta retries
}
}
@@ -107,10 +122,27 @@
function handleFeedDelta(data: string) {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Apply deletions + ban-hides FIRST and unconditionally: both are explicit, uncapped
// lists, so a moderated/removed photo (or a banned user's whole set) must leave the
// rotation even when the delta is truncated. This replays a `user-hidden`/delete the
// projector missed while disconnected — the reason a banned guest's slides used to
// keep cycling all night. (We can't infer removals by absence from a capped /feed
// backfill — older slides beyond the newest 200 would be falsely dropped.)
for (const id of delta.deleted_ids) {
const result = queue.remove(id, current?.id ?? null);
if (result.wasCurrent) advance();
}
for (const userId of delta.hidden_user_ids) {
const result = queue.removeByUser(userId, current?.id ?? null);
if (result.wasCurrent) advance();
}
// A truncated delta's `uploads` is only the newest slice of a larger gap; merging it
// alone would skip the older-but-still-new items in between. Backfill new uploads from
// a full feed fetch (pushLive dedupes by id, so the current slide isn't disturbed).
if (delta.truncated) {
void backfillFromFeed();
return;
}
for (const upload of delta.uploads) {
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
// later `upload-processed` still refines anything that arrives raw.
@@ -122,6 +154,20 @@
}
}
// Full-feed backfill used when a reconnect delta overflows the cap. Merges via
// pushLive (id-deduped) so a long-running projector recovers the whole gap.
async function backfillFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=200');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — the next live event keeps the show moving
}
}
function handleUserHidden(data: string) {
try {
const payload = JSON.parse(data) as { user_id: string };

View File

@@ -231,6 +231,20 @@
onSseEvent('feed-delta', (data) => {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Evict removed content FIRST and unconditionally — deletions AND ban-hides are
// explicit, uncapped lists, so they apply even on a truncated delta (the stale-pill
// refresh below only prunes page 1, so a banned user's cards beyond page 1 would
// otherwise linger). Replays a `user-hidden`/delete missed while the tab was hidden.
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
if (selectedUpload && dead.has(selectedUpload.id)) selectedUpload = null;
}
if (delta.hidden_user_ids.length) {
const hidden = new Set(delta.hidden_user_ids);
uploads = uploads.filter((u) => !hidden.has(u.user_id));
if (selectedUpload && hidden.has(selectedUpload.user_id)) selectedUpload = null;
}
if (delta.truncated) {
// Missed more than the backend delta cap while backgrounded — the
// delta is only the newest slice, so merging would leave a silent gap
@@ -245,10 +259,6 @@
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
}
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
}
// A delta reconciles new uploads and deletions, but not like/comment
// counts that changed on already-visible cards while we were
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges

View File

@@ -3,7 +3,8 @@
import { getToken, getRole, getUserId } from '$lib/auth';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { onSseEvent } from '$lib/sse';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte';
@@ -34,12 +35,38 @@
created_at: string;
}
interface ExportJob {
status: string;
progress_pct: number;
}
interface ExportStatusDto {
released: boolean;
zip: ExportJob | null;
html: ExportJob | null;
}
let event = $state<EventStatus | null>(null);
let users = $state<UserSummary[]>([]);
let pinResetRequests = $state<PinResetRequest[]>([]);
let exportInfo = $state<ExportStatusDto | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Live keepsake state for the host dashboard: are both halves done, and if not, how far.
let exportReady = $derived(
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
);
let exportGenerating = $derived(
!!exportInfo?.released && !exportReady &&
(exportInfo?.zip?.status !== 'failed' || exportInfo?.html?.status !== 'failed')
);
let exportProgress = $derived(
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
);
// SSE unsubscribers, torn down on destroy.
let sseOff: Array<() => void> = [];
// Collapsible section state
let statsOpen = $state(true);
let settingsOpen = $state(true);
@@ -85,8 +112,13 @@
confirmAction = null;
}
/** Mirrors backend `handlers::host::reset_user_pin` authorisation rules. */
function canResetPinFor(target: UserSummary): boolean {
/**
* Mirrors the backend authorisation rules shared by ban / unban / PIN-reset / set-role:
* a plain host may only act on GUESTS; only an admin may act on a host; nobody may act on
* an admin. Used to hide moderation buttons that would always 403 — a non-admin host must
* not see Sperren/Degradieren/PIN on a peer-host row (they'd tap it and get a bare 403).
*/
function canModerate(target: UserSummary): boolean {
if (target.role === 'admin') return false;
if (myRole === 'admin') return true;
if (myRole === 'host') return target.role === 'guest';
@@ -115,16 +147,33 @@
return;
}
await reload();
// Live updates so the dashboard doesn't need a manual reload:
// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
// the other host's stale row disappears instead of yielding a conflicting PIN).
// - export-progress/-available: keepsake generation moves → refresh the status line.
sseOff = [
onSseEvent('pin-reset-requested', () => void refreshPinRequests()),
onSseEvent('pin-reset', () => void refreshPinRequests()),
onSseEvent('export-progress', () => void refreshExportStatus()),
onSseEvent('export-available', () => void refreshExportStatus())
];
});
onDestroy(() => {
for (const off of sseOff) off();
});
async function reload() {
loading = true;
error = null;
try {
[event, users, pinResetRequests] = await Promise.all([
[event, users, pinResetRequests, exportInfo] = await Promise.all([
api.get<EventStatus>('/host/event'),
api.get<UserSummary[]>('/host/users'),
api.get<PinResetRequest[]>('/host/pin-reset-requests')
api.get<PinResetRequest[]>('/host/pin-reset-requests'),
api.get<ExportStatusDto>('/export/status')
]);
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
@@ -133,6 +182,24 @@
}
}
/** Refetch just the pending PIN-reset requests (live badge; avoids a full-page reload). */
async function refreshPinRequests() {
try {
pinResetRequests = await api.get<PinResetRequest[]>('/host/pin-reset-requests');
} catch {
/* non-fatal — the next manual reload picks it up */
}
}
/** Refetch just the keepsake export status (live progress/ready). */
async function refreshExportStatus() {
try {
exportInfo = await api.get<ExportStatusDto>('/export/status');
} catch {
/* non-fatal */
}
}
async function resetPinForRequest(req: PinResetRequest) {
try {
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
@@ -152,22 +219,50 @@
}
}
async function toggleEventLock() {
if (!event) return;
async function reopenEvent() {
try {
if (event.uploads_locked) {
await api.post('/host/event/open');
toast('Uploads wurden wieder geöffnet.', 'success');
} else {
await api.post('/host/event/close');
toast('Uploads wurden gesperrt.', 'success');
}
await api.post('/host/event/open');
toast('Uploads wurden wieder geöffnet.', 'success');
await reload();
} catch (e: unknown) {
toastError(e);
}
}
function toggleEventLock() {
if (!event) return;
if (event.uploads_locked) {
// Reopening after a release INVALIDATES the published keepsake: it clears the
// release + ready flags, so every guest's download 404s until the host re-releases.
// That's destructive and easy to trigger by accident ("let a few more photos in"),
// so gate it behind a danger confirm — but a plain lock (not yet released) reopens
// with no ceremony.
if (event.export_released) {
confirmAction = {
title: 'Galerie-Freigabe zurücknehmen?',
message:
'Wenn du die Uploads wieder öffnest, wird die veröffentlichte Galerie zurückgezogen. ' +
'Gäste können das Keepsake erst nach einer erneuten Freigabe wieder herunterladen.',
confirmLabel: 'Wieder öffnen',
tone: 'danger',
run: reopenEvent
};
return;
}
void reopenEvent();
} else {
void (async () => {
try {
await api.post('/host/event/close');
toast('Uploads wurden gesperrt.', 'success');
await reload();
} catch (e: unknown) {
toastError(e);
}
})();
}
}
async function releaseGallery() {
try {
await api.post('/host/gallery/release');
@@ -175,6 +270,10 @@
await reload();
} catch (e: unknown) {
toastError(e);
// Release marks `export_released_at` before enqueuing the workers; if that second
// step errored the event is already released, so reconcile the UI (otherwise the
// button still reads "Galerie freigeben" and a retry just 409s "bereits freigegeben").
await reload();
}
}
@@ -310,7 +409,8 @@
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
Galerie, Diashow und Export, und Hochladen, Liken und Kommentieren werden blockiert. Der
Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
</p>
<div class="flex gap-2">
<button
@@ -409,7 +509,7 @@
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4">
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
</div>
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
@@ -470,6 +570,27 @@
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
</div>
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and
downloads 404 until they're done — surface it so a host doesn't announce
"released!" while guest downloads still fail. -->
{#if event.export_released}
<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
{#if exportGenerating}
<p class="font-medium text-amber-700 dark:text-amber-300">Keepsake wird erstellt… {exportProgress}%</p>
<div class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-full rounded-full bg-amber-500 transition-all" style="width: {exportProgress}%"></div>
</div>
{:else if exportReady}
<p class="flex items-center justify-between gap-2">
<span class="font-medium text-green-700 dark:text-green-300">Keepsake ist bereit.</span>
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
</p>
{:else}
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen. Öffne die Uploads erneut und gib die Galerie neu frei.</p>
{/if}
</div>
{/if}
</div>
</div>
@@ -529,6 +650,9 @@
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
{#if user.role !== 'admin'}
{#if user.is_banned}
<!-- Only show Entsperren to someone allowed to act on this user (a plain
host can't unban a peer host — that's an admin-only action, F1). -->
{#if canModerate(user)}
<button
onclick={() => (confirmAction = {
title: 'Sperre aufheben?',
@@ -541,6 +665,7 @@
>
Entsperren
</button>
{/if}
{: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
@@ -560,8 +685,9 @@
Host
</button>
{/if}
{#if user.role === 'host'}
<!-- Hosts may demote other Hosts (never themselves); backend enforces. -->
{#if user.role === 'host' && myRole === 'admin'}
<!-- Only an admin may demote a host (F1). A plain host demoting a
peer host is a 403 on the backend, so the button is hidden. -->
<button
onclick={() => (confirmAction = {
title: 'Zum Gast degradieren?',
@@ -575,7 +701,7 @@
Degradieren
</button>
{/if}
{#if canResetPinFor(user)}
{#if canModerate(user)}
<button
onclick={() => askResetPin(user)}
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
@@ -583,12 +709,14 @@
PIN zurücksetzen
</button>
{/if}
<button
onclick={() => openBanModal(user)}
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
>
Sperren
</button>
{#if canModerate(user)}
<button
onclick={() => openBanModal(user)}
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
>
Sperren
</button>
{/if}
{/if}
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto, afterNavigate } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin, getToken } from '$lib/auth';
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
import { browser } from '$app/environment';
import IconButton from '$lib/components/IconButton.svelte';
@@ -28,6 +28,26 @@
let pin = $state('');
let error = $state('');
let loading = $state(false);
let pinRequestLoading = $state(false);
let pinRequestSent = $state(false);
// Forgot the PIN entirely (never noted it, or a host reset it): ask a host to reset it.
// The endpoint always 204s (no name enumeration), so optimistically confirm regardless.
async function requestPinReset() {
if (!displayName.trim()) {
error = 'Bitte gib zuerst deinen Namen ein.';
return;
}
pinRequestLoading = true;
try {
await api.post('/recover/request', { display_name: displayName.trim() });
} catch {
// Non-fatal (rate limit etc.) — still confirm so the user isn't stuck.
} finally {
pinRequestLoading = false;
pinRequestSent = true;
}
}
// Pre-fill PIN from localStorage if available
if (browser) {
@@ -53,6 +73,10 @@
} catch (e) {
if (e instanceof ApiError) {
error = e.message;
// A wrong PIN here often means the locally-cached PIN is stale (a host reset it
// while this device was offline and missed the `pin-reset` SSE). Drop the cached
// value so it doesn't keep pre-filling the field with the dead PIN.
if (e.status === 401) clearPin();
} else {
error = 'Ein Fehler ist aufgetreten.';
}
@@ -124,6 +148,24 @@
</button>
</form>
<!-- PIN lost entirely (never noted, or reset by a host while offline): a soft dead-end
without this — offer the host-reset request path. -->
{#if pinRequestSent}
<p class="mt-4 text-center text-sm text-green-700 dark:text-green-400" data-testid="recover-pin-request-sent">
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — komm danach mit der neuen PIN zurück.
</p>
{:else}
<button
type="button"
onclick={requestPinReset}
disabled={pinRequestLoading}
data-testid="recover-request-pin-reset"
class="mt-4 w-full text-center text-sm text-blue-600 underline disabled:opacity-50 dark:text-blue-400"
>
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Noch kein Konto?
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Neu beitreten</a>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue';
import { toast } from '$lib/toast-store';
import { showBottomNav } from '$lib/ui-store';
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
import { get } from 'svelte/store';
@@ -98,8 +99,18 @@
submitting = true;
vibrate(10);
const hashtagsString = captionTags.join(',');
let full = 0;
for (const sf of stagedFiles) {
await addToQueue(sf.file, caption, hashtagsString);
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
toast(
`Warteschlange voll ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
'error',
6000
);
}
clearPending();
goto('/feed');