//! Route handlers, MNW-integrated forum. mod account; mod admin; mod flagging; mod forum; pub(crate) mod helpers; pub mod internal; mod moderation; mod scope; mod search; mod settings; mod tracking; mod uploads; // Re-export helpers so submodules can `use super::*` as before. pub(crate) use helpers::*; pub(crate) use scope::CommunityScope; use axum::{ Json, Router, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, }; use serde::Deserialize; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; use tower_sessions::Session; use crate::trusted_proxy::TrustedProxyKeyExtractor; use crate::AppState; use crate::auth::{self, MaybeUser}; use crate::csrf; use crate::templates::Error404Template; // Rate limiting, per-IP on write endpoints /// Write endpoints: burst 10, then 2/sec (one token per 500ms). const WRITE_RATE_LIMIT_MS: u64 = 500; const WRITE_RATE_LIMIT_BURST: u32 = 10; /// Search endpoint: burst 5, then 1/sec, full-text + trigram queries are expensive. const SEARCH_RATE_LIMIT_MS: u64 = 1000; const SEARCH_RATE_LIMIT_BURST: u32 = 5; /// Upload request body cap: the image size limit plus headroom for multipart /// framing. Bounds the in-memory buffer before the handler reads the field. const MAX_UPLOAD_BODY_BYTES: usize = crate::storage::MAX_IMAGE_SIZE + 64 * 1024; /// Auth endpoints: burst 10, then 1/sec. Throttles login/callback floods and /// the `/auth/refresh` → MNW userinfo amplifier. const AUTH_RATE_LIMIT_MS: u64 = 1000; const AUTH_RATE_LIMIT_BURST: u32 = 10; /// Image serve (`/uploads/{id}`): generous, since a single page legitimately /// fans out one request per embedded ``. Burst 60 covers an image-heavy /// page load, then refills ~20/sec, enough to bound an unauthenticated /// S3-egress proxy against a scraping/amplification flood (ultra-fuzz Mi1) /// without throttling normal browsing. const IMAGE_RATE_LIMIT_MS: u64 = 50; const IMAGE_RATE_LIMIT_BURST: u32 = 60; /// Build the forum route tree. pub fn forum_routes(state: AppState) -> Router { let write_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(TrustedProxyKeyExtractor::new( state.config.trusted_proxies.clone(), )) .per_millisecond(WRITE_RATE_LIMIT_MS) .burst_size(WRITE_RATE_LIMIT_BURST) .finish() .expect("rate limiter config"), ); // POST-only routes, rate limited per IP let write_routes = Router::new() .route( "/p/{slug}/settings", post(settings::update_community_handler), ) .route( "/p/{slug}/settings/categories/new", post(settings::create_category_handler), ) .route( "/p/{slug}/settings/categories/{cat_id}/edit", post(settings::edit_category_handler), ) .route( "/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler), ) .route( "/p/{slug}/settings/tags/new", post(settings::create_tag_handler), ) .route( "/p/{slug}/settings/tags/delete", post(settings::delete_tag_handler), ) .route( "/p/{slug}/settings/state", post(settings::set_community_state_handler), ) .route( "/account/signature", post(account::update_signature_handler), ) .route( "/p/{slug}/moderation/ban", post(moderation::ban_user_handler), ) .route( "/p/{slug}/moderation/unban", post(moderation::unban_user_handler), ) .route( "/p/{slug}/moderation/mute", post(moderation::mute_user_handler), ) .route( "/p/{slug}/moderation/unmute", post(moderation::unmute_user_handler), ) .route( "/p/{slug}/{category}/new", post(forum::create_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/reply", post(forum::create_reply_handler), ) .route( "/p/{slug}/{category}/{thread_id}/edit", post(forum::edit_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/delete", post(forum::delete_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/pin", post(moderation::pin_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/lock", post(moderation::lock_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/posts/{post_id}/footnote", post(forum::add_footnote_handler), ) .route( "/p/{slug}/{category}/{thread_id}/posts/{post_id}/endorse", post(forum::toggle_endorsement_handler), ) .route( "/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove", post(moderation::mod_remove_post_handler), ) .route( "/p/{slug}/{category}/{thread_id}/posts/{post_id}/restore", post(moderation::mod_restore_post_handler), ) .route( "/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag", post(flagging::flag_post_handler), ) .route( "/p/{slug}/moderation/flags/{flag_id}/dismiss", post(flagging::dismiss_flag_handler), ) .route( "/p/{slug}/moderation/flags/{flag_id}/remove", post(flagging::remove_flagged_post_handler), ) .route( "/p/{slug}/{category}/{thread_id}/track", post(tracking::track_thread_handler), ) .route( "/p/{slug}/{category}/{thread_id}/untrack", post(tracking::untrack_thread_handler), ) .route("/tracked/stop-all", post(tracking::untrack_all_handler)) .route( "/_admin/communities/{id}/suspend", post(admin::suspend_community_handler), ) .route( "/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler), ) .route( "/_admin/communities/{slug}/clean-slate", post(admin::admin_community_clean_slate_handler), ) .route( "/_admin/users/{id}/suspend", post(admin::suspend_user_handler), ) .route( "/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler), ) .route( "/p/{slug}/upload", post(uploads::upload_image_handler) .layer(axum::extract::DefaultBodyLimit::max(MAX_UPLOAD_BODY_BYTES)), ) .route( "/p/{slug}/uploads/{id}/remove", post(uploads::remove_image_handler), ) .route_layer(GovernorLayer::new(write_rate_limit.clone())); // Search, rate limited per IP (expensive full-text queries) let search_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(TrustedProxyKeyExtractor::new( state.config.trusted_proxies.clone(), )) .per_millisecond(SEARCH_RATE_LIMIT_MS) .burst_size(SEARCH_RATE_LIMIT_BURST) .finish() .expect("search rate limiter config"), ); let search_routes = Router::new() .route("/search", get(search::search_handler)) .route_layer(GovernorLayer::new(search_rate_limit.clone())); // Auth endpoints, rate limited per IP (login/callback flood + refresh // amplifier against MNW). let auth_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(TrustedProxyKeyExtractor::new( state.config.trusted_proxies.clone(), )) .per_millisecond(AUTH_RATE_LIMIT_MS) .burst_size(AUTH_RATE_LIMIT_BURST) .finish() .expect("auth rate limiter config"), ); let auth_routes = Router::new() .route("/auth/login", get(auth::login)) .route("/auth/reverify", get(auth::reverify)) .route("/auth/callback", get(auth::callback)) .route("/auth/logout", post(auth::logout)) .route("/auth/refresh", post(auth::refresh)) .route_layer(GovernorLayer::new(auth_rate_limit.clone())); // Image serve, per-IP rate limited. `/uploads/{id}` is an unauthenticated // S3-egress proxy (it streams bytes to any viewer who passes the community // access check), so a generous governor bounds scraping/amplification floods // (ultra-fuzz Mi1) while leaving image-heavy page loads unthrottled. let image_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(TrustedProxyKeyExtractor::new( state.config.trusted_proxies.clone(), )) .per_millisecond(IMAGE_RATE_LIMIT_MS) .burst_size(IMAGE_RATE_LIMIT_BURST) .finish() .expect("image rate limiter config"), ); let image_routes = Router::new() .route("/uploads/{id}", get(uploads::serve_image_handler)) .route("/img-proxy", get(uploads::image_proxy_handler)) .route_layer(GovernorLayer::new(image_rate_limit.clone())); // Periodically evict idle per-IP buckets from every rate limiter so the // keyspace can't grow unbounded over the process lifetime (M-Pf3). // Trusted-proxy keying already bounds keys to real client IPs, but a // long-running server still accumulates one-off visitors; `retain_recent` // drops buckets with no recent activity. Spawned here because `forum_routes` // runs inside the tokio runtime at startup. { let limiters = [ write_rate_limit.limiter().clone(), search_rate_limit.limiter().clone(), auth_rate_limit.limiter().clone(), image_rate_limit.limiter().clone(), ]; tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_mins(5)); interval.tick().await; // consume the immediate first tick loop { interval.tick().await; for limiter in &limiters { limiter.retain_recent(); } } }); } // GET routes + health, no rate limiting let read_routes = Router::new() .route("/", get(forum::forum_directory)) .route("/p/{slug}", get(forum::project_forum)) .route("/p/{slug}/members", get(forum::community_members)) .route("/p/{slug}/u/{username}", get(forum::user_profile)) .route("/account", get(account::account_settings)) .route("/p/{slug}/settings", get(settings::community_settings)) .route( "/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form), ) .route("/p/{slug}/moderation", get(moderation::moderation_page)) .route("/p/{slug}/moderation/log", get(moderation::mod_log_page)) .route( "/p/{slug}/moderation/deleted", get(moderation::deleted_threads_page), ) .route( "/p/{slug}/moderation/threads/{thread_id}/restore", post(moderation::restore_thread_handler), ) .route("/p/{slug}/{category}", get(forum::category)) .route("/p/{slug}/{category}/new", get(forum::new_thread)) .route("/p/{slug}/{category}/{thread_id}", get(forum::thread)) .route( "/p/{slug}/{category}/{thread_id}/edit", get(forum::edit_thread_form), ) .route("/tracked", get(tracking::tracked_threads_page)) .route("/about/tracking", get(tracking::tracking_info_page)) .route("/_admin", get(admin::admin_dashboard)) .route( "/_admin/communities/{slug}", get(admin::admin_community_detail), ) .route("/api/user/{user_id}/summary", get(forum::user_summary_api)) .route("/api/health", get(health)); read_routes .merge(search_routes) .merge(auth_routes) .merge(image_routes) .merge(write_routes) .fallback(not_found_handler) .with_state(state) } // Form types #[derive(Deserialize)] pub(super) struct CreateThreadForm { pub(super) title: String, pub(super) body: String, #[serde(default, deserialize_with = "deserialize_string_or_seq")] pub(super) tags: Vec, } /// Deserialize a form field that may be a single string or a repeated-key sequence. /// serde_urlencoded sends a single `tags=x` as a string, but `tags=x&tags=y` as a sequence. fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { struct StringOrSeq; impl<'de> serde::de::Visitor<'de> for StringOrSeq { type Value = Vec; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a string or sequence of strings") } fn visit_str(self, v: &str) -> Result, E> { Ok(vec![v.to_string()]) } fn visit_seq>( self, mut seq: A, ) -> Result, A::Error> { let mut v = Vec::new(); while let Some(s) = seq.next_element::()? { v.push(s); } Ok(v) } } deserializer.deserialize_any(StringOrSeq) } #[derive(Deserialize)] pub(super) struct CreateReplyForm { pub(super) body: String, } #[derive(Deserialize)] pub(super) struct FootnoteForm { pub(super) body: String, } #[derive(Deserialize)] pub(super) struct EditThreadForm { pub(super) title: String, } #[derive(Deserialize)] pub(super) struct UpdateCommunityForm { pub(super) name: String, pub(super) description: String, pub(super) auto_hide_threshold: Option, } /// `POST /_admin/communities/{slug}/clean-slate` confirmation form. /// `confirm` must exactly match the community slug (typed-phrase pattern). #[derive(Deserialize)] pub(super) struct CleanSlateForm { pub(super) confirm: String, } #[derive(Deserialize)] pub(super) struct SignatureForm { pub(super) signature: String, /// `Some("1")` when the Clear button is pressed. pub(super) clear: Option, } #[derive(Deserialize)] pub(super) struct SetCommunityStateForm { /// Target state: `"active" | "restricted" | "frozen" | "archived"`. pub(super) state: String, } #[derive(Deserialize)] pub(super) struct CreateCategoryForm { pub(super) name: String, pub(super) slug: String, pub(super) description: String, } #[derive(Deserialize)] pub(super) struct EditCategoryFormData { pub(super) name: String, pub(super) description: String, } #[derive(Deserialize)] pub(super) struct MoveCategoryForm { pub(super) direction: String, } #[derive(Deserialize)] pub(super) struct PageQuery { pub(super) page: Option, } /// Query for `/` forum directory. `filter=archived` shows only archived /// communities; otherwise default listing (archived hidden). #[derive(Deserialize)] pub(super) struct ForumDirectoryQuery { pub(super) page: Option, pub(super) filter: Option, } #[derive(Deserialize)] pub(super) struct CategoryQuery { pub(super) page: Option, pub(super) sort: Option, pub(super) order: Option, pub(super) tag: Option, } #[derive(Deserialize)] pub(super) struct BanForm { pub(super) username: String, pub(super) duration: String, pub(super) reason: Option, } #[derive(Deserialize)] pub(super) struct UnbanForm { pub(super) username: String, } #[derive(Deserialize)] pub(super) struct AdminSearchQuery { pub(super) q: Option, } #[derive(Deserialize)] pub(super) struct SuspendForm { pub(super) reason: Option, } #[derive(Deserialize)] pub(super) struct CreateTagForm { pub(super) name: String, pub(super) slug: String, } #[derive(Deserialize)] pub(super) struct DeleteTagForm { pub(super) tag_id: String, } // Handlers /// Health check, proves the service is responding and the database is reachable. /// /// Returns `200 OK` when the DB is reachable and `503 Service Unavailable` when /// it is not, so a status-only uptime probe or load-balancer healthcheck can't /// read a box that can't serve a single DB-backed page as healthy. PoM parses /// the JSON body key-by-key and additionally expects `200` for the operational /// case (`pom/deploy/pom-hetzner.toml`), which the OK branch satisfies. #[tracing::instrument(skip_all)] async fn health(axum::extract::State(state): axum::extract::State) -> impl IntoResponse { let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1") .fetch_one(&state.db) .await .is_ok(); (health_status(db_ok), Json(health_body(db_ok))) } /// Map DB reachability to the HTTP status. Pure so the status contract can be /// tested without a live DB (mirrors `health_body`). fn health_status(db_ok: bool) -> StatusCode { if db_ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE } } /// Build the JSON body for the `/api/health` response. /// /// Kept as a pure function (no AppState, no DB) so the schema-drift guard /// test in this module can exercise it directly. PoM polls this endpoint /// and runs key-by-key assertions from `pom/deploy/pom-hetzner.toml`; the /// guard test validates that every asserted path still resolves here. fn health_body(db_ok: bool) -> serde_json::Value { let status = if db_ok { "operational" } else { "degraded" }; serde_json::json!({ "status": status, "version": env!("CARGO_PKG_VERSION"), // The commit this binary was built from (short sha, set by build.rs). // `null` on a build without git metadata. Lets monitoring see a // same-semver redeploy, which `version` alone cannot distinguish. "git_sha": option_env!("GIT_HASH").filter(|h| !h.is_empty()), "database": db_ok, }) } // 404 fallback #[tracing::instrument(skip_all)] async fn not_found_handler( axum::extract::State(state): axum::extract::State, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); let session_user = session_user .as_ref() .map(|u| template_user(u, state.config.platform_admin_id)); Ok(( StatusCode::NOT_FOUND, Error404Template { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), }, )) } #[cfg(test)] mod health_tests { use super::{health_body, health_status}; use axum::http::StatusCode; /// Schema-drift guard for the `mt` target. See `shared/pom-contract/`. #[test] fn pom_hetzner_health_expectations_resolve() { let body = health_body(true); pom_contract::assert_health_expectations_resolve( "../pom/deploy/pom-hetzner.toml", "mt", &body, ); } /// The `git_sha` key is what tells monitoring a same-semver redeploy /// happened, so lock its presence. Its value varies per build (and is /// `null` without git metadata), which is why it is asserted here rather /// than in PoM's exact-match `json_fields`. #[test] fn health_body_carries_version_and_git_sha_keys() { let body = health_body(true); assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); assert!( body.get("git_sha").is_some(), "git_sha key must be present (null is fine)" ); } /// A reachable DB is `200`; an unreachable DB is `503` so status-only probes /// don't read a degraded box as healthy. PoM expects `200` for operational. #[test] fn health_status_reflects_db_reachability() { assert_eq!(health_status(true), StatusCode::OK); assert_eq!(health_status(false), StatusCode::SERVICE_UNAVAILABLE); } }