//! Post flagging handlers, flag, dismiss, mod-remove via flag. use axum::{ Form, extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use crate::AppState; use crate::auth::RequireUser; use mt_core::types::{ModAction, ModActor}; use super::{ CommunityScope, audit, begin_tx, check_write_access, commit_tx, field_error, parse_uuid, require_mod_or_owner, }; use mt_db::queries::PostForEdit; #[derive(Deserialize)] pub(super) struct FlagForm { pub(super) reason: String, pub(super) detail: Option, } /// POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag #[tracing::instrument(skip_all)] pub(super) async fn flag_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, Form(form): Form, ) -> Result { // Validate reason if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") { return Err(field_error("reason", "Invalid flag reason.")); } // Resolve the post within the slug's community, CommunityScope proves the // post belongs here, so a user banned in the post's community can't route the // flag through a different slug to evade the ban check or apply the wrong // community's auto-hide threshold (replaces the old hand-copied guard). let scope = CommunityScope::::resolve(&state.db, &slug, &post_id_str).await?; let post_id = scope.resource.id; // Cannot flag own post if user.user_id == scope.resource.author_id { return Err((StatusCode::FORBIDDEN, "You cannot flag your own post.").into_response()); } let CommunityScope { community, resource: post_data, } = scope; // Flagging is a write, not a read: enough flags trip `auto_hide_if_threshold_met` // on someone else's post. Gate it on `check_write_access` so platform suspension // and community mute apply, a user who cannot post must not be able to flag // either (the read-level `check_community_access` sees neither). check_write_access( &state.db, community.id, user.user_id, community.suspended_at.is_some(), ) .await?; let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty()); if let Some(d) = detail && d.len() > 1024 { return Err(field_error( "detail", "Flag detail too long (max 1024 bytes).", )); } mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail) .await .map_err(|e| { tracing::error!(error = ?e, "db error inserting flag"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; // Auto-hide: atomically check flag count and remove post if threshold met. // The removal and its audit row commit on one transaction, so the auto-hide // can never land without its log entry, and the log records `System` as the // actor (NULL actor_id), not the member who happened to trip the threshold. // Best-effort: a failure here is traced but does not fail the flag submission // (the flag itself already committed; the post will re-trip on the next flag). if let Some(threshold) = community.auto_hide_threshold && threshold > 0 { let hide = async { let mut tx = state.db.begin().await?; let hidden = mt_db::mutations::auto_hide_if_threshold_met(&mut *tx, post_id, threshold).await?; if hidden { mt_db::mutations::insert_mod_log( &mut *tx, Some(community.id), ModActor::System, ModAction::AutoHidePost, Some(post_data.author_id), Some(post_id), None, ) .await?; } tx.commit().await?; Ok::<(), sqlx::Error>(()) } .await; if let Err(e) = hide { tracing::error!(error = ?e, "auto-hide: failed to hide/log post"); } } Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+flagged" ))) } /// POST /p/{slug}/moderation/flags/{flag_id}/dismiss #[tracing::instrument(skip_all)] pub(super) async fn dismiss_flag_handler( axum::extract::State(state): axum::extract::State, Path((slug, flag_id_str)): Path<(String, String)>, RequireUser(user): RequireUser, ) -> Result { let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let flag_id = parse_uuid(&flag_id_str)?; // Verify flag belongs to this community before acting on it let flag_exists = mt_db::queries::flag_belongs_to_community(&state.db, flag_id, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking flag community"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; if !flag_exists { return Err((StatusCode::NOT_FOUND, "Not found").into_response()); } mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed") .await .map_err(|e| { tracing::error!(error = ?e, "db error dismissing flag"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=Flag+dismissed" ))) } /// POST /p/{slug}/moderation/flags/{flag_id}/remove /// Mod-removes the flagged post and resolves all flags on that post. #[tracing::instrument(skip_all)] pub(super) async fn remove_flagged_post_handler( axum::extract::State(state): axum::extract::State, Path((slug, flag_id_str)): Path<(String, String)>, RequireUser(user): RequireUser, ) -> Result { let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let flag_id = parse_uuid(&flag_id_str)?; // Find the flag's post + thread, scoped to this community (scoping enforced // in the query layer). let (post_id, author_id, thread_id) = mt_db::queries::get_flag_removal_target(&state.db, flag_id, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching flag"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })? .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?; // Mod-remove the post, resolve its flags, and write the audit row(s) on one // transaction: the removal, the flag resolution, and the log all commit // together or not at all. If the post is the OP, the whole thread is // soft-deleted and that deletion is logged on the same tx. 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(|e| { tracing::error!(error = ?e, "db error removing flagged post"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; mt_db::mutations::resolve_all_flags_for_post(&mut *tx, post_id, user.user_id, "removed") .await .map_err(|e| { tracing::error!(error = ?e, "db error resolving flags"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None, ) .await?; if removal.thread_removed { audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::DeleteThread, Some(author_id), Some(thread_id), None, ) .await?; } commit_tx(tx).await?; if removal.thread_removed { return Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=Thread+removed" ))); } Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=Post+removed" ))) }