fix(upload): refuse undecodable images at the door, and stop retrying them
Two halves of the same complaint: an oversized photo was accepted with a 201 and then silently soft-deleted minutes later, after the worker had burned six seconds of backoff re-reaching a conclusion it could not change. Admission. The compression budget now runs at upload time, against the header only, so a guest is told immediately and told why: "Bild hat zu viele Bildpunkte (ca. 99 Megapixel) und kann nicht verarbeitet werden. Bitte verkleinere es und lade es erneut hoch." instead of watching the photo vanish behind a vague "could not be processed" — which arrived only if they happened to still be on the feed with that card loaded. Nothing is stored, so there is no row to soft-delete and no orphan for the sweep to reclaim. Admission and the worker share ONE function (`decoder_within_budget`), so they cannot drift apart and start disagreeing about what is acceptable — a photo accepted at the door and rejected by the worker would be worse than either behaviour alone. The worker keeps its own check: the backfill decodes files that predate this check, and defence in depth is the whole reason the budget exists. Retries. The loop retried every failure, including ones that are a property of the input. An image over the budget, a corrupt file, an unsupported format: each fails identically on all three attempts, so the only effect was 2s + 4s of sleep and three near-identical warnings before the same outcome. `is_permanent_image_error` classifies the `ImageError` variants that cannot change between attempts — Limits, Unsupported, Decoding — and the loop gives up on those at once. `IoError` is deliberately excluded: an ENOSPC while writing a derivative is exactly the transient case the retry exists for, and misclassifying it would turn a blip back into the data loss round 1 fixed. Measured: retry log lines went from 3 per oversized upload to 0. Tests: unit tests for both sides of the classifier (a Limits error is permanent, a missing file is not) and for admission agreeing with the decoder on accept AND reject. The e2e spec is rewritten for the new contract — 400 with an actionable message, nothing stored, backend alive after a burst of four — plus a mirror asserting an ordinary photo still uploads and processes, since a budget that rejected everything would satisfy the other two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -233,6 +233,28 @@ pub async fn upload(
|
||||
)));
|
||||
}
|
||||
|
||||
// Images only: refuse anything the compression worker could never decode, reading just
|
||||
// the header. Without this the upload is accepted with a 201 and then silently
|
||||
// soft-deleted minutes later when the worker gives up — the guest sees the photo
|
||||
// vanish with, at best, a vague "could not be processed". Rejecting here gives them a
|
||||
// reason at the door that they can act on, and it uses the SAME budget the worker
|
||||
// enforces, so admission and processing cannot disagree.
|
||||
if mime.starts_with("image/")
|
||||
&& let Err(e) = crate::services::imaging::probe_decodable(&temp_abs)
|
||||
{
|
||||
let mp = crate::services::imaging::megapixels(&temp_abs);
|
||||
tracing::info!(
|
||||
error = ?e, %mime, megapixels = ?mp,
|
||||
"rejecting an image that exceeds the decode budget at admission"
|
||||
);
|
||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||
let detail = mp.map_or(String::new(), |mp| format!(" (ca. {mp:.0} Megapixel)"));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Bild hat zu viele Bildpunkte{detail} und kann nicht verarbeitet werden. \
|
||||
Bitte verkleinere es und lade es erneut hoch."
|
||||
)));
|
||||
}
|
||||
|
||||
// Per-user storage quota — dynamic formula based on available disk space and the
|
||||
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||
// disable it on trusted instances.
|
||||
|
||||
Reference in New Issue
Block a user