fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session

Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:

HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
  no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
  banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
  /feed/delta; feed + diashow evict those users. Applied even on a truncated delta.

MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
  terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
  the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
  in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
  race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
  hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.

Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.

LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.

Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.

Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-13 21:06:28 +02:00
parent 641174717c
commit 36fe59caa5
28 changed files with 1185 additions and 302 deletions

View File

@@ -96,30 +96,45 @@ pub async fn enqueue_and_spawn_exports(
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) -> Result<()> {
for export_type in ["zip", "html"] {
// Reset a prior 'done'/'failed' row to 'pending' so this (re)release regenerates —
// but DO NOT touch a row that's still 'running'. If a worker from an earlier
// release is mid-export, leaving it 'running' makes the worker we spawn below bail
// its claim (WHERE status='pending' → 0 rows), so the two never race the temp file.
// Only (re)generate a type that isn't ALREADY complete. A (re)release always arrives
// with both ready flags false (a fresh release starts false; `open_event` clears them
// before a re-release), so both regenerate as intended. But startup recovery of a
// half-finished export (e.g. ZIP done+ready, HTML crashed) must leave the good half
// alone — resetting it would 404 an already-downloadable keepsake until it needlessly
// regenerates. Skip the ready ones; their still-`done` rows keep their `file_path`, and
// the worker we spawn for them below simply bails its claim (no `pending` row to grab).
let (zip_ready, html_ready): (bool, bool) = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_one(&pool)
.await?;
for (export_type, ready) in [("zip", zip_ready), ("html", html_ready)] {
if ready {
continue;
}
// Reset the row to a clean 'pending' and BUMP `release_seq` — unconditionally, even
// if a worker from a prior release is still 'running'. That worker captured the old
// seq at claim time; bumping it here supersedes its generation, so when it finishes
// its guarded finalize (`WHERE release_seq = <captured>`) matches nothing and it
// discards its now-stale output instead of overwriting a fresh keepsake. The worker
// we spawn below then claims this new generation and regenerates from a current
// snapshot. Per-seq temp/final paths (see `run_zip_export`/`run_html_export`) keep
// the old and new workers from racing the same file on disk.
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct)
VALUES ($1, $2::export_type, 'pending', 0)
"INSERT INTO export_job (event_id, type, status, progress_pct, release_seq)
VALUES ($1, $2::export_type, 'pending', 0, 1)
ON CONFLICT (event_id, type) DO UPDATE
SET status = 'pending', progress_pct = 0, file_path = NULL,
error_message = NULL, completed_at = NULL
WHERE export_job.status <> 'running'",
error_message = NULL, completed_at = NULL,
release_seq = export_job.release_seq + 1",
)
.bind(event_id)
.bind(export_type)
.execute(&pool)
.await?;
}
// Clear readiness so /export downloads 404 until the fresh run completes, instead of
// serving a stale keepsake that predates a reopen→re-release.
sqlx::query("UPDATE event SET export_zip_ready = FALSE, export_html_ready = FALSE WHERE id = $1")
.bind(event_id)
.execute(&pool)
.await?;
spawn_export_jobs(event_id, event_name, pool, media_path, export_path, sse_tx);
Ok(())
@@ -182,9 +197,10 @@ pub fn spawn_export_jobs(
let event_name2 = event_name.clone();
tokio::spawn(async move {
// Failure marking happens INSIDE the worker (guarded by the claimed `release_seq`),
// so a superseded worker's error can't clobber the fresh generation's row.
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &export_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
@@ -194,7 +210,6 @@ pub fn spawn_export_jobs(
run_html_export(event_id, &event_name2, &pool2, &media_path2, &export_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
@@ -209,11 +224,29 @@ async fn run_zip_export(
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
if !claim_job(pool, event_id, "zip").await {
let seq = match claim_job(pool, event_id, "zip").await {
Some(seq) => seq,
// Another worker already owns this ZIP export — bail rather than race the temp file.
return Ok(());
}
None => return Ok(()),
};
// Run the body under the captured `seq`; on error mark THIS generation failed (a no-op
// if a re-release already superseded us).
let res = run_zip_export_inner(seq, event_id, pool, media_path, export_path, sse_tx).await;
if let Err(e) = &res {
mark_failed(pool, event_id, "zip", seq, &e.to_string()).await;
}
res
}
async fn run_zip_export_inner(
seq: i64,
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
@@ -221,8 +254,13 @@ async fn run_zip_export(
let exports_dir = export_path.to_path_buf();
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
let out_path = exports_dir.join("Gallery.zip");
// Per-generation paths: a superseded worker (older `seq`) and the fresh worker never
// share a file on disk, so neither can truncate or interleave the other's bytes. The
// final name embeds the seq too; the download handler serves whatever `file_path` the
// current 'done' row points at, so an orphaned older generation is never served.
let tmp_path = exports_dir.join(format!("Gallery.zip.{seq}.tmp"));
let out_name = format!("Gallery.{seq}.zip");
let out_path = exports_dir.join(&out_name);
{
let file = tokio::fs::File::create(&tmp_path).await?;
@@ -247,7 +285,7 @@ async fn run_zip_export(
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
update_progress(pool, event_id, "zip", seq, pct.min(99)).await;
}
zip.close().await?;
@@ -255,19 +293,31 @@ async fn run_zip_export(
tokio::fs::rename(&tmp_path, &out_path).await?;
// Guarded finalize: commit ONLY if our generation is still current. If a reopen→
// re-release bumped `release_seq` while we were streaming, we lost — discard our (now
// stale) archive and let the fresh worker own the keepsake. This is the fix for the
// stale-keepsake data loss: a superseded worker can no longer flip `export_zip_ready`.
if !finalize_job(pool, event_id, "zip", seq, &format!("exports/{out_name}")).await {
let _ = tokio::fs::remove_file(&out_path).await;
tracing::info!("ZIP export for event {event_id} superseded by a newer release; discarded");
return Ok(());
}
// Flip the ready flag only while still current (a re-release between finalize and here
// would have cleared it; don't resurrect it for a superseded generation).
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
"UPDATE event SET export_zip_ready = TRUE
WHERE id = $1
AND EXISTS (SELECT 1 FROM export_job
WHERE event_id = $1 AND type = 'zip'::export_type
AND release_seq = $2 AND status = 'done')",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.bind(seq)
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
prune_stale_export_files(&exports_dir, "Gallery", event_id, seq).await;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
@@ -305,25 +355,43 @@ async fn run_html_export(
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
if !claim_job(pool, event_id, "html").await {
let seq = match claim_job(pool, event_id, "html").await {
Some(seq) => seq,
// Another worker already owns this HTML export — bail rather than race the temp dir.
return Ok(());
}
None => return Ok(()),
};
let res =
run_html_export_inner(seq, event_id, event_name, pool, media_path, export_path, sse_tx).await;
if let Err(e) = &res {
mark_failed(pool, event_id, "html", seq, &e.to_string()).await;
}
res
}
async fn run_html_export_inner(
seq: i64,
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
// 1. Query data
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
update_progress(pool, event_id, "html", 5).await;
update_progress(pool, event_id, "html", seq, 5).await;
// Written OUTSIDE media_path: the public /media ServeDir must never reach these.
let exports_dir = export_path.to_path_buf();
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
// 2. Create temp directory for media processing (per-generation — see run_zip_export).
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{seq}"));
let media_tmp = tmp_dir.join("media");
tokio::fs::create_dir_all(&media_tmp).await?;
@@ -485,7 +553,7 @@ async fn run_html_export(
});
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
update_progress(pool, event_id, "html", pct.min(69)).await;
update_progress(pool, event_id, "html", seq, pct.min(69)).await;
}
// 4. Build data.json
@@ -499,11 +567,12 @@ async fn run_html_export(
let data_json =
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
update_progress(pool, event_id, "html", 72).await;
update_progress(pool, event_id, "html", seq, 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip");
// 5. Create ZIP (per-generation paths — see run_zip_export)
let tmp_path = exports_dir.join(format!("Memories.zip.{seq}.tmp"));
let out_name = format!("Memories.{seq}.zip");
let out_path = exports_dir.join(&out_name);
{
let file = tokio::fs::File::create(&tmp_path).await?;
@@ -512,7 +581,7 @@ async fn run_html_export(
// Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
update_progress(pool, event_id, "html", 75).await;
update_progress(pool, event_id, "html", seq, 75).await;
// Write data.json
{
@@ -532,7 +601,7 @@ async fn run_html_export(
entry.close().await?;
}
update_progress(pool, event_id, "html", 78).await;
update_progress(pool, event_id, "html", seq, 78).await;
// Write media files from the manifest built in step 3. Thumbnails and derived
// image variants stream from temp; video and small-image full variants stream
@@ -556,7 +625,7 @@ async fn run_html_export(
files_written += 1;
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
update_progress(pool, event_id, "html", pct.min(98)).await;
update_progress(pool, event_id, "html", seq, pct.min(98)).await;
}
zip.close().await?;
@@ -568,19 +637,26 @@ async fn run_html_export(
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
// Guarded finalize — discard if a re-release superseded us mid-export (see run_zip_export).
if !finalize_job(pool, event_id, "html", seq, &format!("exports/{out_name}")).await {
let _ = tokio::fs::remove_file(&out_path).await;
tracing::info!("HTML export for event {event_id} superseded by a newer release; discarded");
return Ok(());
}
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
"UPDATE event SET export_html_ready = TRUE
WHERE id = $1
AND EXISTS (SELECT 1 FROM export_job
WHERE event_id = $1 AND type = 'html'::export_type
AND release_seq = $2 AND status = 'done')",
)
.bind(event_id)
.bind("exports/Memories.zip")
.bind(seq)
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
prune_stale_export_files(&exports_dir, "Memories", event_id, seq).await;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
@@ -640,45 +716,138 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
Ok(rows)
}
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
/// worker already owns it — e.g. a reopen→re-release spawned a second worker while a run
/// from the first release is still in flight. Both write the SAME fixed temp path
/// (`Gallery.zip.tmp` / `viewer_tmp_*`), so a loser MUST NOT run or it would interleave
/// bytes and corrupt the archive. The row-level lock serializes the two UPDATEs, so
/// exactly one sees `status = 'pending'`.
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
sqlx::query(
/// Atomically claim a pending export job for this worker and return the generation
/// (`release_seq`) it claimed. Returns `Some(seq)` only if we won (status flipped
/// `pending`→`running` in one statement); `None` when another worker already owns it — e.g.
/// a reopen→re-release spawned a second worker while a run from a prior release is still in
/// flight. The row-level lock serializes the two UPDATEs, so exactly one sees
/// `status = 'pending'`. The caller threads the returned seq into per-generation temp/final
/// paths and into the guarded finalize, so a superseded worker never corrupts or resurrects
/// a stale keepsake.
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> Option<i64> {
sqlx::query_as::<_, (i64,)>(
"UPDATE export_job SET status = 'running'
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'",
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'
RETURNING release_seq",
)
.bind(event_id)
.bind(export_type)
.fetch_optional(pool)
.await
.ok()
.flatten()
.map(|(seq,)| seq)
}
/// Guarded finalize: mark this export `done` and record its file path ONLY if our
/// generation is still current (`release_seq = seq` and still `running`). Returns `true` if
/// we won the finalize, `false` if a re-release superseded us mid-export (in which case the
/// caller must discard its output — the fresh worker owns the keepsake now). Atomic, so it
/// can't race a concurrent re-release.
async fn finalize_job(
pool: &PgPool,
event_id: Uuid,
export_type: &str,
seq: i64,
file_path: &str,
) -> bool {
sqlx::query(
"UPDATE export_job
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
WHERE event_id = $1 AND type = $2::export_type
AND release_seq = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(file_path)
.bind(seq)
.execute(pool)
.await
.map(|r| r.rows_affected() > 0)
.unwrap_or(false)
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
/// Parse the trailing generation number out of `<prefix><n><suffix>` (e.g.
/// `Gallery.<n>.zip`, `Gallery.zip.<n>.tmp`, `viewer_tmp_<event>_<n>`). Returns None if the
/// name doesn't fit the shape, so unrelated files are left untouched.
fn parse_gen_seq(name: &str, prefix: &str, suffix: &str) -> Option<i64> {
name.strip_prefix(prefix)?.strip_suffix(suffix)?.parse::<i64>().ok()
}
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
/// and never a NEWER generation that a concurrent re-release may already be producing (that
/// "delete all but mine" would let a lagging older winner nuke a newer live file). Covers the
/// finished archive (`<prefix>.<n>.zip`), its temp (`<prefix>.zip.<n>.tmp`), and — for html —
/// the `viewer_tmp_<event>_<n>` staging dir, so crash-orphaned per-generation temps don't
/// accumulate. Runs after a worker wins its finalize; older generations can never be served
/// again (their `file_path` was nulled by the re-release that superseded them). Tolerates
/// races and IO errors — purely disk hygiene.
async fn prune_stale_export_files(exports_dir: &Path, prefix: &str, event_id: Uuid, keep_seq: i64) {
let final_prefix = format!("{prefix}."); // Gallery. / Memories.
let temp_prefix = format!("{prefix}.zip."); // Gallery.zip. / Memories.zip.
let viewer_prefix = format!("viewer_tmp_{event_id}_");
let mut rd = match tokio::fs::read_dir(exports_dir).await {
Ok(rd) => rd,
Err(_) => return,
};
while let Ok(Some(entry)) = rd.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
// Try each artifact shape; a strictly-older seq in any of them marks it for deletion.
// Check the temp shape before the final shape: `Gallery.zip.<n>.tmp` also starts with
// `Gallery.` but isn't a `<n>.zip`, so its final-shape parse returns None anyway.
let seq = parse_gen_seq(&name, &temp_prefix, ".tmp")
.or_else(|| parse_gen_seq(&name, &final_prefix, ".zip"))
.or_else(|| {
if prefix == "Memories" {
parse_gen_seq(&name, &viewer_prefix, "")
} else {
None
}
});
if seq.is_some_and(|n| n < keep_seq) {
let path = entry.path();
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
let _ = if is_dir {
tokio::fs::remove_dir_all(&path).await
} else {
tokio::fs::remove_file(&path).await
};
}
}
}
/// Mark this worker's export `failed` — but ONLY for the generation it was running
/// (`release_seq = seq` and still `running`). Without the seq guard, a superseded worker
/// erroring out after a re-release would clobber the FRESH worker's `running`/`pending` row
/// to `failed`, stranding the new keepsake. A superseded worker's failure is a no-op here.
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, seq: i64, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
WHERE event_id = $1 AND type = $2::export_type
AND release_seq = $4 AND status = 'running'",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.bind(seq)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
/// Update the progress bar for THIS generation only (`release_seq = seq`). Seq-guarded so a
/// superseded worker still streaming can't overwrite the fresh generation's progress and make
/// the bar jump backwards. Cosmetic, but keeps the displayed percent monotonic per release.
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, seq: i64, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
"UPDATE export_job SET progress_pct = $3
WHERE event_id = $1 AND type = $2::export_type AND release_seq = $4",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.bind(seq)
.execute(pool)
.await;
}