Skip to main content

max / multithreaded

6.2 KB · 179 lines History Blame Raw
1 //! Post flagging handlers — flag, dismiss, mod-remove via flag.
2
3 use axum::{
4 extract::Path,
5 http::StatusCode,
6 response::{IntoResponse, Redirect, Response},
7 Form,
8 };
9 use serde::Deserialize;
10
11 use crate::auth::MaybeUser;
12 use crate::AppState;
13
14 use mt_core::types::ModAction;
15
16 use super::{
17 check_community_access, get_community, log_mod_action, parse_uuid, require_mod_or_owner,
18 };
19
20 #[derive(Deserialize)]
21 pub(super) struct FlagForm {
22 pub(super) reason: String,
23 pub(super) detail: Option<String>,
24 }
25
26 /// POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag
27 #[tracing::instrument(skip_all)]
28 pub(super) async fn flag_post_handler(
29 axum::extract::State(state): axum::extract::State<AppState>,
30 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
31 MaybeUser(session_user): MaybeUser,
32 Form(form): Form<FlagForm>,
33 ) -> Result<Redirect, Response> {
34 let user = session_user
35 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
36
37 let post_id = parse_uuid(&post_id_str)?;
38
39 // Validate reason
40 if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") {
41 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid flag reason.").into_response());
42 }
43
44 // Fetch post to check ownership and existence
45 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
46 .await
47 .map_err(|e| {
48 tracing::error!(error = ?e, "db error fetching post for flag");
49 StatusCode::INTERNAL_SERVER_ERROR.into_response()
50 })?
51 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
52
53 // Cannot flag own post
54 if user.user_id == post_data.author_id {
55 return Err((StatusCode::FORBIDDEN, "You cannot flag your own post.").into_response());
56 }
57
58 // Check community access
59 let community = get_community(&state.db, &slug).await?;
60 check_community_access(&state.db, &community, Some(user.user_id)).await?;
61
62 let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty());
63
64 if let Some(d) = detail
65 && d.len() > 1024
66 {
67 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Flag detail too long (max 1024 bytes).").into_response());
68 }
69
70 mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail)
71 .await
72 .map_err(|e| {
73 tracing::error!(error = ?e, "db error inserting flag");
74 StatusCode::INTERNAL_SERVER_ERROR.into_response()
75 })?;
76
77 // Auto-hide: atomically check flag count and remove post if threshold met
78 if let Some(threshold) = community.auto_hide_threshold
79 && threshold > 0
80 {
81 match mt_db::mutations::auto_hide_if_threshold_met(
82 &state.db, post_id, user.user_id, threshold,
83 ).await {
84 Ok(true) => {
85 log_mod_action(
86 &state.db, Some(community.id), user.user_id,
87 ModAction::AutoHidePost, Some(post_data.author_id), Some(post_id), None,
88 ).await;
89 }
90 Ok(false) => {} // threshold not met or already removed
91 Err(e) => tracing::error!(error = ?e, "auto-hide: failed to check/remove post"),
92 }
93 }
94
95 Ok(Redirect::to(&format!(
96 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+flagged"
97 )))
98 }
99
100 /// POST /p/{slug}/moderation/flags/{flag_id}/dismiss
101 #[tracing::instrument(skip_all)]
102 pub(super) async fn dismiss_flag_handler(
103 axum::extract::State(state): axum::extract::State<AppState>,
104 Path((slug, flag_id_str)): Path<(String, String)>,
105 MaybeUser(session_user): MaybeUser,
106 ) -> Result<Redirect, Response> {
107 let user = session_user
108 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
109
110 let (_community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
111 let flag_id = parse_uuid(&flag_id_str)?;
112
113 mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed")
114 .await
115 .map_err(|e| {
116 tracing::error!(error = ?e, "db error dismissing flag");
117 StatusCode::INTERNAL_SERVER_ERROR.into_response()
118 })?;
119
120 Ok(Redirect::to(&format!(
121 "/p/{slug}/moderation?toast=Flag+dismissed"
122 )))
123 }
124
125 /// POST /p/{slug}/moderation/flags/{flag_id}/remove
126 /// Mod-removes the flagged post and resolves all flags on that post.
127 #[tracing::instrument(skip_all)]
128 pub(super) async fn remove_flagged_post_handler(
129 axum::extract::State(state): axum::extract::State<AppState>,
130 Path((slug, flag_id_str)): Path<(String, String)>,
131 MaybeUser(session_user): MaybeUser,
132 ) -> Result<Redirect, Response> {
133 let user = session_user
134 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
135
136 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
137 let flag_id = parse_uuid(&flag_id_str)?;
138
139 // Get the flag to find the post_id
140 let flag_row: Option<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
141 "SELECT post_id, (SELECT author_id FROM posts WHERE id = post_flags.post_id) FROM post_flags WHERE id = $1",
142 )
143 .bind(flag_id)
144 .fetch_optional(&state.db)
145 .await
146 .map_err(|e| {
147 tracing::error!(error = ?e, "db error fetching flag");
148 StatusCode::INTERNAL_SERVER_ERROR.into_response()
149 })?;
150
151 let (post_id, author_id) = flag_row
152 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
153
154 // Mod-remove the post (idempotent — returns false if already removed)
155 let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
156 .await
157 .map_err(|e| {
158 tracing::error!(error = ?e, "db error removing flagged post");
159 StatusCode::INTERNAL_SERVER_ERROR.into_response()
160 })?;
161
162 // Resolve all flags on this post
163 mt_db::mutations::resolve_all_flags_for_post(&state.db, post_id, user.user_id, "removed")
164 .await
165 .map_err(|e| {
166 tracing::error!(error = ?e, "db error resolving flags");
167 StatusCode::INTERNAL_SERVER_ERROR.into_response()
168 })?;
169
170 log_mod_action(
171 &state.db, Some(community.id), user.user_id,
172 ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None,
173 ).await;
174
175 Ok(Redirect::to(&format!(
176 "/p/{slug}/moderation?toast=Post+removed"
177 )))
178 }
179