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:
@@ -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 —
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
frontend/src/lib/upload-queue.test.ts
Normal file
42
frontend/src/lib/upload-queue.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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