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

@@ -9,7 +9,7 @@
import Toaster from '$lib/components/Toaster.svelte';
import { showBottomNav } from '$lib/ui-store';
import { isAuthenticated } from '$lib/auth';
import { queueItems, isProcessing } from '$lib/upload-queue';
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
import { privacyNote } from '$lib/privacy-note-store';
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
@@ -17,7 +17,8 @@
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { setRole } from '$lib/role-store';
import { loadEventConfig } from '$lib/event-config-store';
import { loadEventConfig, commentsEnabled } from '$lib/event-config-store';
import { isBanned } from '$lib/ban-store';
let { children } = $props();
@@ -31,6 +32,27 @@
return Math.round((done / total) * 100);
});
// Rate-limit countdown, mirrored from UploadQueue.svelte. That component is mounted ONLY on
// /upload, but the composer sends the guest straight to /feed after staging — which is where
// they invariably are when the 429 lands. The documented "Wird in Xs automatisch fortgesetzt"
// reassurance was therefore unreachable in the exact situation it exists for: all the guest
// saw was a stuck badge and a queue that appeared to have died.
let rateLimitCountdown = $state(0);
$effect(() => {
const retryAt = $rateLimitRetryAt;
if (!retryAt) {
rateLimitCountdown = 0;
return;
}
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
const interval = setInterval(() => {
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
if (rateLimitCountdown <= 0) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
});
onMount(async () => {
// With `ssr = false` the server ships an empty shell; `app.html` paints a boot
// spinner to cover the JS-load gap. The app has now mounted and painted, so drop it.
@@ -46,6 +68,15 @@
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
// Page-level mounts will refresh again as needed.
if (getToken()) {
// Rehydrate the persisted upload queue from IndexedDB on EVERY boot, not just
// when /upload happens to mount. `queueItems` is a module-level store that starts
// empty, and the drain loop reads only that store — so without this, a guest whose
// PWA is evicted mid-upload (iOS does this aggressively) and who reopens onto /feed
// has pending blobs sitting in IndexedDB that nothing ever reads. The badge shows
// 0, the photos never upload, and there is no symptom to act on. This is also what
// arms the other resume paths below (`online`, `event-opened`, `feed-delta`), all
// of which call processQueue() against the same store.
void loadQueue();
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
@@ -56,6 +87,7 @@
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
isBanned.set(ctx.is_banned);
} 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.
@@ -79,6 +111,20 @@
// Malformed payload — discard; nothing actionable for the user.
}
}),
// A host banned someone. Same contract as `pin-reset`: `data` is a JSON string of
// `{ 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.
onSseEvent('user-hidden', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) isBanned.set(true);
} 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),
@@ -117,6 +163,55 @@
</div>
{/if}
<!-- Rate-limit countdown, rendered next to the progress bar for the same reason: the queue's
own banner lives on /upload, which is not where the guest is standing when the 429 hits.
Suppressed while the bottom nav is (i.e. on /upload), where UploadQueue already shows it
in place and this would duplicate it over the sticky submit bar — and for a banned guest,
whose read-only banner occupies the same slot and is the more relevant message. -->
{#if $rateLimitRetryAt && rateLimitCountdown > 0 && $isAuthenticated && $showBottomNav && !$isBanned}
<div
role="status"
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
>
<div
class="rounded-xl bg-amber-50 px-4 py-2 text-center text-sm text-amber-800 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:text-amber-300 dark:ring-amber-900"
>
Upload-Limit erreicht. Wird in {rateLimitCountdown} Sek. automatisch fortgesetzt.
</div>
</div>
{/if}
<!-- Read-only notice for a banned guest. Rendered in the layout so it follows them across
every route, and above the bottom nav so it is not hidden behind it. A ban blocks every
write server-side but leaves the feed and the keepsake readable, so the guest needs to be
told once — otherwise the upload, like and delete controls all look available and answer
with a 403 toast each time, which reads as the app being broken.
The offset must be computed, not the fixed `bottom-16` it used to be: BottomNav is h-14
(3.5rem) PLUS env(safe-area-inset-bottom), so on a notched iPhone the nav is ~90px tall and
paints over the lower part of this banner (same z-40, later in the DOM). And it only renders
alongside the nav — on /upload the nav is suppressed and this would float over the sticky
submit bar; a banned guest can no longer open the composer anyway (see UploadSheet). -->
{#if $isBanned && $isAuthenticated && $showBottomNav}
<div
role="status"
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
>
<div
class="rounded-xl bg-amber-50 px-4 py-3 text-center shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:ring-amber-900"
>
<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
Du kannst alle Fotos ansehen und die Galerie später herunterladen. Hochladen, Liken{$commentsEnabled
? ' und Kommentieren'
: ''} sind für dich deaktiviert.
</p>
</div>
</div>
{/if}
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
<UploadSheet />

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}