diff --git a/frontend/src/app.html b/frontend/src/app.html index 638c779..360830a 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -78,7 +78,10 @@ '' + ''; var btn = document.getElementById('app-boot-reload'); - if (btn) btn.addEventListener('click', function () { location.reload(); }); + if (btn) + btn.addEventListener('click', function () { + location.reload(); + }); }, 15000); })(); @@ -151,7 +154,12 @@ .app-boot__fail { max-width: 20rem; text-align: center; - font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; } .app-boot__fail-title { margin: 0 0 0.5rem; @@ -198,7 +206,12 @@ padding: 2rem; text-align: center; background: #faf9f7; - font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; color: #545350; } html.dark .app-boot__noscript { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dab35b3..6d51f27 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,11 +15,28 @@ export class ApiError extends Error { const TIMEOUT_MS = 20_000; -/** Pages that ARE the recovery flow — redirecting from them would loop. */ -const AUTH_ROUTES = ['/join', '/recover']; +/** + * Pages that ARE a credential-entry form — redirecting away from them would loop. + * + * `/admin/login` belongs here: a mistyped admin password 401s, the hard redirect below fired, and + * the admin was thrown off their own login form — destroying the error message that would have + * told them what happened. The redirect exists to rescue a session that died mid-app; someone + * actively typing credentials into a login form does not need rescuing. + * + * It must be `/admin/login` and NOT the `/admin` prefix. `/admin` would also match the dashboard, + * and suppressing `clearAuth()` there is a trap: the dead token stays resident, `admin/+page` + * bounces to `/admin/login`, and that page's `getRole() === 'admin'` guard — which decodes the JWT + * without checking `exp` — bounces straight back. The result is an unbreakable flip-flop with no + * way to reach the login form short of clearing site data, on the one device running the party. + */ +const AUTH_ROUTES = ['/join', '/recover', '/admin/login']; /** - * Send a guest whose session died back to the join screen. + * Send someone whose session died back to the right credential form. + * + * Staff go to `/admin/login`, guests to `/join`. Sending an admin whose session expired to the + * guest join screen is a dead end: they do not have a name and PIN to type, and the screen gives + * them no route back to their own login. * * Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already * imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would @@ -29,9 +46,31 @@ const AUTH_ROUTES = ['/join', '/recover']; */ function redirectToJoin(): void { if (typeof window === 'undefined') return; + if (onAuthRoute()) return; + const staff = window.location.pathname.startsWith('/admin'); + window.location.assign(staff ? '/admin/login' : '/join'); +} + +/** + * Are we currently ON a credential-entry page? + * + * A 401 means two completely different things depending on the answer, and treating them alike + * destroyed data (H14). Off these pages it means "your session died" — clear it and rescue the + * guest. ON them it means "the credentials you just typed were wrong", which is ordinary form + * validation and must change no stored state at all. + * + * The concrete failure: an authenticated guest taps "Gerät wechseln", mistypes their own name, and + * the backend answers 401 (deliberately identical for a wrong PIN and an unknown name — that + * indistinguishability is an anti-enumeration property, see `recover`'s dummy bcrypt, so it must not + * be "fixed" by making the responses differ). `clearAuth()` then threw away their working token, + * and the recover page additionally called `clearPin()` — which discarded the ONLY copy of their + * PIN, since localStorage is where it lives and the server holds only the bcrypt. One typo, and + * both their session and their credential were gone; rejoining under the same name 409s. + */ +function onAuthRoute(): boolean { + if (typeof window === 'undefined') return false; const path = window.location.pathname; - if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return; - window.location.assign('/join'); + return AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`)); } async function request(method: string, path: string, body?: unknown): Promise { @@ -106,7 +145,7 @@ async function request(method: string, path: string, body?: unknown): Promise // An expired/invalid token (401) clears the dead session. Banned users are // NOT logged out — they keep read access by design (USER_JOURNEYS §10) and // simply get a 403 "gesperrt" toast on writes. - if (res.status === 401) { + if (res.status === 401 && !onAuthRoute()) { clearAuth(); // Clearing auth alone leaves the guest stranded: the bottom nav and FAB are // gated on `isAuthenticated` so they simply vanish, route guards only run in diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 48bfded..e5a478b 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -112,7 +112,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string): // 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 +// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps // clearAuth the single source of truth without baking dependencies on every // downstream store into this module (which would create circular imports). const clearAuthHooks: Array<() => void> = []; diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index 3a2c2a3..b0bc777 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -61,6 +61,9 @@ const KNOWN_EVENTS = [ 'new-comment', 'comment-deleted', 'user-hidden', + // The mirror of `user-hidden`: a host lifted a ban, so the guest's uploads return to every + // feed and the projector, and their own parked upload queue resumes. + 'user-shown', 'event-closed', 'event-opened', 'event-updated', @@ -144,7 +147,9 @@ export function connectSse(): void { for (const eventName of KNOWN_EVENTS) { eventSource.addEventListener(eventName, (e) => { noteStreamActivity(); - dispatch(eventName, (e as MessageEvent).data); + const data = (e as MessageEvent).data; + noteDelivered(eventName, data); + dispatch(eventName, data); }); } @@ -191,6 +196,10 @@ export function disconnectSse(): void { eventSource.close(); eventSource = null; } + // A new stream has delivered nothing, so anything the next delta returns is genuinely + // undelivered as far as THIS connection is concerned. Keeping the old set would suppress the + // liveness signal for content that arrived during the gap — the opposite failure to H1. + forgetDelivered(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -305,6 +314,78 @@ function dispatch(eventType: string, data: string): void { } } +/** + * Ids the LIVE STREAM has actually delivered to us — the evidence the liveness backstop needs. + * + * The backstop asks "did the poll find something the stream never pushed?", and the old answer was + * simply "did the poll return any rows?". Those are not the same question, and on a live event they + * diverge constantly: `dispatch` advances the cursor to an upload's `created_at`, which is always + * EARLIER than the `server_time` the previous delta stored, so the cursor rewinds onto + * `feed_delta`'s deliberately inclusive `>=` boundary and the poll re-returns an upload the stream + * had already delivered a moment ago. Row count > 0, so the backstop concluded the socket was dead + * and tore down a perfectly healthy stream — then reset `reconnectAttempt` to 0, bypassing the + * jittered backoff. At 100 guests that is roughly one reconnect per second, sustained, all evening, + * each costing ~10 queries. + * + * Bounded FIFO: an event runs for hours and this must not grow without limit. The cap only needs to + * exceed what one delta window can return (`DELTA_LIMIT` server-side), because anything older than + * the current window cannot be re-returned as "new". + */ +const DELIVERED_MEMORY = 500; +const deliveredIds: string[] = []; +const deliveredSet = new Set(); + +function rememberDelivered(id: string): void { + if (deliveredSet.has(id)) return; + deliveredSet.add(id); + deliveredIds.push(id); + if (deliveredIds.length > DELIVERED_MEMORY) { + const evicted = deliveredIds.shift(); + if (evicted !== undefined) deliveredSet.delete(evicted); + } +} + +/** Record whatever ids a stream payload carried, so a later delta can be recognised as a repeat. + * + * Only the id that IS the thing the liveness check tests, per event — not every id field present. + * + * This used to harvest `id`, `upload_id` and `user_id` from every payload, which quietly disarmed + * two thirds of the backstop. `new-upload` carries `id` + `user_id`, and `like-update` / + * `new-comment` carry `upload_id` + `user_id`, so by the time anything was deleted or anyone was + * banned their ids were already in `deliveredSet` — recorded from ordinary traffic about content + * that was still perfectly live. `carried`'s `deleted_ids` and `hidden_user_ids` clauses were then + * false essentially always. + * + * The cost: a socket that goes half-open (a phone roaming APs leaves `readyState === OPEN`, so the + * cheap check misses it) is only noticed once a genuinely NEW upload appears. A host moderating + * three photos, or banning a guest, produced a delta whose every id was "already delivered" — so + * the stream stayed dead and the host kept moderating into a feed nobody's app was listening to. + */ +function noteDelivered(eventName: string, data: string): void { + try { + const p = JSON.parse(data) as { id?: unknown; upload_id?: unknown; user_id?: unknown }; + // Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id, + // ban-hides by user id. + const relevant = + eventName === 'new-upload' || eventName === 'upload-processed' + ? p.id + : eventName === 'upload-deleted' + ? (p.upload_id ?? p.id) + : eventName === 'user-hidden' || eventName === 'user-shown' + ? p.user_id + : undefined; + if (typeof relevant === 'string') rememberDelivered(relevant); + } catch { + // non-JSON payload — nothing to record + } +} + +/** Reset on disconnect: a fresh stream has delivered nothing yet. */ +function forgetDelivered(): void { + deliveredIds.length = 0; + deliveredSet.clear(); +} + /** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */ function extractCreatedAt(data: string): string | undefined { try { @@ -333,10 +414,18 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise { // reconnect resumes exactly where the server left off (no browser-clock skew). lastEventTime = response.server_time; dispatch('feed-delta', JSON.stringify(response)); + // "Did the poll find something the STREAM never delivered?" — not "did it return rows?". + // See `deliveredIds`: on a live event the cursor rewinds onto the inclusive `>=` boundary + // and re-returns uploads the stream already pushed, so a row count made every healthy + // stream look dead and produced a sustained reconnect storm. + // + // Ids the stream delivered while we were connected are excluded. Anything genuinely new — + // including everything that arrived while the socket was half-open — still counts, which is + // the signal this backstop exists for. return ( - response.uploads.length > 0 || - response.deleted_ids.length > 0 || - response.hidden_user_ids.length > 0 + response.uploads.some((u) => !deliveredSet.has(u.id)) || + response.deleted_ids.some((id) => !deliveredSet.has(id)) || + response.hidden_user_ids.some((id) => !deliveredSet.has(id)) ); } catch (e) { // A throttled delta (429) must NOT be silently dropped: live events keep advancing diff --git a/frontend/src/lib/upload-queue.test.ts b/frontend/src/lib/upload-queue.test.ts index a8540da..838c7ad 100644 --- a/frontend/src/lib/upload-queue.test.ts +++ b/frontend/src/lib/upload-queue.test.ts @@ -142,7 +142,6 @@ describe('shouldAbortForStall', () => { expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false); expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true); }); - }); /** diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts index 3fa6550..d8c66fc 100644 --- a/frontend/src/lib/upload-queue.ts +++ b/frontend/src/lib/upload-queue.ts @@ -215,6 +215,21 @@ interface QueueEntry { * Cleared by `retryItem` — an explicit tap is the guest changing their mind. */ cancelled?: boolean; + /** + * Parked waiting for a specific host action, with the blob intact. + * + * Both values are reversible 403s whose answer cannot change without somebody deciding to + * change it, which makes automatic retries pure waste: they re-pushed the whole photo over + * cellular on every budget refill for the rest of the night while the guest was told to tap + * a camera button that 403s. + * + * - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`. + * - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`. + * + * An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try + * anyway, and if the condition still holds the next response simply re-parks it. + */ + parkedFor?: 'reopen' | 'unban'; blob?: Blob; } @@ -266,7 +281,7 @@ onClearAuth(() => queueItems.set([])); let sseBound = false; function bindSse(): void { if (sseBound || typeof window === 'undefined') return; - const resume = (options: { resetAttempts?: boolean } = {}) => { + const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => { void (async () => { await requeueRetriable(options); await processQueue(); @@ -275,7 +290,20 @@ function bindSse(): void { // A reopen is a deliberate host action that changes the server's answer, so it's fair to // give parked items a fresh retry budget. A plain reconnect is not — that's the signal // that fires over and over on a flapping AP. - onSseEvent('event-opened', () => resume({ resetAttempts: true })); + onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' })); + // An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to + // everyone (every feed needs to un-hide that user's photos), so check it is actually us + // before resuming — otherwise one guest's unban would resume every OTHER banned guest's + // queue straight into another 403. + onSseEvent('user-shown', (payload) => { + let userId: unknown; + try { + userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id; + } catch { + return; + } + if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' }); + }); onSseEvent('feed-delta', () => resume()); sseBound = true; } @@ -293,8 +321,14 @@ bindSse(); * * `resetAttempts` is for signals that are positive evidence the blocking condition changed * (the host reopening the event), where starting the budget over is warranted. + * + * `release` names the host action that just happened, and un-parks only the items that were + * waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and + * an unban must not resume uploads into a released gallery — both would just 403 again. */ -async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise { +async function requeueRetriable( + options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {} +): Promise { const myUserId = getUserId(); const all = await storeGetAll(); const now = Date.now(); @@ -306,6 +340,13 @@ async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Prom // not even on `resetAttempts` (the host reopening the event says nothing about whether // this guest still wants this photo sent). Only `retryItem` clears it. if (entry.cancelled) continue; + // Parked waiting on a host action. Only the matching signal releases it, so a plain + // reconnect leaves it alone instead of re-pushing the photo at a server whose answer + // cannot have changed. A manual "Erneut" bypasses this via `retryItem`. + if (entry.parkedFor) { + if (entry.parkedFor !== options.release) continue; + entry.parkedFor = undefined; + } if (options.resetAttempts) { entry.attempts = 0; entry.nextAttemptAt = undefined; @@ -612,6 +653,31 @@ class AuthError extends Error {} */ class LockedError extends Error {} +/** + * The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every + * blob-preserving code path below keeps treating it as a reversible lock (the host *can* still + * reopen, and losing a photo is the worst outcome). + * + * What differs is the retry policy. A closed event is a pause the host means to undo, so + * auto-resuming on reconnect is right. A released gallery is the end of the event, and in the + * normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over + * cellular on every budget refill, forever, for an answer that will not change, while the guest + * is told to tap a camera button that 403s. Items parked this way sit still (see + * `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest + * retries by hand. + */ +class ReleasedError extends LockedError {} + +/** + * The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives: + * `unban_user` exists and the host's confirm copy promises the photos come back, which the old + * generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row + * moved to `blocked`, and `blocked` has no retry button). + * + * Parks still like `ReleasedError` and waits for `user-shown`. + */ +class BannedError extends LockedError {} + /** Retry policy for an upload response status. */ export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal'; @@ -656,11 +722,36 @@ export function classifyUploadStatus(status: number): UploadOutcome { export function isReversibleLock(status: number, errorCode: unknown): boolean { return ( errorCode === 'uploads_locked' || + errorCode === 'gallery_released' || + // A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging + // the blob here made that promise impossible to keep for anything mid-flight. + errorCode === 'user_banned' || errorCode === 'quota_exceeded' || (status === 403 && errorCode !== 'forbidden') ); } +/** + * Within the reversible-lock bucket, is this the END of the event rather than a pause? + * + * `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host + * reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure + + * exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets + * re-pushed over cellular all night. + */ +export function isGalleryReleased(errorCode: unknown): boolean { + return errorCode === 'gallery_released'; +} + +/** + * Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on + * its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the + * photo on every reconnect at a guest who is currently not allowed to upload. + */ +export function isUserBanned(errorCode: unknown): boolean { + return errorCode === 'user_banned'; +} + /** * Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the * field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried @@ -716,6 +807,35 @@ export async function loadQueue(): Promise { })(); } +/** + * Release parked items whose blocking condition is already over, using the authoritative state + * the app fetches at boot. + * + * `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE + * `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the + * host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they + * close the app at the end of the night, and the host lifts the ban or reopens uploads the next + * morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast + * had promised "wird gesendet, sobald die Sperre aufgehoben ist". + * + * Called once per boot with what `/me/context` and the event state actually say, so a park can + * never outlive the condition it was waiting on. Cheap: a no-op unless something is parked. + */ +export async function releaseResolvedParks(state: { + banned: boolean; + uploadsOpen: boolean; +}): Promise { + // Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says + // nothing about whether the gallery reopened, and vice versa. + if (!state.banned) { + await requeueRetriable({ resetAttempts: true, release: 'unban' }); + } + if (state.uploadsOpen) { + await requeueRetriable({ resetAttempts: true, release: 'reopen' }); + } + await processQueue(); +} + /** 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'; @@ -813,6 +933,10 @@ export async function retryItem(id: string): Promise { // And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped // themselves is them changing their mind. entry.cancelled = false; + // Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may + // have reopened or unbanned without this device seeing the SSE). If the condition still + // holds the next response re-parks it, so this cannot become a loop. + entry.parkedFor = undefined; await storePut(entry); queueItems.update((items) => @@ -1124,7 +1248,16 @@ async function uploadItem(id: string): Promise { // portal) must NOT purge the blob — losing a photo is the worst outcome, and // 403 is the reversible-lock status here. if (isReversibleLock(xhr.status, body?.error)) { - settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.'))); + const msg = body?.message || 'Event ist geschlossen.'; + settle(() => + reject( + isGalleryReleased(body?.error) + ? new ReleasedError(msg) + : isUserBanned(body?.error) + ? new BannedError(msg) + : new LockedError(msg) + ) + ); break; } // Any other 4xx the server will keep rejecting (banned, too large, wrong @@ -1190,20 +1323,31 @@ async function uploadItem(id: string): Promise { // the hourly media reclaim puts them back under it). KEEP the blob and park the item // as retryable so it survives until then. `event-opened` and the `feed-delta` // reconnect both auto-resume it; a manual "Erneut" also works. Never purge here. - const exhausted = chargeAttempt(entry); + // Neither a release nor a ban lifts on its own, so charging an attempt — and with it + // the backoff ladder and budget refill that drive automatic re-pushes — buys nothing + // but bandwidth. Park those still and wait for the host action. + const parkedFor: 'reopen' | 'unban' | undefined = + e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined; + const exhausted = parkedFor ? false : chargeAttempt(entry); entry.status = 'error'; - entry.error = withRetryHint(e.message, exhausted); + entry.parkedFor = parkedFor; + entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted); await storePut(entry); updateItemStatus(id, 'error', entry.error); // Say it out loud. The queue list is only mounted on /upload and the composer sends // the guest straight to /feed, so this message otherwise lands in a store that // nothing on screen renders — the photo just never appears and the guest, with no // operator to ask, assumes it worked. - toast( - `${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`, - 'warning', - 6000 - ); + // + // For a release, be explicit that the photo is NOT lost and NOT coming back on its + // own — that is the whole difference the guest needs to act on. + const parkedHint = + parkedFor === 'reopen' + ? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.' + : parkedFor === 'unban' + ? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.' + : ' Du findest den Upload über den Kamera-Button.'; + toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000); throw e; } if (e instanceof AuthError) { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c2d1e46..36c6d56 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,7 +9,13 @@ import Toaster from '$lib/components/Toaster.svelte'; import { showBottomNav } from '$lib/ui-store'; import { isAuthenticated } from '$lib/auth'; - import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue'; + import { + queueItems, + isProcessing, + loadQueue, + rateLimitRetryAt, + releaseResolvedParks + } from '$lib/upload-queue'; import { privacyNote } from '$lib/privacy-note-store'; import { refreshQuota } from '$lib/quota-store'; import { onSseEvent } from '$lib/sse'; @@ -88,6 +94,16 @@ galleryReleased: ctx.gallery_released }); isBanned.set(ctx.is_banned); + // Now that we know the AUTHORITATIVE state, release anything the queue parked + // waiting on a host action that has already happened. The live `user-shown` / + // `event-opened` events only reach a tab that was open when the host acted, and + // the usual sequence is the other way round: the guest closes the app, the host + // lifts the ban or reopens uploads later. Without this the photo stays parked + // forever, which is exactly the "my pictures never sent" the host gets asked about. + void releaseResolvedParks({ + banned: ctx.is_banned, + uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released + }); } catch { // Cross-cutting hydration on boot — failure is non-fatal; users without // a session land on /join anyway, and the per-page mount will retry. @@ -115,8 +131,7 @@ // `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards // from every feed), so only OUR id means us. Without this, `isBanned` was seeded once // on boot and never moved — a guest banned mid-party kept the full UI and learned - // about it one 403 toast at a time, which reads as the app being broken. The ban is - // one-way here on purpose: an unban has no SSE, and the next `/me/context` clears it. + // about it one 403 toast at a time, which reads as the app being broken. onSseEvent('user-hidden', (data) => { try { const payload = JSON.parse(data) as { user_id: string }; @@ -125,6 +140,18 @@ // Malformed payload — discard; nothing actionable for the user. } }), + // And the mirror. This used to be one-way ("an unban has no SSE, and the next + // `/me/context` clears it") because `unban_user` broadcast nothing at all — so a + // guest whose ban was lifted kept the banned UI until they happened to reload, + // which for a PWA with no URL bar is not a thing they can easily do. + onSseEvent('user-shown', (data) => { + try { + const payload = JSON.parse(data) as { user_id: string }; + if (payload.user_id === getUserId()) isBanned.set(false); + } catch { + // Malformed payload — discard; nothing actionable for the user. + } + }), // 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. // `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock), diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 1334ac1..2f3fbce 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -3,7 +3,7 @@ import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth'; import { role, setRole } from '$lib/role-store'; import { clearQueue } from '$lib/upload-queue'; - import { api } from '$lib/api'; + import { api, ApiError } from '$lib/api'; import { onMount, onDestroy } from 'svelte'; import { dataMode } from '$lib/data-mode-store'; import { themePreference, type ThemePreference } from '$lib/theme-store'; @@ -14,6 +14,7 @@ import { avatarPalette, initials } from '$lib/avatar'; import ConfirmSheet from '$lib/components/ConfirmSheet.svelte'; import { vibrate } from '$lib/haptics'; + import { toast } from '$lib/toast-store'; import type { MeContextDto } from '$lib/types'; let displayName = $state(null); @@ -124,6 +125,40 @@ goto('/join'); } + let deleteConfirmOpen = $state(false); + let deleting = $state(false); + + /** + * Erase this account. Unlike `handleLogout`, the server call is NOT best-effort — if it fails + * we must keep the guest signed in and say so, because clearing local auth on a failed delete + * would leave them believing their photos were gone while every one of them is still live. + */ + async function handleDeleteAccount() { + if (deleting) return; + deleting = true; + try { + await api.delete('/me'); + } catch (e) { + deleteConfirmOpen = false; + deleting = false; + toast( + e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen. Bitte versuch es erneut.', + 'error', + 6000 + ); + return; + } + // Only now is it safe to tear down locally. The queue must go too: its blobs are this + // guest's photos, and leaving them would re-upload everything on the next session. + try { + await clearQueue(); + } catch { + /* best-effort cleanup */ + } + clearAuth(); + goto('/join'); + } + function formatDate(d: Date): string { return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); } @@ -517,7 +552,13 @@ + + + + @@ -624,3 +691,15 @@ onConfirm={() => handleLogout(leaveEverywhere)} onCancel={() => (leaveConfirmOpen = false)} /> + + + (deleteConfirmOpen = false)} +/> diff --git a/frontend/src/routes/diashow/+page.svelte b/frontend/src/routes/diashow/+page.svelte index 344696c..dfaf399 100644 --- a/frontend/src/routes/diashow/+page.svelte +++ b/frontend/src/routes/diashow/+page.svelte @@ -195,12 +195,12 @@ clearTimeout(timer); action(); }; - // Always strictly less than the dwell — see PRELOAD_HEADROOM_MS. - const preloadBudget = Math.max( - 250, - Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS) - ); - timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget); + // Always strictly less than the dwell — see PRELOAD_HEADROOM_MS. + const preloadBudget = Math.max( + 250, + Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS) + ); + timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget); const pre = new Image(); pre.src = candidates[i]; pre.decode().then( @@ -445,6 +445,12 @@ unsubs.push(onSseEvent('upload-processed', handleUploadProcessed)); unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted)); unsubs.push(onSseEvent('user-hidden', handleUserHidden)); + // An unban restores that guest's photos to the eligible set. The periodic `reconcile` + // below would pick this up on its own, but this is the projector nobody is standing at — + // waiting a whole interval to un-hide photos the host has just decided are fine again is + // needlessly visible. A full reconcile is the only correct response anyway: the hidden + // cards were dropped from local state, so there is nothing to restore in place. + unsubs.push(onSseEvent('user-shown', () => void reconcile())); unsubs.push(onSseEvent('feed-delta', handleFeedDelta)); // Open the stream ourselves — a kiosk/projector loads /diashow directly (never // via /feed), so we can't rely on another page having opened the singleton @@ -515,8 +521,8 @@
- Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische - Bildschirmsperre am Gerät deaktivieren. + Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische Bildschirmsperre + am Gerät deaktivieren.
{/if} diff --git a/frontend/src/routes/export/+page.svelte b/frontend/src/routes/export/+page.svelte index a5fc28e..4b6abe2 100644 --- a/frontend/src/routes/export/+page.svelte +++ b/frontend/src/routes/export/+page.svelte @@ -126,9 +126,7 @@ // they did, the guest got a green success toast, a consumed single-use ticket, one // of three daily slots gone, and nothing in their Downloads. Now the mint fails // honestly and lands in the `catch` below. - const { ticket } = await api.post<{ ticket: string }>( - `/export/ticket?kind=${kind}` - ); + const { ticket } = await api.post<{ ticket: string }>(`/export/ticket?kind=${kind}`); downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`; // An iframe download produces NO visible change: no spinner, no navigation, and // on mobile often no browser chrome either. Without a word here the guest cannot diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index d855ecb..753fccf 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -308,7 +308,11 @@ unsubscribers.push( onSseEvent('new-upload', (data) => { try { - const upload: FeedUpload = JSON.parse(data); + // The `new-upload` payload is the backend's `UploadDto`, which carries `hashtags` + // on top of what `FeedUpload` declares — needed for the filter check below. + // Typed explicitly rather than widening `FeedUpload`, because the feed's own + // rows (from `/feed`) genuinely do not include them. + const upload: FeedUpload & { hashtags?: string[] } = JSON.parse(data); // GRID view must NOT prepend live. Its rows are POSITIONAL windows // (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts // every tile by one slot: each row's keyed `{#each}` then sees a different set of @@ -329,6 +333,12 @@ // row, and a duplicate id in a keyed `{#each}` is a thrown error, not a // cosmetic glitch. if (uploads.some((u) => u.id === upload.id)) return; + // Respect the active filter (H13). `uploads` IS the filtered set — the server + // applied `filterParams()` — but this handler prepended unconditionally, so a + // guest who filtered to #tanzflaeche had that filter quietly destroyed within + // minutes by everyone else's uploads, with no indication and no way back short + // of toggling the chip. The payload carries `hashtags`, so we can just check. + if (selectedHashtag && !upload.hashtags?.includes(selectedHashtag)) return; uploads = [upload, ...uploads]; } catch { /* ignore */ @@ -372,6 +382,17 @@ /* ignore */ } }), + // The ban was lifted — their cards must come back. Unlike a hide we cannot do this in + // place: the cards were filtered out of `uploads`, so there is nothing left to restore + // from. Flag the feed stale and let the existing tap-to-refresh pill resync, which is + // the same treatment a truncated delta gets and keeps the guest's scroll position. + // + // Without this the unban was invisible to every open feed and to the unattended + // projector until somebody reloaded by hand — while the host's confirm copy promised + // the photos were back. + onSseEvent('user-shown', () => { + feedStale = true; + }), // Patch the single affected card in place from the SSE payload instead of // refetching page 1 — a busy event fires these constantly and a full reload // would yank every scrolled-down user back to the top on each reaction. @@ -406,9 +427,39 @@ return; } if (delta.uploads.length) { + // `/feed/delta` takes NO filter parameters, so its rows are the unfiltered + // event. Merging them into a filtered view is the same defect as the + // `new-upload` prepend above (H13) — and here we cannot check hashtags, + // because the delta rows do not carry them. + // + // So when a filter is active, don't merge: flag the feed stale and let the + // existing tap-to-refresh pill re-run page 1 under `filterParams()`. Same + // treatment a truncated delta gets, and it keeps the guest's scroll. + // Dedupe FIRST, in both branches. `delta.uploads.length > 0` is not + // evidence that anything NEW arrived: the delta cursor boundary is + // inclusive and `sse.ts` deliberately rewinds `lastEventTime` to an + // upload's `created_at`, so a delta routinely re-returns rows the + // stream already delivered. The filtered branch skipped this check + // entirely and set `feedStale` on EVERY delta — and the backstop + // polls every 60-120s, so a guest who tapped a hashtag got a "Neue + // Beiträge" pill they could never clear, each tap costing a full + // filtered refetch. That trains guests to ignore the one control + // that means something. It also fired for any other guest's + // non-matching upload, which by construction is never in the + // filtered `uploads`. const seen = new Set(uploads.map((u) => u.id)); const fresh = delta.uploads.filter((u) => !seen.has(u.id)); - if (fresh.length) uploads = [...fresh, ...uploads]; + if (fresh.length) { + if (selectedHashtag || activeFilters.length) { + // Still cannot MERGE under a filter — `/feed/delta` takes no + // filter params and its rows carry no hashtags, so we cannot + // tell which belong in this view. Flagging stale is right; + // doing it for rows already on screen was not. + feedStale = true; + } else { + uploads = [...fresh, ...uploads]; + } + } } // A delta reconciles new uploads and deletions, but not like/comment // counts that changed on already-visible cards while we were diff --git a/frontend/src/routes/join/+page.svelte b/frontend/src/routes/join/+page.svelte index 620ec32..b5fae6d 100644 --- a/frontend/src/routes/join/+page.svelte +++ b/frontend/src/routes/join/+page.svelte @@ -8,10 +8,14 @@ // Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth. let eventName = $state(''); + // The operator's own data notice, if they set one. Usually empty — see the notice block below. + let privacyNote = $state(''); + let noticeOpen = $state(false); onMount(async () => { try { - const ev = await api.get<{ name: string; slug: string }>('/event'); + const ev = await api.get<{ name: string; slug: string; privacy_note?: string }>('/event'); eventName = ev.name; + privacyNote = ev.privacy_note?.trim() ?? ''; } catch { // Non-fatal — fall back to the generic heading if the lookup fails. } @@ -34,6 +38,39 @@ let pinRequestSent = $state(false); let pinRequestLoading = $state(false); + /** + * Stable idempotency key for THIS join attempt, surviving a reload or a PWA relaunch. + * + * The failure it closes: `/join` commits the account and the PIN hash, but the plaintext PIN + * only ever exists in the response body. Lose that response — the 5G-to-nothing handoff every + * venue car park has — and the retry used to 409 on the guest's own name, leaving them staring + * at a PIN prompt for a PIN nobody had ever seen. With a key the server recognises the retry + * and answers with a working PIN (it rotates it; see migration 027). + * + * Kept in localStorage rather than component state because the guest's instinctive response to + * a hung request is to reload the page, which would otherwise mint a fresh key and re-create + * the exact bug. Cleared once the join has demonstrably landed. + */ + const JOIN_KEY_STORAGE = 'eventsnap:join-attempt-id'; + function joinAttemptId(): string { + let id: string | null = null; + try { + id = localStorage.getItem(JOIN_KEY_STORAGE); + } catch { + // Private mode / storage disabled. A per-call UUID is still better than none: it makes + // a retry within this page view idempotent, which is the common case. + } + if (!id) { + id = crypto.randomUUID(); + try { + localStorage.setItem(JOIN_KEY_STORAGE, id); + } catch { + /* best effort */ + } + } + return id; + } + async function handleJoin() { if (!displayName.trim()) return; loading = true; @@ -44,9 +81,19 @@ pin: string; user_id: string; is_new: boolean; - }>('/join', { display_name: displayName.trim() }); + }>('/join', { + display_name: displayName.trim(), + client_join_id: joinAttemptId() + }); setAuth(res.jwt, res.pin, res.user_id, displayName.trim()); + // The join landed and we hold the PIN — retire the key so a later deliberate join (a + // second guest on a shared device) is a new attempt rather than a retry of this one. + try { + localStorage.removeItem(JOIN_KEY_STORAGE); + } catch { + /* best effort */ + } pin = res.pin; showPinModal = true; } catch (e) { @@ -311,6 +358,70 @@ + +
+

+ Mit dem Beitreten legst du ein Konto mit deinem Namen an. Deine Fotos, Kommentare und + dein Name sind für alle Gäste dieses Events sichtbar. +

+ + {#if noticeOpen} +
+

+ Was gespeichert wird: dein angezeigter + Name, deine hochgeladenen Fotos und Videos samt Aufnahmezeitpunkt, deine Bildtexte, Kommentare + und Likes. Dazu ein verschlüsselter Prüfwert deines PINs — der PIN selbst wird nicht gespeichert. +

+

+ Wer es sehen kann: alle Gäste + dieses Events. Die Gastgeber können außerdem Beiträge und Kommentare entfernen. Am Ende + erhalten die Gastgeber ein Archiv mit allen Fotos. +

+

+ Wie lange: bis die Gastgeber + das Event abschließen und die Installation abbauen. Du kannst eigene Fotos jederzeit selbst + löschen, und über „Mein Konto“ dein Konto samt aller Inhalte entfernen lassen. +

+

+ Lade bitte keine Fotos von Personen hoch, die damit nicht einverstanden sind — bei + Kindern brauchst du das Einverständnis der Eltern. +

+ {#if privacyNote} +

+ Hinweis der Gastgeber: + {privacyNote} +

+ {/if} +
+ {/if} +
+

import { goto, afterNavigate } from '$app/navigation'; import { api, ApiError } from '$lib/api'; - import { setAuth, getPin, getToken, clearPin } from '$lib/auth'; + import { setAuth, getPin, getToken, clearPin, getDisplayName } from '$lib/auth'; import { markGuideSeen } from '$lib/onboarding'; import { browser } from '$app/environment'; import IconButton from '$lib/components/IconButton.svelte'; @@ -80,10 +80,25 @@ } 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(); + // A wrong PIN CAN mean the locally-cached PIN is stale (a host reset it while this + // device was offline and missed the `pin-reset` SSE), and then dropping it stops the + // field pre-filling with a dead value. `+layout.svelte` already handles the online + // case; this is the offline backstop. + // + // But it must be narrow, because the backend returns the SAME 401 for a wrong PIN + // and an UNKNOWN NAME (deliberately — it closes an enumeration and timing oracle). + // Clearing on any 401 meant a guest who mistyped their own name lost the only copy + // of their PIN: localStorage is where it lives, the server keeps only the bcrypt, + // and rejoining under the same name 409s. One typo, permanently locked out of their + // own account, needing a host with a dashboard open. + // + // So clear only when the evidence actually points at a stale cache: the name they + // submitted is the one this device belongs to, AND the PIN that was rejected is the + // cached one. Any other 401 leaves stored state untouched. + const submittedOwnName = + getDisplayName()?.trim().toLowerCase() === displayName.trim().toLowerCase(); + const submittedCachedPin = getPin() !== null && pin.trim() === getPin(); + if (e.status === 401 && submittedOwnName && submittedCachedPin) clearPin(); } else { error = 'Ein Fehler ist aufgetreten.'; }