//! Moderation handlers — pin, lock, ban, mute, mod log. use axum::{ extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Redirect, Response}, Form, }; use tower_sessions::Session; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::*; use crate::AppState; use mt_core::types::{BanType, ModAction}; use super::{ get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner, log_mod_action, parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm, PageQuery, UnbanForm, }; #[tracing::instrument(skip_all)] pub(super) async fn pin_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; let role = get_role(&state.db, user.user_id, thread_data.community_id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } let new_pinned = !thread_data.pinned; mt_db::mutations::set_thread_pinned(&state.db, thread_data.id, new_pinned) .await .map_err(|e| { tracing::error!(error = ?e, "db error toggling pin"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread }; log_mod_action( &state.db, Some(thread_data.community_id), user.user_id, action, None, Some(thread_data.id), None, ).await; let toast = if new_pinned { "Thread+pinned" } else { "Thread+unpinned" }; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast={toast}" ))) } #[tracing::instrument(skip_all)] pub(super) async fn lock_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; let role = get_role(&state.db, user.user_id, thread_data.community_id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } let new_locked = !thread_data.locked; mt_db::mutations::set_thread_locked(&state.db, thread_data.id, new_locked) .await .map_err(|e| { tracing::error!(error = ?e, "db error toggling lock"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread }; log_mod_action( &state.db, Some(thread_data.community_id), user.user_id, action, None, Some(thread_data.id), None, ).await; let toast = if new_locked { "Thread+locked" } else { "Thread+unlocked" }; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast={toast}" ))) } // ============================================================================ // Post removal (mod/owner only) // ============================================================================ #[tracing::instrument(skip_all)] pub(super) async fn mod_remove_post_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let post_id = parse_uuid(&post_id_str)?; let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching post for removal"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let role = get_role(&state.db, user.user_id, post_data.community_id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error removing post"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(post_data.community_id), user.user_id, ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+removed" ))) } // ============================================================================ // Community moderation routes // ============================================================================ #[tracing::instrument(skip_all)] pub(super) async fn moderation_page( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } // Opportunistic cleanup of expired bans/mutes if let Err(e) = mt_db::mutations::cleanup_expired_bans(&state.db, community.id).await { tracing::error!(error = %e, "failed to clean up expired bans"); } let db_bans = mt_db::queries::list_community_bans(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing bans"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let bans = db_bans .into_iter() .map(|b| BanListRow { username: b.username, display_name: b.display_name, ban_type: b.ban_type, reason: b.reason, expires: b.expires_at.map(mt_core::time_format::relative_timestamp), created: mt_core::time_format::relative_timestamp(b.created_at), banned_by: b.banned_by_username, }) .collect(); let db_flags = mt_db::queries::list_pending_flags(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing flags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let pending_flags = db_flags .into_iter() .map(|f| FlagViewRow { flag_id: f.flag_id.to_string(), post_id: f.post_id.to_string(), thread_id: f.thread_id.to_string(), thread_title: f.thread_title, category_slug: f.category_slug, flagger_username: f.flagger_username, reason: f.reason, detail: f.detail, created: mt_core::time_format::relative_timestamp(f.created_at), }) .collect(); Ok(ModerationTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, bans, pending_flags, is_owner: is_owner(&role), }) } #[tracing::instrument(skip_all)] pub(super) async fn ban_user_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; // Prevent banning owners let target_role = get_role(&state.db, target_id, community.id).await?; if is_owner(&target_role) { return Err((StatusCode::FORBIDDEN, "Cannot ban an owner.").into_response()); } // Mods can't ban other mods — only owners can if is_mod_or_owner(&target_role) && !is_owner(&role) { return Err((StatusCode::FORBIDDEN, "Only owners can ban moderators.").into_response()); } let expires_at = parse_duration(&form.duration); let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty()); if let Some(r) = reason && r.len() > 1024 { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response()); } mt_db::mutations::create_community_ban( &state.db, community.id, target_id, user.user_id, BanType::Ban, reason, expires_at, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating ban"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::Ban, Some(target_id), None, reason, ).await; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=User+banned" ))) } #[tracing::instrument(skip_all)] pub(super) async fn unban_user_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Ban) .await .map_err(|e| { tracing::error!(error = ?e, "db error removing ban"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::Unban, Some(target_id), None, None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=User+unbanned" ))) } #[tracing::instrument(skip_all)] pub(super) async fn mute_user_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; // Prevent muting owners let target_role = get_role(&state.db, target_id, community.id).await?; if is_owner(&target_role) { return Err((StatusCode::FORBIDDEN, "Cannot mute an owner.").into_response()); } if is_mod_or_owner(&target_role) && !is_owner(&role) { return Err((StatusCode::FORBIDDEN, "Only owners can mute moderators.").into_response()); } let expires_at = parse_duration(&form.duration); let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty()); if let Some(r) = reason && r.len() > 1024 { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response()); } mt_db::mutations::create_community_ban( &state.db, community.id, target_id, user.user_id, BanType::Mute, reason, expires_at, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating mute"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::Mute, Some(target_id), None, reason, ).await; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=User+muted" ))) } #[tracing::instrument(skip_all)] pub(super) async fn unmute_user_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Mute) .await .map_err(|e| { tracing::error!(error = ?e, "db error removing mute"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::Unmute, Some(target_id), None, None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=User+unmuted" ))) } #[tracing::instrument(skip_all)] pub(super) async fn mod_log_page( axum::extract::State(state): axum::extract::State, Path(slug): Path, Query(page_query): Query, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let per_page: i64 = 50; let total = mt_db::queries::count_mod_log(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error counting mod log"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32; let total_pages = total_pages.max(1); let page = page_query.page.unwrap_or(1).max(1).min(total_pages); let offset = (page as i64 - 1) * per_page; let db_entries = mt_db::queries::list_mod_log(&state.db, community.id, per_page, offset) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing mod log"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let entries = db_entries .into_iter() .map(|e| ModLogRow { actor: e.actor_username, action: e.action, target: e.target_username, reason: e.reason, timestamp: mt_core::time_format::relative_timestamp(e.created_at), }) .collect(); Ok(ModLogTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, entries, pagination: Pagination::new(page, total, per_page), }) }