fix(upload): stop the queue from wedging, and tell the guest when it fails

A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall
detection, so a half-open connection from an AP roam left the item `uploading`
indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of
the queue stopped draining. The UI offered no control at all for an `uploading` item.
The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and
no button to press; the only escape was force-quitting the PWA, which nobody guesses.
Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server
may take its time storing a file it already has; a size-scaled timeout as a generous
backstop that will not kill slow-but-progressing LTE; and a cancel button.

FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue
component is mounted only on /upload — so a 5xx, a captive-portal error or an
uploads-locked 403 wrote a German message into an item that nothing ever rendered. The
guest believed the photo was uploading; it never appeared. Same for the documented
rate-limit countdown banner, which lives in that same unreachable component and is now
also rendered from the layout.

RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on
the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no
attempt counter and no backoff. On a flapping network a large failing video was
re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted
attempt count, exponential backoff and a cap of five.

INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab
holding an older version made it never settle, and it rejects outright on iOS private
mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons
stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing
queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback
so uploading still works when persistence is unavailable, and a `finally`.

A 401 during a background upload cleared the session without redirecting — and api.ts
documents exactly why that strands a guest: the nav and FAB are gated on
`isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA
has no URL bar. Three early-return paths wrote status only to memory and never to
IndexedDB, leaving blob-less error rows that could never be evicted and held the red
FAB badge lit all night.

A banned guest was offered the entire upload flow — FAB, camera, staging — and only the
POST 403'd, while the new read-only banner told them uploading was disabled. The sheet
now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the
UI instead of arriving as a stream of 403 toasts, and the banner clears
`env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones.
The /upload submit bar gets the same inset — it sat in the home-indicator zone, where
the system swallows the first tap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:36:30 +02:00
parent 51e55b1ace
commit fffa2d556c
8 changed files with 915 additions and 121 deletions

View File

@@ -26,6 +26,42 @@
const MAX_CAPTION_LENGTH = 2000;
// Mirrors MAX_UPLOAD_BYTES in backend/src/main.rs — the axum body limit, which is a
// BOOT CONSTANT rather than an admin-tunable value, so checking it here cannot drift
// out of sync with the dashboard the way max_image_size_mb / max_video_size_mb would.
// Anything above this is refused by the server no matter how the event is configured.
const HARD_MAX_UPLOAD_BYTES = 576 * 1024 * 1024;
/**
* Reject files the server is certain to refuse, BEFORE any bytes leave the phone.
*
* Without this a guest pushes the whole file over the venue wifi — saturating the AP
* for everyone else — only to get a German error at the end. Two cases are worth
* catching, and only two: both are certain rejections, so this can never over-reject
* a file the server would have taken.
*
* HEIC/HEIF: deliberately excluded from the backend allowlist (neither the `image`
* crate nor the bundled ffmpeg can decode them). iOS transcodes HEIC→JPEG when a photo
* comes from the Photos picker, but NOT via the Files app or a third-party share sheet,
* so this path is reachable in normal guest use.
*/
function uploadRejectReason(file: File): string | null {
const type = file.type.toLowerCase();
// Trust the MIME type. The filename is only consulted when the browser supplied no
// MIME at all: iOS transcodes HEIC→JPEG for the Photos picker but can hand back a
// File still NAMED *.HEIC, so an extension-based rule would reject good JPEGs — the
// one thing this function must never do.
const heicByName = type === '' && /\.hei[cf]$/.test(file.name.toLowerCase());
if (type === 'image/heic' || type === 'image/heif' || heicByName) {
return `„${file.name}“ ist ein HEIC-Bild und kann nicht verarbeitet werden. Bitte teile es aus der Fotos-App (dann wandelt iOS es automatisch in JPEG um).`;
}
if (file.size > HARD_MAX_UPLOAD_BYTES) {
const mb = Math.round(file.size / (1024 * 1024));
return `„${file.name}“ ist mit ${mb} MB zu groß. Bitte nimm einen kürzeren Clip auf.`;
}
return null;
}
// The storage widget is staff-only (host/admin). Guests never see server-derived
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
// this is UI-consistency on top of an API guarantee, not the security boundary.
@@ -105,20 +141,42 @@
vibrate(10);
const hashtagsString = captionTags.join(',');
let full = 0;
for (const sf of stagedFiles) {
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
// `addToQueue` touches IndexedDB, which can reject outright (iOS private mode, a denied
// quota) — and an unhandled throw here left BOTH buttons stuck on "Wird hochgeladen…"
// with no error and no way forward, because `submitting` was never reset. Whatever
// happens, the composer has to come back to life and say something in German.
try {
for (const sf of stagedFiles) {
// Refuse certain-rejects before a single byte is sent (see uploadRejectReason).
const reason = uploadRejectReason(sf.file);
if (reason) {
toast(reason, 'error', 8000);
continue;
}
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
toast(
`Warteschlange voll ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
'error',
6000
);
}
clearPending();
goto('/feed');
} catch {
// Staged files are deliberately NOT cleared: the guest stays in the composer with
// their selection intact so a second tap can succeed.
toast(
`Warteschlange voll ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
'Der Upload konnte nicht gestartet werden. Bitte versuch es noch einmal.',
'error',
6000
);
} finally {
submitting = false;
}
clearPending();
goto('/feed');
}
function isVideo(file: File): boolean {
@@ -224,7 +282,7 @@
<div>
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
Geh zurück und tippe auf den Plus-Button.
Geh zurück und tippe auf den Kamera-Button.
</p>
</div>
<button onclick={cancel} class="btn btn-secondary btn-sm"> Zurück </button>
@@ -313,8 +371,13 @@
onCancel={() => (discardConfirmOpen = false)}
/>
<!-- Sticky submit button at bottom (mobile-primary) -->
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<!-- Sticky submit button at bottom (mobile-primary). This page suppresses the bottom nav, so
this bar IS the bottom of the layout and has to carry the safe-area inset itself — with
viewport-fit=cover the lower ~22px of the button otherwise sits in the iPhone home
indicator's gesture zone, where the first tap is swallowed by the system. -->
<div
class="border-t border-gray-100 px-4 py-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] dark:border-gray-800"
>
<button
onclick={handleSubmit}
disabled={stagedFiles.length === 0 || submitting}