diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index ab8ec6d..7a2879e 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -335,8 +335,17 @@ pub struct DownloadQuery { /// carry an `Authorization` header, so the client exchanges its Bearer token for /// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same /// single-use, 30s-TTL store as the SSE stream. +#[derive(serde::Deserialize)] +pub struct ExportTicketQuery { + /// Which archive the ticket is for — `zip` or `html`. Optional so an older client that + /// doesn't send it keeps working; it simply skips the pre-check it doesn't know to ask for. + #[serde(default)] + pub kind: Option, +} + pub async fn export_ticket( State(state): State, + axum::extract::Query(q): axum::extract::Query, auth: crate::auth::middleware::AuthUser, ) -> Result, AppError> { // NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access @@ -353,6 +362,39 @@ pub async fn export_ticket( // // Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be // obtained from this authenticated endpoint, so one mint is at most one download. + // Confirm the archive actually EXISTS before spending anything on it. + // + // `export_status` — which is what enables the Download button — reports `done` from + // `export_job`, while the download resolves through `export_current.file_path` plus a + // `Path::exists()`. Those are different sources of truth and can legitimately disagree: a + // row can say done while the file is gone, or an epoch bump can retire it between the page + // rendering and the guest tapping. When they disagreed the guest got the worst possible + // shape of failure — a green "Download gestartet" toast, a consumed single-use ticket, one + // of only three daily slots spent, and nothing in their Downloads folder, repeatable until + // the day's allowance was gone. + // + // Checking here, before `enforce_export_rate`, turns that into an honest error on a plain + // `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe + // ruled out elsewhere: it reads the same indexed row the download will read and touches no + // ticket, so it cannot consume anything. + if let Some(kind) = q.kind.as_deref() { + let export_type = match kind { + "zip" => "zip", + "html" => "html", + other => { + return Err(AppError::BadRequest(format!( + "Unbekannter Export-Typ: {other}" + ))); + } + }; + let msg = if export_type == "zip" { + "Der ZIP-Export ist noch nicht verfügbar." + } else { + "Der HTML-Export ist noch nicht verfügbar." + }; + resolve_export_file(&state, export_type, msg).await?; + } + enforce_export_rate(&state, auth.user_id).await?; let ticket = state.sse_tickets.issue(auth.token_hash); diff --git a/frontend/src/lib/components/CameraCapture.svelte b/frontend/src/lib/components/CameraCapture.svelte index c4d8f56..ec28717 100644 --- a/frontend/src/lib/components/CameraCapture.svelte +++ b/frontend/src/lib/components/CameraCapture.svelte @@ -14,9 +14,17 @@ /// returns the guest to where they were instead of dropping them somewhere they never /// asked to be. onready?: () => void; + /// Fired when the guest gives up on the camera and wants the file picker instead. + /// + /// The error panel used to offer only "Erneut versuchen" and "Schließen", which dead-ends + /// the whole upload journey for anyone whose `getUserMedia` cannot succeed: an in-app + /// browser (the WhatsApp/Instagram webview a shared QR link opens in) may never grant it, + /// and the panel's advice to change "Browsereinstellungen" refers to settings that do not + /// exist there. Retrying cannot help those guests; picking a photo can. + onpickfile?: () => void; } - let { oncapture, onclose, onready }: Props = $props(); + let { oncapture, onclose, onready, onpickfile }: Props = $props(); /// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch, /// and re-announcing "the camera is ready" mid-session would ask the caller to redo a @@ -204,13 +212,21 @@ />

{error}

-
+
+ {#if onpickfile} + + {/if} diff --git a/frontend/src/lib/components/UploadSheet.svelte b/frontend/src/lib/components/UploadSheet.svelte index f230bd8..d6e2d1f 100644 --- a/frontend/src/lib/components/UploadSheet.svelte +++ b/frontend/src/lib/components/UploadSheet.svelte @@ -156,6 +156,12 @@ oncapture={handleCapture} onclose={handleCameraClose} onready={handleCameraReady} + onpickfile={() => { + // Close the camera first, then open the OS picker — the sheet is still mounted + // behind it, so this returns the guest to a working path instead of a dead end. + showCamera = false; + openGallery(); + }} /> {/if} @@ -288,6 +294,20 @@

Jetzt aufnehmen

+ + +

+ Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome. +

{/if}
- {#if loadingMore} + {#if loadMoreError && !loadingMore} +
+

+ Weitere Fotos konnten nicht geladen werden. +

+ +
+ {:else if loadingMore}