//! Shared route helpers — validation, permission checks, markdown rendering, enforcement. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; use mt_core::types::{CommunityRole, ModAction}; use crate::auth; use crate::templates::*; use crate::AppState; // ============================================================================ // Rate limiting constants // ============================================================================ /// Per-user rate limit: max posts per window (complements per-IP tower-governor). pub(crate) const USER_POST_RATE_LIMIT: i64 = 15; pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60; // ============================================================================ // Markdown rendering // ============================================================================ /// Render markdown to HTML, stripping raw HTML events to prevent XSS. pub(crate) fn render_markdown(input: &str) -> String { docengine::render_strict(input) } /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. pub(crate) fn render_markdown_with_mentions( input: &str, community_slug: &str, valid_usernames: &std::collections::HashSet, ) -> String { let template = format!("/p/{community_slug}/u/{{username}}"); let resolved = docengine::resolve_mentions(input, valid_usernames, &template); docengine::render_strict(&resolved) } // ============================================================================ // Common helpers — reduce boilerplate in handlers // ============================================================================ /// Fetch community by slug, returning 404/500 on failure. #[tracing::instrument(skip_all)] pub(crate) async fn get_community( db: &sqlx::PgPool, slug: &str, ) -> Result { mt_db::queries::get_community_by_slug(db, slug) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching community"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response()) } /// Fetch thread with breadcrumb, returning 404/500 on failure. #[tracing::instrument(skip_all)] pub(crate) async fn get_thread( db: &sqlx::PgPool, thread_id_str: &str, ) -> Result { let thread_id = parse_uuid(thread_id_str)?; mt_db::queries::get_thread_with_breadcrumb(db, thread_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching thread"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response()) } /// Parse a UUID from a string, returning 404 on failure. #[allow(clippy::result_large_err)] pub(crate) fn parse_uuid(id_str: &str) -> Result { Uuid::parse_str(id_str).map_err(|_| StatusCode::NOT_FOUND.into_response()) } /// Fetch a user's role in a community, returning 500 on DB error. #[tracing::instrument(skip_all)] pub(crate) async fn get_role( db: &sqlx::PgPool, user_id: Uuid, community_id: Uuid, ) -> Result, Response> { let role_str = mt_db::queries::get_user_role(db, user_id, community_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching role"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(role_str.and_then(|s| CommunityRole::from_db(&s))) } /// Look up a user by username, returning 422 if not found. #[tracing::instrument(skip_all)] pub(crate) async fn get_user_by_username( db: &sqlx::PgPool, username: &str, ) -> Result { mt_db::queries::get_user_by_username(db, username) .await .map_err(|e| { tracing::error!(error = ?e, "db error looking up user"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response()) } /// Fire-and-forget mod log entry. Logs errors but never fails the request. pub(crate) async fn log_mod_action( db: &sqlx::PgPool, community_id: Option, actor_id: Uuid, action: ModAction, target_user: Option, target_id: Option, reason: Option<&str>, ) { if let Err(e) = mt_db::mutations::insert_mod_log( db, community_id, actor_id, action, target_user, target_id, reason, ) .await { tracing::error!(error = %e, "failed to insert mod log"); } } /// Convert a session user to a template session user. pub(crate) fn template_user( user: &auth::SessionUser, platform_admin_id: Option, ) -> TemplateSessionUser { TemplateSessionUser { is_platform_admin: platform_admin_id == Some(user.user_id), username: user.username.clone(), } } /// Validate a title field (1-256 chars). #[allow(clippy::result_large_err)] pub(crate) fn validate_title(text: &str) -> Result<&str, Response> { let t = text.trim(); if t.is_empty() || t.len() > 256 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Title must be between 1 and 256 characters.", ) .into_response()); } Ok(t) } /// Validate a body/content field (1 to max chars). #[allow(clippy::result_large_err)] pub(crate) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> { let t = text.trim(); if t.is_empty() || t.len() > max { return Err(( StatusCode::UNPROCESSABLE_ENTITY, format!("{field} must be between 1 and {max} characters."), ) .into_response()); } Ok(t) } // ============================================================================ // Permission helpers // ============================================================================ /// Is this user a moderator or owner in the community? pub(crate) fn is_mod_or_owner(role: &Option) -> bool { role.is_some_and(|r| r.is_mod_or_owner()) } /// Is this user an owner of the community? pub(crate) fn is_owner(role: &Option) -> bool { role.is_some_and(|r| r.is_owner()) } // ============================================================================ // Enforcement helpers // ============================================================================ /// Check community suspension + user ban. For read handlers. #[tracing::instrument(skip_all)] pub(crate) async fn check_community_access( db: &sqlx::PgPool, community: &mt_db::queries::CommunityRow, user_id: Option, ) -> Result<(), Response> { if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } if let Some(uid) = user_id { let banned = mt_db::queries::is_user_banned(db, community.id, uid) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking ban status"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if banned { return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); } } Ok(()) } /// Check community suspension + platform suspension + user ban + user mute. For write handlers. #[tracing::instrument(skip_all)] pub(crate) async fn check_write_access( db: &sqlx::PgPool, community_id: Uuid, user_id: Uuid, community_suspended: bool, ) -> Result<(), Response> { if community_suspended { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } let suspended = mt_db::queries::is_user_suspended(db, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking user suspension"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if suspended { return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()); } let banned = mt_db::queries::is_user_banned(db, community_id, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking ban status"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if banned { return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); } let muted = mt_db::queries::is_user_muted(db, community_id, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking mute status"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if muted { return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response()); } Ok(()) } /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit. #[tracing::instrument(skip_all)] pub(crate) async fn check_user_post_rate( db: &sqlx::PgPool, user_id: Uuid, ) -> Result<(), Response> { let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking user post rate"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if count >= USER_POST_RATE_LIMIT { return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response()); } Ok(()) } /// Parse a ban duration string into an optional expiration datetime. pub(crate) fn parse_duration(duration: &str) -> Option> { match duration { "permanent" => None, "1h" => Some(Utc::now() + Duration::hours(1)), "1d" => Some(Utc::now() + Duration::days(1)), "7d" => Some(Utc::now() + Duration::days(7)), "30d" => Some(Utc::now() + Duration::days(30)), _ => None, } } /// Helper: fetch community + verify owner role, returning 403 if not owner. #[tracing::instrument(skip_all)] pub(crate) async fn require_owner( state: &AppState, slug: &str, user: &auth::SessionUser, ) -> Result { let community = get_community(&state.db, slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } Ok(community) } /// Helper: fetch community + verify mod_or_owner role, returning 403 if not. #[tracing::instrument(skip_all)] pub(crate) async fn require_mod_or_owner( state: &AppState, slug: &str, user: &auth::SessionUser, ) -> Result<(mt_db::queries::CommunityRow, Option), Response> { let community = get_community(&state.db, slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } Ok((community, role)) }