fix: close the last three guest-facing dead ends (items 7-9)
C1 — a failed page-append silently ended infinite scroll `loadMore`'s catch showed a toast and changed no state, unlike every sibling error path in the file. `nextCursor` survived so the feed was still technically paginable, but the IntersectionObserver only fires on a CHANGE: after a failed append nothing scrolls and no rows are added, so it never re-fires. One 429 or wifi blip and the guest concluded the gallery was 20 photos. Now leaves a retry control at the sentinel — a toast that fades in 5s is not an affordance — resuming from the untouched cursor. C2 — the export page reported downloads that never happened `downloadFile` toasted 'Download gestartet' the instant it assigned the iframe's src, before a single byte existed. Since the iframe swallows errors BY DESIGN (a top-level navigation to a 404 would unload the PWA), a failure produced a green success message, a consumed single-use ticket, and one of only three daily slots spent — repeatable until the day's allowance was gone, on the screen that is the whole point of the app. Root cause is two sources of truth: `export_status` reports `done` from `export_job` and enables the button, while the download resolves through `export_current.file_path` plus a `Path::exists()`. They can legitimately disagree. `export_ticket` now takes a `kind` and calls the existing `resolve_export_file` BEFORE charging the rate slot, so a missing archive fails honestly on a plain fetch that `toastError` already renders. 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. The parameter is optional, so an older client degrades to today's behaviour rather than breaking. C3 — the WhatsApp journey could dead-end with no error at all The join link travels through guest group chats, and a link tapped inside one opens in that app's browser, where the file picker and getUserMedia both depend on the host app having wired them up. When they aren't, the buttons do nothing — no error, nothing to act on. Two targeted changes rather than a UI rebuild: the camera error panel now offers "Aus Galerie wählen" (its advice to change "Browsereinstellungen" refers to settings that do not exist in a webview, so retrying could never help those guests), and the sheet carries a standing one-line hint to open the link in Safari or Chrome. Deliberately no user-agent sniffing: a sniff list is wrong for every browser it has not heard of, while a quiet standing hint costs one line and is never wrong. The hint lives in UploadSheet rather than the root layout because both layout banners are gated on `$showBottomNav`, which `/upload` turns off — one there would never render on the composer. Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest, svelte-check 0 errors, eslint clean, both builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub async fn export_ticket(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
|
||||
auth: crate::auth::middleware::AuthUser,
|
||||
) -> Result<Json<serde_json::Value>, 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);
|
||||
|
||||
Reference in New Issue
Block a user