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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user