From f5c55d6f927d7ed2fd25737db388fa9dc6f6d688 Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 11 Aug 2026 22:44:26 +0200 Subject: [PATCH] chore(backend): route wiring, error mapping, and a crossbeam-epoch bump Cargo.lock moves crossbeam-epoch to 0.9.20, clearing RUSTSEC-2026-0204. Targeted rather than a broad `cargo update` across 406 crates, which is not a change to make days before a live event. Co-Authored-By: Claude Opus 5 --- backend/Cargo.lock | 4 +-- backend/src/error.rs | 30 +++++++++++++++-- backend/src/main.rs | 76 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cb4cbea..ba4988e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -605,9 +605,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/backend/src/error.rs b/backend/src/error.rs index 8ba412e..e05ebdf 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -11,6 +11,27 @@ pub enum AppError { /// (banned user, quota): the queued blob is kept and retried if the host reopens, /// instead of being purged like a genuinely-terminal rejection. UploadsLocked(String), + /// The gallery has been RELEASED — the keepsake was snapshotted, so a late upload could + /// never appear in it. Mechanically this is still reversible (a host reopen clears + /// `export_released_at` and bumps the epoch), which is why the blob must still be kept. + /// + /// Distinct from `UploadsLocked` because the two differ in *expectation*, and the client's + /// retry policy has to differ with them. A closed event is a pause the host means to undo; + /// a released gallery is the end of the event, and nobody reopens it. Under one shared code + /// the queue kept auto-retrying a released event forever — re-streaming a multi-megabyte + /// photo over cellular on every budget refill, for a request whose answer will not change, + /// while telling the guest to tap a camera button that 403s. `gallery_released` lets the + /// client park the item visibly and wait for an actual `event-opened` instead of guessing. + GalleryReleased(String), + /// The uploader is banned. A 403 like `Forbidden`, but tagged `user_banned` so the client + /// keeps the queued blob instead of purging it. + /// + /// A ban is reversible — `unban_user` exists, and the host's own confirm copy promises the + /// photos come back — but the client classified the generic `forbidden` code as permanent, + /// deleted the blob from IndexedDB, and moved the row to `blocked`, which has no retry + /// button. So an unban could restore everything except the photos that were in flight when + /// the ban landed, and a ban issued by mistake destroyed them with no way back. + UserBanned(String), NotFound(String), Conflict(String), /// Second field: optional retry-after seconds to include in the response. @@ -35,6 +56,8 @@ impl AppError { Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"), + Self::GalleryReleased(_) => (StatusCode::FORBIDDEN, "gallery_released"), + Self::UserBanned(_) => (StatusCode::FORBIDDEN, "user_banned"), Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), @@ -52,6 +75,8 @@ impl AppError { | Self::Unauthorized(msg) | Self::Forbidden(msg) | Self::UploadsLocked(msg) + | Self::GalleryReleased(msg) + | Self::UserBanned(msg) | Self::NotFound(msg) | Self::Conflict(msg) => msg.clone(), Self::TooManyRequests(msg, _) => msg.clone(), @@ -190,9 +215,8 @@ mod tests { AppError::ServiceUnavailable("busy".into(), Some(3)), ] { let expected = match &err { - AppError::TooManyRequests(_, Some(s)) | AppError::ServiceUnavailable(_, Some(s)) => { - s.to_string() - } + AppError::TooManyRequests(_, Some(s)) + | AppError::ServiceUnavailable(_, Some(s)) => s.to_string(), _ => unreachable!(), }; let resp = err.into_response(); diff --git a/backend/src/main.rs b/backend/src/main.rs index 1ecb7a5..1d4cd74 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -41,6 +41,20 @@ async fn main() -> Result<()> { .init(); let config = AppConfig::from_env()?; + + // Prove both media directories are writable BEFORE anything else runs. This is first + // because everything downstream — the derivative backfill, export recovery, every upload — + // assumes it silently. + // + // This used to be `create_dir_all(&media_path).await.ok()` far below, which discarded the + // only signal there was, and EXPORT_PATH was never created or probed at all. The failure + // mode that produced: a wrong bind mount or a root-owned volume left the app booting + // *green* — `/health` only probes the database — so Caddy routed traffic to it, guests + // joined, and every single upload failed with EACCES. Existence is not the property we + // need; writability is, and the only way to know is to write. + ensure_writable_dir(&config.media_path, "MEDIA_PATH").await?; + ensure_writable_dir(&config.export_path, "EXPORT_PATH").await?; + let pool = db::create_pool(&config.database_url).await?; // Reset any rows left mid-flight by a previous (possibly crashed) instance — @@ -86,9 +100,6 @@ async fn main() -> Result<()> { config.media_path.clone(), ); - // Ensure media directories exist - tokio::fs::create_dir_all(&config.media_path).await.ok(); - let api = Router::new() // Auth .route("/api/v1/event", get(handlers::public::get_public_event)) @@ -138,6 +149,10 @@ async fn main() -> Result<()> { // Current-user endpoints (live quota estimate, profile + privacy note bundle) .route("/api/v1/me/context", get(handlers::me::get_context)) .route("/api/v1/me/quota", get(handlers::me::get_quota)) + // Self-service erasure. There was no user-deletion route at any role, so an erasure + // request could only be honoured with hand-written SQL against production — and the join + // page's data notice now promises this exists. See `me::delete_account`. + .route("/api/v1/me", delete(handlers::me::delete_account)) // Feed .route("/api/v1/feed", get(handlers::feed::feed)) .route("/api/v1/feed/delta", get(handlers::feed::feed_delta)) @@ -278,7 +293,10 @@ async fn main() -> Result<()> { // * The two handlers were the same `SELECT 1` with the same 2s timeout under two // names, so keeping both bought nothing. // - // The external uptime monitor the runbook now calls for points at this route. + // The external uptime monitor points at this route — DEPLOYMENT_RUNBOOK.md §10.4, + // which documents the response table and is the only thing in this deployment that + // can page a human. (That section previously did not exist and this comment claimed + // it did; if you are removing §10.4, this route loses its only consumer.) .route("/health", get(health)) .merge(api) .layer(TraceLayer::new_for_http()) @@ -299,6 +317,56 @@ async fn main() -> Result<()> { Ok(()) } +/// Create `dir` if absent, then prove we can actually write inside it. Hard error otherwise. +/// +/// `create_dir_all` succeeding proves nothing: it is a no-op on an existing directory, so a +/// root-owned volume, a read-only bind mount and a full filesystem all "succeed". The probe +/// below is the only thing that distinguishes them, and it is worth the two syscalls once per +/// boot to turn a silent evening of failed uploads into a container that refuses to start. +/// +/// `label` is the env var name so the operator gets the name of the knob to fix, not a path +/// they then have to trace back to a variable. +async fn ensure_writable_dir(dir: &std::path::Path, label: &str) -> anyhow::Result<()> { + use anyhow::Context; + + tokio::fs::create_dir_all(dir) + .await + .with_context(|| format!("{label}: cannot create {}", dir.display()))?; + + // A fixed name is fine: this runs once, before the server accepts requests, and two + // instances sharing one volume would be a misconfiguration in its own right. Removed on + // both the success and failure paths so a crashed boot cannot leave litter behind. + let probe = dir.join(".eventsnap-write-probe"); + let result = async { + let mut f = tokio::fs::File::create(&probe) + .await + .with_context(|| format!("{label}: cannot create a file in {}", dir.display()))?; + // Write and fsync rather than just create: a full filesystem lets the create succeed + // and fails at the first byte, which is exactly the disk-full endgame this guards. + tokio::io::AsyncWriteExt::write_all(&mut f, b"ok") + .await + .with_context(|| format!("{label}: cannot write to {}", dir.display()))?; + f.sync_all() + .await + .with_context(|| format!("{label}: cannot flush to {}", dir.display()))?; + anyhow::Ok(()) + } + .await; + let _ = tokio::fs::remove_file(&probe).await; + + result.with_context(|| { + format!( + "{label} ({}) is not writable. The app refuses to start rather than accept uploads \ + it cannot store — check the bind mount and that the volume is owned by the \ + container's non-root user.", + dir.display() + ) + })?; + + tracing::info!(path = %dir.display(), "{label} is writable"); + Ok(()) +} + /// How long `/health` waits for the database before calling the app unhealthy. Deliberately /// short: the point is to answer "can this process actually serve a request right now", and a /// probe that blocks for the acquire timeout is itself a symptom.