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:
4
backend/Cargo.lock
generated
4
backend/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user