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 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:26 +02:00
parent 8720571beb
commit f5c55d6f92
3 changed files with 101 additions and 9 deletions

4
backend/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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();

View File

@@ -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.