use anyhow::Result; use axum::extract::DefaultBodyLimit; use axum::routing::{delete, get, patch, post}; use axum::Router; use tower_http::services::ServeDir; 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. const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024; #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { "eventsnap_backend=debug,tower_http=debug".into() })) .with(tracing_subscriber::fmt::layer()) .init(); let config = AppConfig::from_env()?; 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()); // 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(), 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(), ); // 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)) .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 — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES. .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}/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)) // 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)) // 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 }; // Serve media files from disk let media_service = ServeDir::new(&config.media_path); let router = Router::new() .route("/health", get(|| async { "ok" })) .merge(api) // Block direct HTTP access to ALL media subtrees. The files live under // `media_path` (so the compression worker and export can read them off disk) but // must NOT be pullable straight from `/media/**` — that bypasses the visibility // checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch // goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter // via `find_visible_media`. The more specific nests take precedence over the // `/media` ServeDir below (which, with all three subtrees blocked, now serves // nothing — kept as a backstop). .nest_service( "/media/originals", get(|| async { axum::http::StatusCode::NOT_FOUND }), ) .nest_service( "/media/previews", get(|| async { axum::http::StatusCode::NOT_FOUND }), ) .nest_service( "/media/thumbnails", get(|| async { axum::http::StatusCode::NOT_FOUND }), ) .nest_service("/media", media_service) .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()?); axum::serve(listener, router) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } /// 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); }); }