Three defects in the same pipeline, each of which loses a photo or misrepresents one. 1. A transient error destroyed the guest's only copy. `process`'s error arm unconditionally `remove_file`d the original. Every failure routed there: `create_dir_all`, both derivative `save_with_format` calls (disk full is the canonical case, and it arrives exactly when many guests upload at once), a panic inside the image codec, or a momentary DB-pool exhaustion. The row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and they were the part we deleted. The author already knew this was wrong next door: `backfill_missing_display` says it "must NEVER soft-delete an upload that already has a working preview". Retry up to 3 times with backoff (re-checking the e2e generation guard after each sleep), and on final failure keep the refund + soft-delete but leave the original on disk, logging its path. A failed upload is now recoverable instead of gone. 2. Every portrait photo was stored sideways. Phones don't rotate sensor data — they record the camera orientation in EXIF and store the pixels as shot. `decode()` returns those raw pixels and the JPEG re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and the keepsake were all rotated 90°, while "Original anzeigen" rendered upright because the original keeps its tag. That asymmetry is why it reads as a viewer bug. There was no EXIF handling anywhere in the repo and no exif crate. Read the tag via `into_decoder()` (which carries the decode Limits through, so the decompression-bomb cap is untouched) and apply it. Missing/malformed tags fall back to NoTransforms — most images have none. Existing derivatives are already baked wrong, so migration 018 adds `derivatives_rev` and `backfill_missing_display` becomes `backfill_stale_derivatives`: it now also picks up anything below the current rev and regenerates it once from the original, which still carries its EXIF. Videos are marked current in the migration — ffmpeg already honours the rotation matrix. Bump DERIVATIVES_REV for any future change that invalidates derivatives. 3. A rejected upload vanished without a word. `UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error text, the only "Erneut" retry button and the only rate-limit countdown — was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were unreachable at runtime. On a terminal rejection the store purged the blob and wrote a clear German reason into `entry.error` "so the UI shows a clear reason". There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so the badge decremented exactly as if the upload had succeeded. Mount the queue on /upload, toast the reason immediately (the flow sends the user to /feed straight after staging, so the list alone would still miss them), and count blocked/error in the badge so a failure can't read as success. Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6 and asserts both derivatives come back PORTRAIT, with a sanity check that the source really is stored landscape. 02-upload/rejection-visible bans the uploader between staging and sending, then asserts the toast, the queue row with the server's reason, and that the item is still counted. Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
import { openDB, type IDBPDatabase } from 'idb';
|
|
import { writable, get } from 'svelte/store';
|
|
import { getToken, getUserId, clearAuth } from './auth';
|
|
import { onSseEvent } from './sse';
|
|
import { refreshQuota } from './quota-store';
|
|
import { toast } from './toast-store';
|
|
|
|
export interface QueueItem {
|
|
id: string;
|
|
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;
|
|
// 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
|
|
// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
|
|
// items have had their blob purged from IndexedDB and offer no retry.
|
|
status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
|
|
progress: number;
|
|
error?: string;
|
|
}
|
|
|
|
// Store does NOT hold file blobs — those stay in IndexedDB only
|
|
export const queueItems = writable<QueueItem[]>([]);
|
|
export const isProcessing = writable(false);
|
|
|
|
/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
|
|
export const rateLimitRetryAt = writable<number | null>(null);
|
|
|
|
const DB_NAME = 'eventsnap-uploads';
|
|
const STORE_NAME = 'queue';
|
|
|
|
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
|
|
const MAX_QUEUE_ITEMS = 100;
|
|
|
|
let db: IDBPDatabase | null = null;
|
|
|
|
// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
|
|
// This is the other half of the "flushes when you're back online" promise — without it
|
|
// a reconnect only resumes if the user manually re-stages a file.
|
|
let onlineBound = false;
|
|
function bindOnline(): void {
|
|
if (onlineBound || typeof window === 'undefined') return;
|
|
window.addEventListener('online', () => {
|
|
void (async () => {
|
|
await requeueRetriable();
|
|
await processQueue();
|
|
})();
|
|
});
|
|
onlineBound = true;
|
|
}
|
|
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); flipping it
|
|
// back to pending and re-draining recovers it so a photo staged during a lock isn't lost.
|
|
// We resume on TWO signals:
|
|
// - `event-opened`: the live reopen, when the SSE happens to be connected.
|
|
// - `feed-delta`: fired on every SSE (re)connect (see sse.ts `deltaFetchAndFan`). Because
|
|
// `event-opened` has no server-side replay and the SSE is disconnected on non-feed pages
|
|
// / backgrounded tabs, a reopen is often MISSED live — this catches up on the next
|
|
// reconnect. Retrying while still locked just re-parks the item (bounded, one per event).
|
|
// One-way import (sse.ts never imports this module).
|
|
let sseBound = false;
|
|
function bindSse(): void {
|
|
if (sseBound || typeof window === 'undefined') return;
|
|
const resume = () => {
|
|
void (async () => {
|
|
await requeueRetriable();
|
|
await processQueue();
|
|
})();
|
|
};
|
|
onSseEvent('event-opened', resume);
|
|
onSseEvent('feed-delta', resume);
|
|
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`
|
|
* items (403/413) are left alone — retrying those never succeeds.
|
|
*/
|
|
async function requeueRetriable(): Promise<void> {
|
|
const database = await getDb();
|
|
const myUserId = getUserId();
|
|
const all = await database.getAll(STORE_NAME);
|
|
for (const entry of all) {
|
|
if (entry.userId === myUserId && entry.status === 'error' && entry.blob) {
|
|
entry.status = 'pending';
|
|
entry.error = undefined;
|
|
await database.put(STORE_NAME, entry);
|
|
}
|
|
}
|
|
queueItems.update((items) =>
|
|
items.map((item) =>
|
|
item.status === 'error'
|
|
? { ...item, status: 'pending' as const, progress: 0, error: undefined }
|
|
: item
|
|
)
|
|
);
|
|
}
|
|
|
|
async function getDb(): Promise<IDBPDatabase> {
|
|
if (db) return db;
|
|
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
|
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
|
// persisted across logouts before this version.
|
|
// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
|
|
// opened a *new* transaction inside the callback, which throws InvalidStateError
|
|
// ("A version change transaction is running") and aborts the whole upgrade —
|
|
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
|
|
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
|
|
// those installs; the contains() guard recreates the missing store instead of
|
|
// assuming createObjectStore only ever runs on a brand-new DB.
|
|
db = await openDB(DB_NAME, 3, {
|
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
|
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
} else if (oldVersion < 2) {
|
|
// Existing v1 store: its entries predate the `userId` field, so drop them
|
|
// rather than misattribute them to whoever is signed in now. Reuse the
|
|
// active version-change transaction (never open a new one here — see above).
|
|
// Skipped when we just created the store, which is already empty.
|
|
transaction.objectStore(STORE_NAME).clear();
|
|
}
|
|
}
|
|
});
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on
|
|
* explicit logout so a second guest using the same device doesn't inherit (or be
|
|
* blamed for) the previous guest's pending uploads.
|
|
*/
|
|
export async function clearQueue(): Promise<void> {
|
|
const database = await getDb();
|
|
await database.clear(STORE_NAME);
|
|
queueItems.set([]);
|
|
rateLimitRetryAt.set(null);
|
|
}
|
|
|
|
class RateLimitError extends Error {
|
|
retryAfterSecs: number;
|
|
constructor(secs: number) {
|
|
super('rate_limited');
|
|
this.retryAfterSecs = secs;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
|
|
* will never succeed on retry: uploads locked, user banned, gallery already released, or
|
|
* per-user quota full. The item is moved to the terminal `blocked` state and its blob is
|
|
* dropped from IndexedDB (no point keeping bytes we'll never send).
|
|
*/
|
|
class TerminalError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A connectivity failure — the request never reached the server (offline, DNS, dropped
|
|
* connection). The item stays `pending` (not `error`) so the `online` listener and the
|
|
* next `processQueue` pick it up automatically. This is what makes "the queue flushes
|
|
* when you're back online" actually true for a file staged with no signal.
|
|
*/
|
|
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';
|
|
}
|
|
|
|
/**
|
|
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
|
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
|
* Pure + exported so this data-loss-critical rule is unit-testable without an XHR harness.
|
|
*
|
|
* Reversible when:
|
|
* - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR
|
|
* - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable
|
|
* 403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst
|
|
* outcome, and 403 is the reversible-lock status here.
|
|
* A `forbidden` 403 (banned) and every non-403 4xx (e.g. 413 quota) are permanent → purge.
|
|
*/
|
|
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
|
return errorCode === 'uploads_locked' || (status === 403 && errorCode !== 'forbidden');
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* across — addToQueue's dedup keys on it, so an item restored from IndexedDB (page reload / PWA
|
|
* relaunch) with an undefined lastModified would fail to match a re-selection of the same file
|
|
* and silently queue it twice. `uploading` is downgraded to `pending` (an interrupted in-flight
|
|
* upload must resume, not stay stuck spinning).
|
|
*/
|
|
export function entryToQueueItem(entry: {
|
|
id: string;
|
|
userId: string;
|
|
fileName: string;
|
|
fileSize: number;
|
|
lastModified?: number;
|
|
mimeType: string;
|
|
caption?: string;
|
|
hashtags?: string;
|
|
status: QueueItem['status'] | 'uploading';
|
|
error?: string;
|
|
}): QueueItem {
|
|
return {
|
|
id: entry.id,
|
|
userId: entry.userId,
|
|
fileName: entry.fileName,
|
|
fileSize: entry.fileSize,
|
|
lastModified: entry.lastModified,
|
|
mimeType: entry.mimeType,
|
|
caption: entry.caption ?? '',
|
|
hashtags: entry.hashtags ?? '',
|
|
status: entry.status === 'uploading' ? 'pending' : entry.status,
|
|
progress: entry.status === 'done' ? 100 : 0,
|
|
error: entry.error
|
|
};
|
|
}
|
|
|
|
export async function loadQueue(): Promise<void> {
|
|
const database = await getDb();
|
|
const myUserId = getUserId();
|
|
const all = await database.getAll(STORE_NAME);
|
|
// Only surface entries that belong to the current user. Entries from a previous
|
|
// guest on this device are filtered out (and would also be wiped on their next
|
|
// explicit logout via `clearQueue`).
|
|
const items: QueueItem[] = all
|
|
.filter((entry) => entry.userId && entry.userId === myUserId)
|
|
.map(entryToQueueItem);
|
|
queueItems.set(items);
|
|
// Staged-but-unsent items from a prior session (queued offline, tab closed before
|
|
// reconnect) must resume now — otherwise the "queue flushes when you're back online"
|
|
// promise only holds if the user manually re-stages a file. Reclaim transient errors
|
|
// (a network drop from a prior session) so they retry instead of stalling.
|
|
void (async () => {
|
|
await requeueRetriable();
|
|
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';
|
|
|
|
export async function addToQueue(
|
|
file: File,
|
|
caption: string,
|
|
hashtags: string
|
|
): Promise<EnqueueResult> {
|
|
const database = await getDb();
|
|
const userId = getUserId();
|
|
// 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+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 'duplicate';
|
|
|
|
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
|
// evict the oldest item whose blob is already gone or unrecoverable — ONLY `done` (blob
|
|
// deleted on success) or `blocked` (blob purged, terminal). NEVER evict `error`: those are
|
|
// retryable and still hold a live blob (a network blip, or a locked/released upload that
|
|
// resumes on reopen) — evicting one would silently lose a photo the fix deliberately kept.
|
|
// If nothing is evictable, refuse and tell the caller so it can surface a "queue full"
|
|
// message rather than dropping a photo the user believed was queued.
|
|
if (mine.length >= MAX_QUEUE_ITEMS) {
|
|
const evictable = get(queueItems).find(
|
|
(i) => i.userId === userId && (i.status === 'done' || i.status === 'blocked')
|
|
);
|
|
if (evictable) {
|
|
await database.delete(STORE_NAME, evictable.id);
|
|
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
|
} else {
|
|
return 'full';
|
|
}
|
|
}
|
|
|
|
const id = crypto.randomUUID();
|
|
const entry = {
|
|
id,
|
|
userId,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
lastModified: file.lastModified,
|
|
mimeType: file.type,
|
|
caption,
|
|
hashtags,
|
|
status: 'pending',
|
|
blob: file
|
|
};
|
|
await database.put(STORE_NAME, entry);
|
|
|
|
queueItems.update((items) => [
|
|
...items,
|
|
{
|
|
id,
|
|
userId,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
lastModified: file.lastModified,
|
|
mimeType: file.type,
|
|
caption,
|
|
hashtags,
|
|
status: 'pending',
|
|
progress: 0
|
|
}
|
|
]);
|
|
|
|
processQueue();
|
|
return 'queued';
|
|
}
|
|
|
|
export async function retryItem(id: string): Promise<void> {
|
|
const database = await getDb();
|
|
const entry = await database.get(STORE_NAME, id);
|
|
if (!entry) return;
|
|
|
|
entry.status = 'pending';
|
|
entry.error = undefined;
|
|
await database.put(STORE_NAME, entry);
|
|
|
|
queueItems.update((items) =>
|
|
items.map((item) =>
|
|
item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
|
|
)
|
|
);
|
|
|
|
processQueue();
|
|
}
|
|
|
|
export async function removeItem(id: string): Promise<void> {
|
|
const database = await getDb();
|
|
await database.delete(STORE_NAME, id);
|
|
queueItems.update((items) => items.filter((item) => item.id !== id));
|
|
}
|
|
|
|
export async function clearCompleted(): Promise<void> {
|
|
const database = await getDb();
|
|
const items = get(queueItems);
|
|
for (const item of items) {
|
|
if (item.status === 'done') {
|
|
await database.delete(STORE_NAME, item.id);
|
|
}
|
|
}
|
|
queueItems.update((items) => items.filter((item) => item.status !== 'done'));
|
|
}
|
|
|
|
let processing = false;
|
|
|
|
async function processQueue(): Promise<void> {
|
|
if (processing) return;
|
|
processing = true;
|
|
isProcessing.set(true);
|
|
|
|
try {
|
|
while (true) {
|
|
// Offline: leave items 'pending' rather than burning through them into 'error'.
|
|
// The `online` listener re-enters here the moment connectivity returns.
|
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) break;
|
|
|
|
const items = get(queueItems);
|
|
const next = items.find((item) => item.status === 'pending');
|
|
if (!next) break;
|
|
|
|
try {
|
|
await uploadItem(next.id);
|
|
} catch (e) {
|
|
if (e instanceof RateLimitError) {
|
|
// Keep all pending items as-is; schedule queue resume when limit lifts
|
|
const retryAt = Date.now() + e.retryAfterSecs * 1000;
|
|
rateLimitRetryAt.set(retryAt);
|
|
setTimeout(() => {
|
|
rateLimitRetryAt.set(null);
|
|
processQueue();
|
|
}, 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. 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')
|
|
}
|
|
}
|
|
} finally {
|
|
processing = false;
|
|
isProcessing.set(false);
|
|
}
|
|
}
|
|
|
|
async function uploadItem(id: string): Promise<void> {
|
|
const database = await getDb();
|
|
const entry = await database.get(STORE_NAME, id);
|
|
if (!entry || !entry.blob) {
|
|
updateItemStatus(id, 'error', 'Datei nicht gefunden.');
|
|
return;
|
|
}
|
|
|
|
const token = getToken();
|
|
const currentUserId = getUserId();
|
|
if (!token || !currentUserId) {
|
|
updateItemStatus(id, 'error', 'Nicht angemeldet.');
|
|
return;
|
|
}
|
|
// Defense-in-depth: if the device's signed-in user changed since this entry was
|
|
// queued, refuse to upload it under the new identity. `loadQueue` already filters
|
|
// by user; this guards the in-memory store path too.
|
|
if (entry.userId && entry.userId !== currentUserId) {
|
|
updateItemStatus(id, 'error', 'Anderer Nutzer angemeldet.');
|
|
return;
|
|
}
|
|
|
|
updateItemStatus(id, 'uploading');
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', entry.blob, entry.fileName);
|
|
if (entry.caption) formData.append('caption', entry.caption);
|
|
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/v1/upload');
|
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|
queueItems.update((items) =>
|
|
items.map((item) => (item.id === id ? { ...item, progress: pct } : item))
|
|
);
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
const body = (() => {
|
|
try {
|
|
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));
|
|
break;
|
|
}
|
|
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.
|
|
// Also treat ANY 403 we can't positively identify as permanent (`forbidden`
|
|
// = banned) as reversible: an unparseable 403 body (proxy/WAF/captive
|
|
// 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)) {
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
|
|
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
|
|
xhr.send(formData);
|
|
});
|
|
|
|
// Success — remove blob from IndexedDB, mark done
|
|
entry.status = 'done';
|
|
delete entry.blob;
|
|
await database.put(STORE_NAME, entry);
|
|
updateItemStatus(id, 'done');
|
|
// Refresh the per-user quota snapshot so the My Account widget reflects this
|
|
// upload's bytes without a manual reload.
|
|
void refreshQuota();
|
|
} catch (e) {
|
|
if (e instanceof RateLimitError) {
|
|
// Reset to pending so it will be retried when the queue resumes
|
|
entry.status = 'pending';
|
|
await database.put(STORE_NAME, entry);
|
|
updateItemStatus(id, 'pending');
|
|
throw e; // Propagate to processQueue for scheduling
|
|
}
|
|
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, '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) {
|
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
|
// so the UI shows a clear reason and offers no retry.
|
|
delete entry.blob;
|
|
entry.status = 'blocked';
|
|
entry.error = e.message;
|
|
await database.put(STORE_NAME, entry);
|
|
updateItemStatus(id, 'blocked', e.message);
|
|
// Tell the user NOW. The queue list only lives on /upload, and the flow sends
|
|
// them straight to /feed after staging a photo — so without this a rejected
|
|
// upload was silently swallowed: the blob is gone, the FAB badge drops exactly
|
|
// as if it had succeeded, and the photo simply never appears.
|
|
toast(`${entry.fileName}: ${e.message}`, 'error');
|
|
return;
|
|
}
|
|
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
|
entry.status = 'error';
|
|
entry.error = msg;
|
|
await database.put(STORE_NAME, entry);
|
|
updateItemStatus(id, 'error', msg);
|
|
}
|
|
}
|
|
|
|
function updateItemStatus(id: string, status: QueueItem['status'], error?: string): void {
|
|
queueItems.update((items) =>
|
|
items.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
status,
|
|
progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress,
|
|
error
|
|
}
|
|
: item
|
|
)
|
|
);
|
|
}
|