use anyhow::Result; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::routing::{delete, get, patch, post}; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod auth; mod config; mod db; mod error; mod handlers; mod models; mod services; mod state; use config::AppConfig; use state::AppState; /// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against /// memory-exhaustion; precise per-class size limits are enforced in the handler. pub(crate) const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024; /// Largest per-file size limit (MB) an operator may configure for `max_image_size_mb` / /// `max_video_size_mb`, derived from [`MAX_UPLOAD_BYTES`] rather than written down twice. /// /// This used to be enforced by a COMMENT — "if an admin raises max_video_size_mb above this, /// bump MAX_UPLOAD_BYTES" — while `patch_config` happily accepted 10240 and the admin dashboard /// rendered "Max. Videogröße (MB)" as a bare number field. Set it to 1000 and every video /// between 576 MB and the new limit is destroyed, in a shape that is much worse than a refusal: /// /// * The body limit trips mid-upload, inside `field.chunk()`, so `stream_field_to_file` maps /// it to `AppError::BadRequest` — a 400, not a 413 with a `quota_exceeded` code. /// * `classifyUploadStatus` (frontend/src/lib/upload-queue.ts) puts every non-401/408/429 4xx /// in the `terminal` bucket, and `isReversibleLock(400, 'bad_request')` is false — so the /// queue DELETES the blob from IndexedDB and moves the row to `blocked`, which by design /// offers no retry button. /// * All of that after the guest has already pushed 600 MB over cellular, and the message they /// get names a read failure rather than a limit. /// /// So the ceiling is enforced where the value is SET (`patch_config`) and again where it is READ /// (`handlers::upload`), because a value stored before this bound existed — or written by hand /// into the `config` table — would otherwise walk straight past the first check. /// /// The subtraction is the multipart envelope: the body carries the file PLUS the boundary /// framing and the `caption` / `hashtags` / `client_upload_id` fields. 1 MiB is enormously more /// than those can occupy (see the test below, which pins it against their actual caps) and costs /// nothing — the alternative is a limit that is satisfiable in theory and off-by-a-header in /// practice. pub(crate) const MAX_CONFIGURABLE_UPLOAD_MB: i64 = (MAX_UPLOAD_BYTES as i64 - 1024 * 1024) / (1024 * 1024); #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::registry() .with( // `info`, not `debug`. A stock deploy sets RUST_LOG nowhere (it is absent from // .env.example and was absent from docker-compose.yml), so this fallback IS the // production level — and at `debug` the TraceLayer below emits a line per request // AND per response, into a log file that had no rotation. `tower_http=warn` // rather than `info` states the intent: those spans are diagnostics, not an // access log, and a future `DefaultOnResponse::new().level(Level::INFO)` should // not silently re-enable them. tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "eventsnap_backend=info,tower_http=warn".into()), ) .with(tracing_subscriber::fmt::layer()) .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 — // stuck `compression_status='processing'` uploads and `status='running'` export // jobs. Must run before the server starts taking requests so clients never see // the half-state. services::maintenance::startup_recovery(&pool).await; let state = AppState::new(pool.clone(), config.clone()); // Regenerate image derivatives an older pipeline produced: the big-screen display for // uploads processed before it existed (v0.17.x), and anything predating the current // DERIVATIVES_REV (rev 1 applies the EXIF orientation, without which every portrait // phone photo is stored sideways). Fire-and-forget behind the compression semaphore; // originals are never touched, so a failure just retries on the next start. state.compression.backfill_stale_derivatives().await; // Re-extract poster frames for videos a restart interrupted. `startup_recovery` above // marks their compression `failed` but nothing re-enqueued them, so `thumbnail_path` // stayed NULL for the rest of the event. Shares the attempt budget with the image // backfill, so a clip that genuinely yields no frame stops being retried. state.compression.backfill_video_posters().await; // Re-spawn exports for events that were released but whose keepsake never finished // (crash mid-export). Needs the media/export paths + SSE sender, so it runs here // rather than inside `startup_recovery`. Fire-and-forget: the workers run in the // background; the HTTP server can start accepting requests meanwhile. services::export::recover_exports( pool.clone(), config.media_path.clone(), config.export_path.clone(), config.comments_enabled, state.sse_tx.clone(), ) .await; // Hourly background hygiene: prune expired sessions, evict cold rate-limiter // keys. Keeps the DB and process from growing unboundedly over multi-day events. services::maintenance::spawn_periodic_tasks( pool, state.rate_limiter.clone(), state.sse_tickets.clone(), config.media_path.clone(), ); let api = Router::new() // Auth .route("/api/v1/event", get(handlers::public::get_public_event)) .route("/api/v1/join", post(auth::handlers::join)) .route("/api/v1/recover", post(auth::handlers::recover)) // Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled). .route( "/api/v1/recover/request", post(auth::handlers::request_pin_reset), ) .route("/api/v1/admin/login", post(auth::handlers::admin_login)) .route("/api/v1/session", delete(auth::handlers::logout)) // "Sign out everywhere" — revoke all of the caller's sessions. .route("/api/v1/sessions", delete(auth::handlers::logout_all)) // Upload — HTTP-level body cap as an OOM backstop. The handler still enforces // the precise per-class limits from DB config (max_image/video_size_mb); this // layer just stops a multi-GB body from being buffered into memory before that // check runs. Sized generously above the default 500 MB video limit + multipart // overhead. The DB-configured limits can no longer exceed it: both are clamped to // MAX_CONFIGURABLE_UPLOAD_MB at write time and at read time — see that constant // for why a comment was not enough. .route( "/api/v1/upload", post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)), ) .route( "/api/v1/upload/{id}", patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), ) .route( "/api/v1/upload/{id}/original", get(handlers::upload::get_original), ) // Preview/thumbnail variants are gated the same way as originals (visibility // check + direct /media block below) so moderation actually revokes access to // the displayed images, not just the full-res download. .route( "/api/v1/upload/{id}/preview", get(handlers::upload::get_preview), ) .route( "/api/v1/upload/{id}/display", get(handlers::upload::get_display), ) .route( "/api/v1/upload/{id}/thumbnail", get(handlers::upload::get_thumbnail), ) // 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)) .route("/api/v1/hashtags", get(handlers::feed::hashtags)) .route("/api/v1/uploaders", get(handlers::feed::uploaders)) // Social .route( "/api/v1/upload/{id}/like", post(handlers::social::toggle_like), ) .route( "/api/v1/upload/{id}/comments", get(handlers::social::list_comments).post(handlers::social::add_comment), ) .route( "/api/v1/comment/{id}", delete(handlers::social::delete_comment), ) // SSE .route("/api/v1/stream", get(handlers::sse::stream)) .route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket)) // Host Dashboard .route("/api/v1/host/event", get(handlers::host::get_event_status)) .route( "/api/v1/host/event/close", post(handlers::host::close_event), ) .route("/api/v1/host/event/open", post(handlers::host::open_event)) .route( "/api/v1/host/gallery/release", post(handlers::host::release_gallery), ) // Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime // (release_gallery refuses an already-released event; recovery only runs at boot). .route( "/api/v1/host/export/rebuild", post(handlers::host::rebuild_export), ) .route("/api/v1/host/users", get(handlers::host::list_users)) .route( "/api/v1/host/users/{id}/ban", post(handlers::host::ban_user), ) .route( "/api/v1/host/users/{id}/unban", post(handlers::host::unban_user), ) .route( "/api/v1/host/users/{id}/role", patch(handlers::host::set_role), ) .route( "/api/v1/host/users/{id}/pin-reset", post(handlers::host::reset_user_pin), ) .route( "/api/v1/host/pin-reset-requests", get(handlers::host::list_pin_reset_requests), ) .route( "/api/v1/host/pin-reset-requests/{id}", delete(handlers::host::dismiss_pin_reset_request), ) .route( "/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload), ) .route( "/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment), ) // Export (all authenticated users) .route("/api/v1/export/status", get(handlers::admin::export_status)) .route( "/api/v1/export/ticket", post(handlers::admin::export_ticket), ) .route("/api/v1/export/zip", get(handlers::admin::download_zip)) .route("/api/v1/export/html", get(handlers::admin::download_html)) // Admin Dashboard .route("/api/v1/admin/stats", get(handlers::admin::get_stats)) .route( "/api/v1/admin/config", get(handlers::admin::get_config).patch(handlers::admin::patch_config), ) .route( "/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs), ); // Test-only route: a hard reset for the Playwright E2E harness. The handler // is compiled in always, but the route is only attached when // `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route // simply isn't there. let api = if handlers::test_admin::is_test_mode() { tracing::warn!( "EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \ DO NOT enable this in production." ); api.route( "/api/v1/admin/__truncate", post(handlers::test_admin::truncate_all), ) } else { api }; // NOTE: media is deliberately NOT served over HTTP. // // Files live under `media_path` so the compression worker and the export job can read // them off disk, but nothing may pull them straight from `/media/**` — that bypasses // the visibility checks (soft-delete + ban-hide) that make a host takedown stick. // Every legitimate fetch goes through `/api/v1/upload/{id}/{original,preview,display, // thumbnail}`, which filter via `find_visible_media`; those are the only media URLs the // backend ever emits (see `handlers::feed`). // // This used to be a `ServeDir` on `/media` with four `nest_service` blockers on the // subtrees above it. That was bypassable: axum routes on the RAW path while `ServeDir` // percent-decodes afterwards, so `/media/%70reviews/{id}.jpg` missed every blocker, // fell through to the `ServeDir`, and was decoded back to `previews/` on disk — serving // a taken-down photo to anyone, unauthenticated. Any single escaped byte worked, in all // four subtrees. Deleting the route removes the vector outright rather than racing the // decoder; `/media/**` now 404s regardless of encoding. let router = Router::new() // ONE probe, and it touches the database. The merge of the unattended-blockers work // brought a competing design — a dependency-free `/health` for the compose gate plus // a DB-backed `/health/ready` for an external monitor. That split is defensible, and // it was rejected deliberately: // // * `/health` returning a constant "ok" is the exact defect faea555 fixed and // verified live (stop Postgres → 503 → start → 200, with no app restart). Every // request path touches the database, so a constant probe reports healthy while // the app is useless — the disk-full endgame stayed green all the way down. // * The split's motive was that Caddy's `depends_on: app: service_healthy` would // be blocked by a Postgres hiccup at boot. But `app` itself already gates on // `db: service_healthy`, so the DB is up before this probe ever runs, and the // healthcheck carries a 20s start_period plus 5 retries on top. // * 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 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()) .with_state(state); let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; tracing::info!("listening on {}", listener.local_addr()?); // `into_make_service_with_connect_info` is required by the pre-auth handlers, which // extract `ConnectInfo` to use the peer address as the rate-limit key when // X-Forwarded-For is absent. Without it those extractors fail at runtime. axum::serve( listener, router.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(shutdown_signal()) .await?; 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. const HEALTH_DB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); /// Readiness probe — the Docker healthcheck, Caddy's `depends_on` gate, and the runbook's /// event-day `curl` all hit this. /// /// It used to return the literal string `"ok"` and touch nothing. Every request in the app needs /// the database, so that answered a question nobody asked: the container reported healthy while /// every real request 500'd, and with no operator watching during the event there was no signal /// at all. Note what this does NOT buy: Compose's `restart: unless-stopped` does not react to /// healthcheck state, so nothing restarts on a red probe — this is a diagnostic, and it is /// deliberately not wired to automatic recovery, because the pool already heals itself across a /// Postgres restart (sqlx revalidates on acquire) and an auto-restart would truncate every /// in-flight upload to "fix" an outage that was about to clear on its own. async fn health( axum::extract::State(state): axum::extract::State, ) -> impl axum::response::IntoResponse { use axum::http::StatusCode; match tokio::time::timeout( HEALTH_DB_TIMEOUT, sqlx::query("SELECT 1").execute(&state.pool), ) .await { Ok(Ok(_)) => (StatusCode::OK, "ok"), Ok(Err(e)) => { tracing::error!(error = ?e, "health check: database query failed"); (StatusCode::SERVICE_UNAVAILABLE, "database unavailable") } Err(_) => { tracing::error!( timeout_s = HEALTH_DB_TIMEOUT.as_secs(), "health check: database did not respond" ); (StatusCode::SERVICE_UNAVAILABLE, "database timeout") } } } /// Hard cap on how long we wait for in-flight connections to drain after a shutdown /// signal. Uploads (streamed to disk) finish in well under this; the cap exists because /// long-lived SSE streams never end on their own and would otherwise keep the graceful /// drain — and thus the process — pending until the orchestrator force-kills it. const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10); /// Resolves on SIGINT (Ctrl-C) or SIGTERM (container stop / deploy). Letting /// `axum::serve` drain in-flight requests on this signal means a redeploy no longer /// truncates uploads mid-flight; any background compression/export half-states that a /// hard kill would leave are already reconciled by `startup_recovery` on the next boot. /// /// Once the signal fires we also arm a detached backstop that force-exits after /// [`SHUTDOWN_GRACE`]. Without it, open SSE streams (which have no natural end) would /// hold the graceful drain open indefinitely; the backstop bounds shutdown regardless /// of the orchestrator's own kill timeout. If the drain completes first, `main` returns /// and the process exits before the timer ever fires. async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] let terminate = async { match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { Ok(mut sig) => { sig.recv().await; } Err(e) => { tracing::warn!(error = ?e, "failed to install SIGTERM handler"); // Never resolve — fall back to ctrl_c only. std::future::pending::<()>().await; } } }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {} _ = terminate => {} } tracing::info!( "shutdown signal received, draining in-flight requests (max {}s)", SHUTDOWN_GRACE.as_secs() ); // Backstop: if the graceful drain is still blocked after the grace window (almost // always because SSE streams are still open), exit anyway so deploys aren't stalled. tokio::spawn(async { tokio::time::sleep(SHUTDOWN_GRACE).await; tracing::warn!( "graceful drain exceeded {}s (likely open SSE streams); forcing exit", SHUTDOWN_GRACE.as_secs() ); std::process::exit(0); }); } #[cfg(test)] mod tests { use super::{MAX_CONFIGURABLE_UPLOAD_MB, MAX_UPLOAD_BYTES}; use crate::handlers::upload::{ MAX_CAPTION_BYTES, MAX_CLIENT_UPLOAD_ID_BYTES, MAX_HASHTAGS_BYTES, }; /// Boundary lines, `Content-Disposition` / `Content-Type` headers and CRLFs for the four /// fields the handler reads. A few hundred bytes in reality; 4 KiB is a deliberately fat /// allowance so this test asserts the invariant rather than a precise byte count. const MULTIPART_FRAMING_BYTES: usize = 4 * 1024; /// Both bounds on `MAX_CONFIGURABLE_UPLOAD_MB`, asserted at COMPILE time. /// /// `const _: () = assert!(...)` rather than a runtime `assert!`, matching the precedent in /// `handlers::upload` and `services::compression`: every operand is a constant, so a /// violation is a build failure rather than something that has to be run to be noticed. The /// cost is that a const panic takes a static message — the reasoning lives here instead. /// /// UPPER: a file at the largest configurable limit must still fit inside the body limit the /// router enforces, envelope included. If it does not, an operator can set a limit the /// handler accepts and the router then refuses MID-BODY — a 400 that the upload queue /// classifies as terminal and answers by deleting the guest's only copy of the photo. See /// `MAX_CONFIGURABLE_UPLOAD_MB`. Written against the field caps rather than a hardcoded /// number, so raising `MAX_CAPTION_LENGTH` (or adding another text field to the envelope) /// fails HERE instead of silently eating the margin. /// /// LOWER: the ceiling must not be so conservative that it forbids the shipped default. /// `max_video_size_mb` is seeded at 500 (migration 005), so a bound below that would clamp /// every video upload on a stock install and reject the stock config through `patch_config`. #[test] fn the_configurable_ceiling_is_bounded_at_both_ends() { const FILE: usize = MAX_CONFIGURABLE_UPLOAD_MB as usize * 1024 * 1024; const ENVELOPE: usize = MAX_CAPTION_BYTES + MAX_HASHTAGS_BYTES + MAX_CLIENT_UPLOAD_ID_BYTES + MULTIPART_FRAMING_BYTES; const _: () = { assert!( FILE + ENVELOPE <= MAX_UPLOAD_BYTES, "a file at MAX_CONFIGURABLE_UPLOAD_MB plus its multipart envelope exceeds \ MAX_UPLOAD_BYTES — an operator could configure a limit that DESTROYS uploads \ (400 mid-body, blob purged as terminal) instead of refusing them" ); assert!( MAX_CONFIGURABLE_UPLOAD_MB >= 500, "MAX_CONFIGURABLE_UPLOAD_MB must clear the 500 MB max_video_size_mb default \ seeded by migration 005, or a stock install clamps every video upload" ); }; } }