fix(export): four ways the keepsake could be lost, stranded, or published empty

* The HTML completeness guard counted manifest ROWS, and there are up to two
  per upload — a thumbnail and a full variant. Thumbnails are 400px JPEGs the
  export generates itself into its own temp dir, so they are no evidence that
  any original was captured. After the guard was relaxed to bail only on
  "nothing written at all", that case could no longer fire while thumbnails
  kept succeeding: if the media volume became unreadable after the stat pass,
  every original open failed, 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. Now counts photos, not files.

* A decoder panic failed the ENTIRE keepsake. The `?` was on the JoinError,
  not on the closure's Result, so a panic in the image crate propagated out
  where the same file merely failing costs one tile — and it was
  deterministic, because "Neu erzeugen" reads the same poison file and dies
  the same way. That is the exact failure shape the completeness guard was
  relaxed to eliminate, arriving through the other door.

* delete_account armed both export jobs and then spawned the workers AFTER an
  awaited file-removal loop. Axum drops a handler future on client
  disconnect, and every other invalidate_and_arm call site spawns with no
  intervening await. Dropped inside that loop, the keepsake is left with the
  epoch bumped, both rows pending at that epoch, and no worker: the downloads
  404 and the UI sits on "Wird vorbereitet..." until someone reboots the app.
  Deleting your account from a phone that walks out of range is enough.

* The daily download quota was charged before the ticket could fail, so a
  store-capacity 503 — a server-side condition the guest cannot see or cause
  — still cost one of their three downloads. There is no refund path.
This commit is contained in:
fabi
2026-08-12 09:15:07 +02:00
parent 9b38d31f97
commit 6afb33e5b6
3 changed files with 56 additions and 17 deletions

View File

@@ -1100,7 +1100,10 @@ async fn run_html_export_inner(
let mut viewer_posts: Vec<ViewerPost> = 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<ViewerComment> = 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(|_| {