//! Route handlers — MNW-integrated forum. mod admin; mod flagging; mod forum; mod helpers; pub mod internal; mod moderation; mod search; mod settings; mod tracking; mod uploads; // Re-export helpers so submodules can `use super::*` as before. pub(crate) use helpers::*; use axum::{ http::StatusCode, response::IntoResponse, Json, Router, routing::{get, post}, }; use serde::Deserialize; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor}; use tower_sessions::Session; use crate::auth::{self, MaybeUser}; use crate::csrf; use crate::templates::*; use crate::AppState; // ============================================================================ // 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; /// Build the forum route tree. pub fn forum_routes(state: AppState) -> Router { let write_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(SmartIpKeyExtractor) .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}/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}/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/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)) .route("/p/{slug}/uploads/{id}/remove", post(uploads::remove_image_handler)) .route_layer(GovernorLayer { config: write_rate_limit, }); // GET routes + auth + 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("/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}/{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("/search", get(search::search_handler)) .route("/_admin", get(admin::admin_dashboard)) .route("/auth/login", get(auth::login)) .route("/auth/callback", get(auth::callback)) .route("/auth/logout", post(auth::logout)) .route("/api/user/{user_id}/summary", get(forum::user_summary_api)) .route("/uploads/{id}", get(uploads::serve_image_handler)) .route("/api/health", get(health)); read_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, } #[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, } #[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. #[tracing::instrument(skip_all)] async fn health() -> Json { Json(serde_json::json!({ "status": "operational", "version": env!("CARGO_PKG_VERSION"), })) } // ============================================================================ // 404 fallback // ============================================================================ #[tracing::instrument(skip_all)] async fn not_found_handler( axum::extract::State(state): axum::extract::State, session: Session, MaybeUser(session_user): MaybeUser, ) -> impl IntoResponse { 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)); ( StatusCode::NOT_FOUND, Error404Template { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), }, ) }