diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index dd84385..ece9a59 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -441,14 +441,15 @@ pub async fn export_ticket( resolve_export_file(&state, export_type, msg).await?; } - enforce_export_rate(&state, auth.user_id).await?; - // `issue` returns None when the ticket store is at capacity. Unwrapping it into the JSON body // serialized `{"ticket": null}` with a 200 — so `api.post` resolved happily, the page toasted - // success, the iframe navigated to `?ticket=null`, and one of the guest's three DAILY - // downloads had already been charged above. That is precisely the phantom-success failure - // this endpoint's pre-validation was added to eliminate, arriving through the other door. - // 503 + Retry-After, matching how `sse::issue_ticket` answers the identical condition. + // success, and the iframe navigated to `?ticket=null`. 503 + Retry-After, matching how + // `sse::issue_ticket` answers the identical condition. + // + // Minted BEFORE the rate limit is charged. Charging first meant a store-capacity 503 — a + // server-side condition the guest did nothing to cause and cannot see — still cost one of + // their three DAILY downloads. There is no refund path, so the only fix is not to charge until + // the thing being charged for actually exists. let ticket = state .sse_tickets .issue(auth.token_hash, TicketKind::Download(export_kind)) @@ -458,6 +459,9 @@ pub async fn export_ticket( Some(30), ) })?; + + enforce_export_rate(&state, auth.user_id).await?; + Ok(Json(serde_json::json!({ "ticket": ticket }))) } diff --git a/backend/src/handlers/me.rs b/backend/src/handlers/me.rs index cae5975..0471bc1 100644 --- a/backend/src/handlers/me.rs +++ b/backend/src/handlers/me.rs @@ -206,6 +206,17 @@ pub async fn delete_account( .await?; tx.commit().await?; + // IMMEDIATELY after the commit, before any other `.await`. Every other `invalidate_and_arm` + // call site does this; this one used to spawn the workers *after* the file-removal loop below, + // and axum drops a handler future the moment the client disconnects. Drop it inside that loop + // and the keepsake is left with the epoch bumped, both `export_job` rows armed `pending` at + // that epoch, and NO WORKER: `/export/zip` and `/export/html` 404, the UI sits on + // "Wird vorbereitet…" forever, and `recover_exports` only runs at boot. Deleting your account + // from a phone that walks out of wifi range is enough to do it. + if let Some(r) = regen { + crate::handlers::host::start_regen(&state, r); + } + // Best effort, after the commit. Anything missed here is an orphan with no row pointing at it, // which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation // rather than leaving the file referenced. @@ -228,10 +239,6 @@ pub async fn delete_account( } } - if let Some(r) = regen { - crate::handlers::host::start_regen(&state, r); - } - // Evict their content from every open feed and the projector. `user-hidden` is exactly the // right signal — it already means "this user's cards must go" — and reusing it means every // client already handles this with no new event type. diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index eb13d98..fe1f503 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -1100,7 +1100,10 @@ async fn run_html_export_inner( let mut viewer_posts: Vec = Vec::new(); // (zip entry name under media/, where its bytes come from). Built here, streamed // into the ZIP in step 5 — so we also know the exact file count without a rescan. - let mut media_manifest: Vec<(String, MediaSource)> = Vec::new(); + // The bool is "this entry is the FULL variant", i.e. the one `data.json` advertises as the + // photo itself. It exists so `check_export_completeness` can count photos rather than files — + // see the call site. Exactly one full entry is pushed per upload that survives the stat. + let mut media_manifest: Vec<(String, MediaSource, bool)> = Vec::new(); // Uploads dropped at the stat below never enter `media_manifest`, so without counting them // here a wholly-unreadable media directory yields an EMPTY manifest — expected 0, skipped 0 — // and the completeness check downstream would wave it through as a legitimately empty event. @@ -1195,7 +1198,15 @@ async fn run_html_export_inner( .context("failed to save thumbnail")?; Ok(()) }) - .await?; + .await + // NOT `?`. The `?` here was on the JoinError, not on the closure's Result — so a + // decoder PANIC (the `image` crate can panic on malformed input, and a resize can + // abort on allocation) propagated out and failed the ENTIRE keepsake, where the very + // same file merely failing returns `Err` and costs one tile. Worse, it was + // deterministic: "Neu erzeugen" reads the same poison file and dies the same way. That + // is the failure shape the completeness guard was reversed to eliminate, arriving + // through the other door. + .unwrap_or_else(|e| Err(anyhow::anyhow!("thumbnail task panicked: {e}"))); // Same dangling-reference hazard as the video branch: a failure here left `thumb` // pointing at a file the ZIP writer would then skip, so `data.json` advertised an @@ -1232,7 +1243,10 @@ async fn run_html_export_inner( .context("failed to save compressed full image")?; Ok(()) }) - .await?; + .await + // See the thumbnail branch: a panic here must cost this one full variant (the + // original is then streamed as-is below), never the whole keepsake. + .unwrap_or_else(|e| Err(anyhow::anyhow!("full-image task panicked: {e}"))); match compress_result { Ok(()) => MediaSource::Temp(full_path), @@ -1256,9 +1270,9 @@ async fn run_html_export_inner( // writer skip it silently while `data.json` still advertised it — the viewer then drew a // broken image tile for an entry the archive never contained. if let Some(name) = &thumb_name { - media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name)))); + media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name)), false)); } - media_manifest.push((full_name.clone(), full_source)); + media_manifest.push((full_name.clone(), full_source, true)); // Build comments for this upload let post_comments: Vec = comments @@ -1372,11 +1386,22 @@ async fn run_html_export_inner( // whose ffmpeg step failed) are skipped — the viewer tolerates gaps. let file_total = media_manifest.len().max(1) as f32; let mut files_written = 0u32; + // Photos, not files — the number `check_export_completeness` is actually about. + // + // `files_written` counts MANIFEST ROWS, and there are up to two per upload: a thumbnail and + // a full variant. Thumbnails are 400px JPEGs this export GENERATES ITSELF into its own temp + // dir, so they are no evidence that any original was captured. Counting them meant the one + // remaining fatal case — nothing at all was written — could not fire while thumbnails kept + // succeeding: if the media volume became unreadable after the stat pass, every original + // open failed and every thumb open succeeded, and a keepsake with 100 thumbnails and ZERO + // full-resolution photos published green, `done` at the live epoch, with the download + // button lit. Boot recovery skips a `done` job, so nothing would ever have rebuilt it. + let mut full_written = 0usize; // See `check_export_completeness`: a viewer that tolerates gaps must still not publish an // archive with no media in it at all. let mut media_skipped = 0usize; - for (name, source) in &media_manifest { + for (name, source, is_full) in &media_manifest { let path = source.path(); // Open-first: a source that disappeared between the manifest being built and now (a // delete, or the hourly sweep reclaiming a long-failed original) must skip this entry, @@ -1401,6 +1426,9 @@ async fn run_html_export_inner( zip_entry.close().await?; files_written += 1; + if *is_full { + full_written += 1; + } let pct = 78 + (files_written as f32 / file_total * 20.0) as i16; if !update_progress(pool, event_id, "html", epoch, pct.min(98)).await { return Err(Superseded.into()); @@ -1421,7 +1449,7 @@ async fn run_html_export_inner( "HTML", event_id, uploads.len(), - files_written as usize, + full_written, upload_skipped + media_skipped, ) .inspect_err(|_| {