//! Route handlers, MNW-integrated forum.
mod account;
mod admin;
mod chat;
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::*`.
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("/p/{slug}/chat/send", post(chat::chat_send))
.route(
"/p/{slug}/chat/messages/{message_id}/delete",
post(chat::chat_delete_message),
)
.route(
"/p/{slug}/chat/moderation/timeout",
post(chat::chat_timeout_user),
)
.route("/p/{slug}/chat/moderation/ban", post(chat::chat_ban_user))
.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}/chat", get(chat::chat_page))
.route("/p/{slug}/chat/stream", get(chat::chat_stream))
.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,
crate::trust_store::anchors_ok(),
state.chat.hub().connection_count(),
)),
)
}
/// 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, trust_anchors_ok: bool, chat_connections: usize) -> 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,
// Whether the host trust store yielded outbound TLS anchors
// (`crate::trust_store`). Deliberately does not move `status` or the
// HTTP code: mt still serves every page that does not leave the box,
// and a bad CA bundle is usually a whole-fleet condition, so failing
// the load-balancer check would turn a login outage into a total one.
// PoM asserts this field, which is what gets it monitored.
"tls_trust_anchors": trust_anchors_ok,
// Live chat listeners across every room in this process. Exposed
// because it is the number that predicts the 512M cgroup cap: each
// listener holds a task, a buffer and a broadcast receiver, and an OOM
// restarts the whole site rather than degrading chat. Like
// `tls_trust_anchors` it deliberately does not move `status` or the
// HTTP code; it is a trend for an operator to watch, and the hub
// already refuses connections past its own cap.
"chat_connections": chat_connections,
})
}
// 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/`.
///
/// Ignored by default because it is not a unit test: it asserts that this
/// crate and a *different repo* agree, by reading pom's deployed config off
/// disk. `cargo test` is scoped to one crate, so the claim is only
/// well-posed where both repos exist together. Anything that relocates this
/// crate — cargo-mutants copies it to /tmp — leaves the read pointing at
/// nothing, and the panic took multithreaded's entire mutation run with it.
///
/// It still runs, every night: sweep materializes the whole ~/Code tree and
/// has a `pom-contract` check that invokes it by name with `--ignored`.
/// Ignored here means "run somewhere that can answer it", not "skipped".
#[test]
#[ignore = "cross-repo: run by sweep's pom-contract check, which materializes pom"]
fn pom_hetzner_health_expectations_resolve() {
let body = health_body(true, true, 0);
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, true, 0);
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
assert!(
body.get("git_sha").is_some(),
"git_sha key must be present (null is fine)"
);
}
/// The trust-anchor field is what PoM watches for a stale or thin host CA
/// bundle, so lock both that it is reported and that it does not move
/// `status`. A box with no anchors still serves every page that does not
/// leave it; degrading the whole target would hide that distinction.
#[test]
fn health_body_reports_trust_anchors_without_moving_status() {
assert_eq!(health_body(true, true, 0)["tls_trust_anchors"], true);
let body = health_body(true, false, 0);
assert_eq!(body["tls_trust_anchors"], false);
assert_eq!(body["status"], "operational");
assert_eq!(health_status(true), StatusCode::OK);
}
/// 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);
}
}