//! Moderation handlers, pin, lock, ban, mute, mod log. use axum::{ Form, extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::AppState; use crate::auth::RequireUser; use crate::csrf; use crate::templates::{ BanListRow, DeletedThreadViewRow, DeletedThreadsTemplate, FlagViewRow, ModLogRow, ModLogTemplate, ModerationTemplate, Pagination, }; use mt_core::types::{BanType, ModAction}; use super::{ BanForm, CommunityScope, PageQuery, UnbanForm, audit, begin_tx, commit_tx, db_error, field_error, get_role, get_user_by_username, is_mod_or_owner, is_owner, parse_duration, parse_uuid, require_mod_or_owner, template_user, }; use mt_core::types::ModActor; use mt_db::queries::{PostForEdit, ThreadWithBreadcrumb}; #[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)>, RequireUser(user): RequireUser, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_mod_write(&state.db, user.user_id).await?; let thread_data = scope.resource; let new_pinned = !thread_data.pinned; let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread }; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::set_thread_pinned(&mut *tx, thread_data.id, new_pinned) .await .map_err(db_error)?; audit( &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), action, None, Some(thread_data.id), None, ) .await?; commit_tx(tx).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)>, RequireUser(user): RequireUser, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_mod_write(&state.db, user.user_id).await?; let thread_data = scope.resource; let new_locked = !thread_data.locked; let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread }; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::set_thread_locked(&mut *tx, thread_data.id, new_locked) .await .map_err(db_error)?; audit( &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), action, None, Some(thread_data.id), None, ) .await?; commit_tx(tx).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)>, RequireUser(user): RequireUser, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &post_id_str).await?; scope.require_mod_write(&state.db, user.user_id).await?; let post_data = scope.resource; let post_id = post_data.id; // Resolve the thread id up front so the cascade-delete log can be written on // the same transaction as the removal. let thread_id = parse_uuid(&thread_id_str)?; let mut tx = begin_tx(&state.db).await?; let removal = mt_db::mutations::mod_remove_post_cascade(&mut tx, post_id, user.user_id) .await .map_err(db_error)?; audit( &mut tx, Some(post_data.community_id), ModActor::User(user.user_id), ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None, ) .await?; // Removing the opening post cascades to soft-deleting the whole thread; log // the thread deletion on the same tx so the two records commit together. if removal.thread_removed { audit( &mut tx, Some(post_data.community_id), ModActor::User(user.user_id), ModAction::DeleteThread, Some(post_data.author_id), Some(thread_id), None, ) .await?; } commit_tx(tx).await?; // The thread page now 404s, so send the mod back to the category listing. if removal.thread_removed { return Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}?toast=Thread+removed" ))); } Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+removed" ))) } /// Reverse a removal, whether a moderator made it or the flag threshold did. /// /// Its own mod-log action rather than a side effect of dismissing a flag: the /// hide and the un-hide are separate decisions, and a log that records /// `auto_hide_post` with nothing after it cannot be told apart from one where /// the mod agreed with the hide. Dismissing a flag deliberately still leaves a /// hidden post hidden. /// /// Outstanding flags on the post are resolved as `dismissed` on the same /// transaction. Without that the restore undoes itself: `auto_hide_if_threshold_met` /// counts unresolved flags, so a post restored while still over the threshold /// re-hides on the very next flag, and the mod has no way to break the loop. #[tracing::instrument(skip_all)] pub(super) async fn mod_restore_post_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, RequireUser(user): RequireUser, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &post_id_str).await?; scope.require_mod_write(&state.db, user.user_id).await?; let post_data = scope.resource; let post_id = post_data.id; let thread_id = parse_uuid(&thread_id_str)?; let mut tx = begin_tx(&state.db).await?; let restore = mt_db::mutations::restore_post_cascade(&mut tx, post_id) .await .map_err(db_error)?; // Nothing to reverse: the post was already live. Say so rather than writing // a log row for an action that did not happen. if !restore.post_restored { commit_tx(tx).await?; return Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+was+not+removed" ))); } mt_db::mutations::resolve_all_flags_for_post(&mut *tx, post_id, user.user_id, "dismissed") .await .map_err(db_error)?; audit( &mut tx, Some(post_data.community_id), ModActor::User(user.user_id), ModAction::RestorePost, Some(post_data.author_id), Some(post_id), None, ) .await?; // The removal logged `DeleteThread` when it cascaded; log the inverse on the // same tx so the pair reads as one reversal rather than a thread that came // back unexplained. if restore.thread_restored { audit( &mut tx, Some(post_data.community_id), ModActor::User(user.user_id), ModAction::RestoreThread, Some(post_data.author_id), Some(thread_id), None, ) .await?; } commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+restored" ))) } // 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, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); // `require_mod_or_owner` already 403s a suspended community. let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; // 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"); } // Cap both moderation reads so a large ban/flag backlog can't make one page // load materialize an unbounded result set. Fetch CAP+1 to detect whether // more exist than we show, then surface that rather than silently truncating. const MOD_LIST_CAP: usize = 200; let mut db_bans = mt_db::queries::list_community_bans(&state.db, community.id, MOD_LIST_CAP as i64 + 1) .await .map_err(db_error)?; let bans_truncated = db_bans.len() > MOD_LIST_CAP; db_bans.truncate(MOD_LIST_CAP); let bans = db_bans .into_iter() .map(|b| BanListRow { username: b.username, display_name: b.display_name, ban_type: b.ban_type.to_string(), 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 mut db_flags = mt_db::queries::list_pending_flags(&state.db, community.id, MOD_LIST_CAP as i64 + 1) .await .map_err(db_error)?; let flags_truncated = db_flags.len() > MOD_LIST_CAP; db_flags.truncate(MOD_LIST_CAP); 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, bans_truncated, pending_flags, flags_truncated, 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, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; // The platform admin is a config identity, not a community role, so in a // community they don't own their role is None and the owner/mod protections // below don't cover them, a mod could ban them out of the community. Reject // it explicitly (their mod actions still bypass bans anyway). if state.config.platform_admin_id == Some(target_id) { return Err(( StatusCode::FORBIDDEN, "Cannot ban the platform administrator.", ) .into_response()); } // 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(field_error("reason", "Reason too long (max 1024 bytes).")); } let mut tx = begin_tx(&state.db).await?; mt_db::mutations::create_community_ban( &mut *tx, community.id, target_id, user.user_id, BanType::Ban, reason, expires_at, ) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::Ban, Some(target_id), None, reason, ) .await?; commit_tx(tx).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, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Ban) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::Unban, Some(target_id), None, None, ) .await?; commit_tx(tx).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, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; // See ban_user_handler: the platform admin holds no community role, so guard // them explicitly against a mod's mute. if state.config.platform_admin_id == Some(target_id) { return Err(( StatusCode::FORBIDDEN, "Cannot mute the platform administrator.", ) .into_response()); } // 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(field_error("reason", "Reason too long (max 1024 bytes).")); } let mut tx = begin_tx(&state.db).await?; mt_db::mutations::create_community_ban( &mut *tx, community.id, target_id, user.user_id, BanType::Mute, reason, expires_at, ) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::Mute, Some(target_id), None, reason, ) .await?; commit_tx(tx).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, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let target_id = get_user_by_username(&state.db, form.username.trim()).await?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Mute) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::Unmute, Some(target_id), None, None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=User+unmuted" ))) } /// The surface a soft-deleted thread can be restored from. /// /// Every thread loader filters `deleted_at IS NOT NULL`, so a deleted thread is /// unreachable by URL: its own page 404s and it is gone from every listing. /// Without this page the delete is effectively permanent even though the rows /// are all still there, and the post-level restore control cannot help, since it /// lives on the thread page that no longer renders. #[tracing::instrument(skip_all)] pub(super) async fn deleted_threads_page( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; // Same cap and truncation signal as the bans/flags reads on the moderation // page, for the same reason. const DELETED_LIST_CAP: usize = 200; let mut db_threads = mt_db::queries::list_deleted_threads(&state.db, community.id, DELETED_LIST_CAP as i64 + 1) .await .map_err(db_error)?; let threads_truncated = db_threads.len() > DELETED_LIST_CAP; db_threads.truncate(DELETED_LIST_CAP); let threads = db_threads .into_iter() .map(|t| DeletedThreadViewRow { thread_id: t.id.to_string(), title: t.title, category_slug: t.category_slug, author_username: t.author_username, deleted: mt_core::time_format::relative_timestamp(t.deleted_at), op_removed: t.op_removed, }) .collect(); Ok(DeletedThreadsTemplate { 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, threads, threads_truncated, }) } /// Restore a soft-deleted thread, and its opening post when the removal of that /// post is what deleted the thread. /// /// Scoped by looking the thread up within the community rather than through /// `CommunityScope`, because every scoped thread loader filters deleted threads /// out and would 404 the very rows this acts on. The community-id check in the /// query is what keeps a mod of one community from restoring another's thread. #[tracing::instrument(skip_all)] pub(super) async fn restore_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, thread_id_str)): Path<(String, String)>, RequireUser(user): RequireUser, ) -> Result { let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let thread_id = parse_uuid(&thread_id_str)?; let target = mt_db::queries::get_deleted_thread_in_community(&state.db, thread_id, community.id) .await .map_err(db_error)? .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?; let mut tx = begin_tx(&state.db).await?; let restore = mt_db::mutations::restore_thread_cascade(&mut tx, thread_id) .await .map_err(db_error)?; if !restore.thread_restored { commit_tx(tx).await?; return Ok(Redirect::to(&format!( "/p/{slug}/moderation/deleted?toast=Thread+was+not+deleted" ))); } audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::RestoreThread, Some(target.author_id), Some(thread_id), None, ) .await?; // Mirrors the removal, which logged RemovePost alongside DeleteThread. if restore.op_restored { audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::RestorePost, Some(target.author_id), Some(thread_id), None, ) .await?; } commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/{}/{thread_id}?toast=Thread+restored", target.category_slug ))) } #[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, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); 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(db_error)?; let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); let offset = pagination.offset(per_page); let db_entries = mt_db::queries::list_mod_log(&state.db, community.id, per_page, offset) .await .map_err(db_error)?; let entries = db_entries .into_iter() .map(|e| ModLogRow { actor: e.actor_username, action: e.action.to_string(), 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, }) }