diff --git a/.env.example b/.env.example index d2bf4a8..ce90e45 100644 --- a/.env.example +++ b/.env.example @@ -51,12 +51,12 @@ POSTGRES_DB=eventsnap # limit — an OOM in Postgres doesn't degrade one feature, it takes the whole event down. DATABASE_MAX_CONNECTIONS=15 -# Log level. `info` is the right production default: at `debug` the tower-http trace -# layer writes a line per request AND per response, which on a busy event is a large -# multiple of the useful output. Container logs are capped at 10m x 3 per service -# (docker-compose.yml), so a chatty level buys you a shorter history, not more of it. -# To debug a live event: RUST_LOG=eventsnap_backend=debug docker compose up -d app -RUST_LOG=info +# Log level: see the "Logging" section near the bottom of this file. +# +# Defined THERE and nowhere else, deliberately. This file used to assign RUST_LOG twice — +# once here and once there — and Compose takes the LAST assignment, so editing this line to +# `debug` to chase a problem during the event changed nothing at all, silently. A key that +# appears twice in a .env is a trap regardless of which value is better. # ── Authentication ──────────────────────────────────────────────────────────── # Generate with: openssl rand -hex 64 diff --git a/backend/src/error.rs b/backend/src/error.rs index eebe314..8ba412e 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -99,12 +99,15 @@ impl IntoResponse for AppError { // // * `message` is tracing's own reserved field for an event's format literal, so `%message` // printed unlabelled and would collide under a JSON layer. - // * Debug formatting QUOTES AND ESCAPES the string. `validate_display_name` allows - // newlines (it rejects only NUL and length), and several 4xx messages interpolate the - // guest's chosen name — `Der Name "X" ist bereits vergeben.` So with Display - // formatting, two unauthenticated `/join` requests could forge arbitrary lines in the - // only forensic record an unattended event has: pick a name containing a newline and a - // plausible log prefix, then trigger the 409. Escaping closes that. + // * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate + // attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and + // multipart/parse errors that echo their input. With Display formatting, a value + // carrying a newline plus a plausible log prefix lets two unauthenticated requests + // forge lines in the only forensic record an unattended event has. + // `validate_display_name` now rejects control characters, so the name route is closed + // at the source as well — but that is ONE input, and this line formats every 4xx + // message in the app. Escaping here is what makes the guarantee general; do not + // "simplify" it to `%message` on the grounds that names are already validated. // // 401 and 404 are logged at DEBUG rather than WARN. They carry no operator signal (an // expired session, a mistyped URL) and they are the cheapest lines for a scanner to diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index a8c2eea..15e9e44 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -483,6 +483,45 @@ pub fn spawn_export_jobs( }); } +/// Preflight that will sacrifice the previous generation rather than deadlock, and the order is +/// the whole point. +/// +/// Deferring the prune (so a failed rebuild can never leave the event with no archive at all) has +/// a cost the first version of this did not follow through on: at rebuild time the previous +/// generation is still on disk and still counted against free space, so the preflight demands room +/// for BOTH. That halves the gallery size a rebuild can survive relative to the size the upload +/// gate allows — and every path that bumps the epoch (a guest deleting their own photo, a caption +/// edit, a ban, "Neu erzeugen") retires the current keepsake IMMEDIATELY on commit. So above that +/// threshold the download 404s, the rebuild is refused, and the only thing that could free the +/// space is the prune that now only runs on success. Permanently stuck, unreachable from any +/// handler. +/// +/// So: try to build while preserving the old generation. If that genuinely does not fit, the old +/// generation is the one thing we can reclaim — sacrifice it and try once more. A keepsake that +/// exists beats one we preserved but can never replace. +/// +/// SHARED by both halves deliberately. This started as two copies and one of them (HTML) silently +/// kept the single-phase form, so the ZIP archive rebuilt and the viewer stayed permanently stuck +/// — the exact deadlock above, on half the product. `prefix` is the only thing that differs, and +/// it must be the caller's OWN prefix: pruning the other half's archives from here would reclaim +/// space a sibling worker is about to need, on its behalf, without its knowledge. +async fn ensure_export_space_reclaiming( + pool: &PgPool, + event_id: Uuid, + export_path: &Path, + prefix: &str, + epoch: i64, +) -> Result<()> { + if ensure_export_space(pool, event_id, export_path).await.is_ok() { + return Ok(()); + } + tracing::warn!( + "not enough room to rebuild {prefix} alongside the previous keepsake; reclaiming it first" + ); + prune_superseded_archives(pool, export_path, prefix, event_id, epoch).await; + ensure_export_space(pool, event_id, export_path).await +} + // ── ZIP export ─────────────────────────────────────────────────────────────── async fn run_zip_export( @@ -503,49 +542,7 @@ async fn run_zip_export( // `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status // guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the // host gets the reason. - // Two-phase, and the order is the whole point. - // - // Deferring the prune (so a failed rebuild can never leave the event with no archive at - // all) has a cost the first version of this did not follow through on: at rebuild time the - // previous generation is still on disk and still counted against free space, so the - // preflight demands room for BOTH. That halves the gallery size a rebuild can survive - // relative to the size the upload gate allows — and every path that bumps the epoch (a - // guest deleting their own photo, a caption edit, a ban, "Neu erzeugen") retires the - // current keepsake IMMEDIATELY on commit. So above that threshold the download 404s, the - // rebuild is refused, and the only thing that could free the space is the prune that now - // only runs on success. Permanently stuck, unreachable from any handler. - // - // So: try to build while preserving the old generation. If that genuinely does not fit, - // the old generation is the one thing we can reclaim — sacrifice it and try once more. A - // keepsake that exists beats one we preserved but can never replace. - if ensure_export_space(pool, event_id, export_path).await.is_err() { - tracing::warn!( - "not enough room to rebuild alongside the previous keepsake; reclaiming it first" - ); - prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await; - // Two-phase, and the order is the whole point. - // - // Deferring the prune (so a failed rebuild can never leave the event with no archive at - // all) has a cost the first version of this did not follow through on: at rebuild time the - // previous generation is still on disk and still counted against free space, so the - // preflight demands room for BOTH. That halves the gallery size a rebuild can survive - // relative to the size the upload gate allows — and every path that bumps the epoch (a - // guest deleting their own photo, a caption edit, a ban, "Neu erzeugen") retires the - // current keepsake IMMEDIATELY on commit. So above that threshold the download 404s, the - // rebuild is refused, and the only thing that could free the space is the prune that now - // only runs on success. Permanently stuck, unreachable from any handler. - // - // So: try to build while preserving the old generation. If that genuinely does not fit, - // the old generation is the one thing we can reclaim — sacrifice it and try once more. A - // keepsake that exists beats one we preserved but can never replace. - if ensure_export_space(pool, event_id, export_path).await.is_err() { - tracing::warn!( - "not enough room to rebuild alongside the previous keepsake; reclaiming it first" - ); - prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await; - ensure_export_space(pool, event_id, export_path).await?; - } - } + ensure_export_space_reclaiming(pool, event_id, export_path, "Gallery", epoch).await?; // On error, mark THIS generation failed — a no-op if we've since been superseded (the // caller in `spawn_export_jobs` does it, epoch-guarded). Temp artifacts are cleaned up @@ -755,8 +752,8 @@ async fn run_html_export( } // See run_zip_export: refuse at the door rather than ENOSPC mid-write, and reclaim the - // superseded generation only AFTER this one lands. - ensure_export_space(pool, event_id, export_path).await?; + // superseded generation only AFTER this one lands — unless it is the only way to land at all. + ensure_export_space_reclaiming(pool, event_id, export_path, "Memories", epoch).await?; let res = run_html_export_inner( epoch, diff --git a/frontend/src/app.html b/frontend/src/app.html index 8b6a14b..638c779 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -64,11 +64,18 @@ setTimeout(function () { var boot = document.getElementById('app-boot'); if (!boot) return; // app mounted — nothing to do + // Classes, not inline styles. The panel must stay legible in dark mode, and + // the only stylesheet guaranteed to be present here is the inline