//! Shared route helpers. //! //! Split by concern: [`markdown`] (rendering + Fan+ image proxying), //! [`validation`] (input parsing → HTTP responses), and [`authz`] (roles, //! ban/mute/suspension gates, community-state machine, superadmin). This module //! keeps the small DB/transaction/audit context helpers and re-exports the //! submodules so callers still reach everything via `crate::routes::*`. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use uuid::Uuid; use mt_core::types::{ModAction, ModActor}; use crate::auth; use crate::templates::TemplateSessionUser; mod authz; mod markdown; mod validation; pub(crate) use authz::*; pub(crate) use markdown::*; pub(crate) use validation::*; // 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; // Common DB / transaction / context helpers /// Log a DB error and map it to a branded 500 page. /// /// The one-liner `.map_err(db_error)?` most page/write handlers want, replacing /// the open-coded `|e| { tracing::error!(error = ?e, "…"); internal_error() }` /// closure. For the internal HMAC API, keep that module's local `db_error` /// (which returns a plain 500, not an HTML page). #[allow( clippy::needless_pass_by_value, reason = "used as a `.map_err(db_error)` combinator, which requires the fn(E) -> R by-value signature" )] pub(crate) fn db_error(e: sqlx::Error) -> Response { tracing::error!(error = ?e, "db error"); crate::error_page::internal_error() } /// 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"); crate::error_page::internal_error() })? .ok_or_else(crate::error_page::not_found) } /// 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"); crate::error_page::internal_error() })? // 422 stays a plain form-validation response (consumed inline by mt.js), // not a full branded page. .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response()) } /// Begin a transaction, mapping a DB error to a branded 500. #[allow(clippy::result_large_err)] pub(crate) async fn begin_tx( db: &sqlx::PgPool, ) -> Result, Response> { db.begin().await.map_err(|e| { tracing::error!(error = ?e, "db error opening transaction"); crate::error_page::internal_error() }) } /// Commit a transaction, mapping a DB error to a branded 500. #[allow(clippy::result_large_err)] pub(crate) async fn commit_tx(tx: sqlx::Transaction<'_, sqlx::Postgres>) -> Result<(), Response> { tx.commit().await.map_err(|e| { tracing::error!(error = ?e, "db error committing transaction"); crate::error_page::internal_error() }) } /// Write a mod-log entry on the caller's transaction. /// /// The audit row is written on the *same* `tx` as the mutation it records, so /// the moderation action and its log either both commit or both roll back, an /// auditable action can never land without its correctly-attributed audit row /// (closes the fire-and-forget gap). A `System` actor (e.g. flag-threshold /// auto-hide) is recorded as a NULL `actor_id`, never the user who tripped it. #[allow(clippy::result_large_err)] pub(crate) async fn audit( tx: &mut sqlx::PgConnection, community_id: Option, actor: ModActor, action: ModAction, target_user: Option, target_id: Option, reason: Option<&str>, ) -> Result<(), Response> { mt_db::mutations::insert_mod_log( &mut *tx, community_id, actor, action, target_user, target_id, reason, ) .await .map_err(|e| { tracing::error!(error = %e, "failed to insert mod log"); crate::error_page::internal_error() }) } /// 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(), } } /// 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, "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(()) }