fix(export): give the keepsake viewer the two-phase preflight it was meant to get
The two-phase preflight from eb0e405 landed in ONE place and was spliced inside
the other. `run_zip_export` ended up containing both blocks nested, so the
Gallery path pruned "Memories" archives that were not its to reclaim, while
`run_html_export` silently kept the single-phase form.
That left the exact deadlock the two-phase preflight exists to break, still
open on half the product. At a gallery size where a rebuild needs the previous
generation's bytes: the ZIP prunes its own superseded archive and rebuilds, and
the HTML preflight fails against a Memories archive still on disk. The prune
that would free it runs only after a success that can never happen, and any
epoch bump — a guest deleting one photo — retires the current viewer
immediately. Permanently stuck, unreachable from any handler, discovered at the
end of the night with nobody there.
Both halves now call one `ensure_export_space_reclaiming`, keyed on the
caller's OWN prefix, so they cannot drift again.
Three smaller things found in the same pass:
- The boot-failure panel hardcoded light-mode colours, and its heading set none
at all — the UA default black on the `#100f0f` dark background. On the one
screen whose entire job is to be readable, and in the failure mode where the
app's own stylesheet may be what did not load. Moved to classes in the inline
<style> so the `html.dark` variants apply.
- `.env.example` assigned RUST_LOG twice, 120 lines apart. Compose takes the
last one, so an operator raising the level mid-event to chase a problem would
have changed nothing, silently.
- The comment justifying `detail = ?message` in error.rs still claimed
`validate_display_name` allows newlines. It rejects control characters now —
but that is one input against every 4xx message in the app, so the escaping
is what makes the guarantee general. Said so.
151/151 backend, 58/58 vitest, clippy clean, svelte-check 0 errors, both
builds, caddy validate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
.env.example
12
.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.
|
# limit — an OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||||
DATABASE_MAX_CONNECTIONS=15
|
DATABASE_MAX_CONNECTIONS=15
|
||||||
|
|
||||||
# Log level. `info` is the right production default: at `debug` the tower-http trace
|
# Log level: see the "Logging" section near the bottom of this file.
|
||||||
# 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
|
# Defined THERE and nowhere else, deliberately. This file used to assign RUST_LOG twice —
|
||||||
# (docker-compose.yml), so a chatty level buys you a shorter history, not more of it.
|
# once here and once there — and Compose takes the LAST assignment, so editing this line to
|
||||||
# To debug a live event: RUST_LOG=eventsnap_backend=debug docker compose up -d app
|
# `debug` to chase a problem during the event changed nothing at all, silently. A key that
|
||||||
RUST_LOG=info
|
# appears twice in a .env is a trap regardless of which value is better.
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
# Generate with: openssl rand -hex 64
|
# Generate with: openssl rand -hex 64
|
||||||
|
|||||||
@@ -99,12 +99,15 @@ impl IntoResponse for AppError {
|
|||||||
//
|
//
|
||||||
// * `message` is tracing's own reserved field for an event's format literal, so `%message`
|
// * `message` is tracing's own reserved field for an event's format literal, so `%message`
|
||||||
// printed unlabelled and would collide under a JSON layer.
|
// printed unlabelled and would collide under a JSON layer.
|
||||||
// * Debug formatting QUOTES AND ESCAPES the string. `validate_display_name` allows
|
// * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate
|
||||||
// newlines (it rejects only NUL and length), and several 4xx messages interpolate the
|
// attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and
|
||||||
// guest's chosen name — `Der Name "X" ist bereits vergeben.` So with Display
|
// multipart/parse errors that echo their input. With Display formatting, a value
|
||||||
// formatting, two unauthenticated `/join` requests could forge arbitrary lines in the
|
// carrying a newline plus a plausible log prefix lets two unauthenticated requests
|
||||||
// only forensic record an unattended event has: pick a name containing a newline and a
|
// forge lines in the only forensic record an unattended event has.
|
||||||
// plausible log prefix, then trigger the 409. Escaping closes that.
|
// `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
|
// 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
|
// expired session, a mistyped URL) and they are the cheapest lines for a scanner to
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
// ── ZIP export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn run_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
|
// `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
|
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
|
||||||
// host gets the reason.
|
// host gets the reason.
|
||||||
// Two-phase, and the order is the whole point.
|
ensure_export_space_reclaiming(pool, event_id, export_path, "Gallery", epoch).await?;
|
||||||
//
|
|
||||||
// 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?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On error, mark THIS generation failed — a no-op if we've since been superseded (the
|
// 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
|
// 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
|
// See run_zip_export: refuse at the door rather than ENOSPC mid-write, and reclaim the
|
||||||
// superseded generation only AFTER this one lands.
|
// superseded generation only AFTER this one lands — unless it is the only way to land at all.
|
||||||
ensure_export_space(pool, event_id, export_path).await?;
|
ensure_export_space_reclaiming(pool, event_id, export_path, "Memories", epoch).await?;
|
||||||
|
|
||||||
let res = run_html_export_inner(
|
let res = run_html_export_inner(
|
||||||
epoch,
|
epoch,
|
||||||
|
|||||||
@@ -64,11 +64,18 @@
|
|||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
var boot = document.getElementById('app-boot');
|
var boot = document.getElementById('app-boot');
|
||||||
if (!boot) return; // app mounted — nothing to do
|
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 <style>
|
||||||
|
// below — a boot failure includes the case where the app's own CSS 404'd
|
||||||
|
// too, so nothing may set a text colour. Inline styles cannot express the
|
||||||
|
// `html.dark` variant, and the first version's un-coloured heading rendered
|
||||||
|
// as the UA default black on the #100f0f dark background: invisible, on the
|
||||||
|
// one screen whose entire job is to be readable.
|
||||||
boot.innerHTML =
|
boot.innerHTML =
|
||||||
'<div style="max-width:20rem;text-align:center">' +
|
'<div class="app-boot__fail">' +
|
||||||
'<p style="font-family:Georgia,serif;font-size:1.125rem;font-weight:600;margin:0 0 .5rem">Die App konnte nicht geladen werden</p>' +
|
'<p class="app-boot__fail-title">Die App konnte nicht geladen werden</p>' +
|
||||||
'<p style="margin:0 0 1.25rem;color:#545350;line-height:1.5">Bitte prüf deine Verbindung und lade die Seite neu.</p>' +
|
'<p class="app-boot__fail-text">Bitte prüf deine Verbindung und lade die Seite neu.</p>' +
|
||||||
'<button id="app-boot-reload" style="font:inherit;font-weight:600;cursor:pointer;border:0;border-radius:.5rem;padding:.625rem 1.25rem;background:#8a6a2b;color:#fff">Neu laden</button>' +
|
'<button id="app-boot-reload" class="app-boot__fail-btn">Neu laden</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
var btn = document.getElementById('app-boot-reload');
|
var btn = document.getElementById('app-boot-reload');
|
||||||
if (btn) btn.addEventListener('click', function () { location.reload(); });
|
if (btn) btn.addEventListener('click', function () { location.reload(); });
|
||||||
@@ -138,6 +145,46 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Boot-failure panel, swapped in by the backstop timer above. Every colour is
|
||||||
|
stated explicitly in both themes: when this renders, the app's own stylesheet
|
||||||
|
may well be one of the things that failed to load. */
|
||||||
|
.app-boot__fail {
|
||||||
|
max-width: 20rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.app-boot__fail-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1c1b1a;
|
||||||
|
}
|
||||||
|
.app-boot__fail-text {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #545350;
|
||||||
|
}
|
||||||
|
html.dark .app-boot__fail-title {
|
||||||
|
color: #f2f0ed;
|
||||||
|
}
|
||||||
|
html.dark .app-boot__fail-text {
|
||||||
|
color: #a6a4a1;
|
||||||
|
}
|
||||||
|
.app-boot__fail-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: #8a6a2b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
html.dark .app-boot__fail-btn {
|
||||||
|
background: #c6a24a;
|
||||||
|
color: #100f0f;
|
||||||
|
}
|
||||||
/* Sits above #app-boot, which is `position: fixed` and would otherwise cover it. */
|
/* Sits above #app-boot, which is `position: fixed` and would otherwise cover it. */
|
||||||
.app-boot__noscript {
|
.app-boot__noscript {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user