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