Skip to main content

max / makenotwork

8.3 KB · 236 lines History Blame Raw
1 //! Post flagging handlers, flag, dismiss, mod-remove via flag.
2
3 use axum::{
4 Form,
5 extract::Path,
6 http::StatusCode,
7 response::{IntoResponse, Redirect, Response},
8 };
9 use serde::Deserialize;
10
11 use crate::AppState;
12 use crate::auth::RequireUser;
13
14 use mt_core::types::{ModAction, ModActor};
15
16 use super::{
17 CommunityScope, audit, begin_tx, check_write_access, commit_tx, field_error, parse_uuid,
18 require_mod_or_owner,
19 };
20 use mt_db::queries::PostForEdit;
21
22 #[derive(Deserialize)]
23 pub(super) struct FlagForm {
24 pub(super) reason: String,
25 pub(super) detail: Option<String>,
26 }
27
28 /// POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag
29 #[tracing::instrument(skip_all)]
30 pub(super) async fn flag_post_handler(
31 axum::extract::State(state): axum::extract::State<AppState>,
32 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
33 RequireUser(user): RequireUser,
34 Form(form): Form<FlagForm>,
35 ) -> Result<Redirect, Response> {
36 // Validate reason
37 if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") {
38 return Err(field_error("reason", "Invalid flag reason."));
39 }
40
41 // Resolve the post within the slug's community, CommunityScope proves the
42 // post belongs here, so a user banned in the post's community can't route the
43 // flag through a different slug to evade the ban check or apply the wrong
44 // community's auto-hide threshold (replaces the old hand-copied guard).
45 let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?;
46 let post_id = scope.resource.id;
47
48 // Cannot flag own post
49 if user.user_id == scope.resource.author_id {
50 return Err((StatusCode::FORBIDDEN, "You cannot flag your own post.").into_response());
51 }
52
53 let CommunityScope {
54 community,
55 resource: post_data,
56 } = scope;
57
58 // Flagging is a write, not a read: enough flags trip `auto_hide_if_threshold_met`
59 // on someone else's post. Gate it on `check_write_access` so platform suspension
60 // and community mute apply, a user who cannot post must not be able to flag
61 // either (the read-level `check_community_access` sees neither).
62 check_write_access(
63 &state.db,
64 community.id,
65 user.user_id,
66 community.suspended_at.is_some(),
67 )
68 .await?;
69
70 let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty());
71
72 if let Some(d) = detail
73 && d.len() > 1024
74 {
75 return Err(field_error(
76 "detail",
77 "Flag detail too long (max 1024 bytes).",
78 ));
79 }
80
81 mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail)
82 .await
83 .map_err(|e| {
84 tracing::error!(error = ?e, "db error inserting flag");
85 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
86 })?;
87
88 // Auto-hide: atomically check flag count and remove post if threshold met.
89 // The removal and its audit row commit on one transaction, so the auto-hide
90 // can never land without its log entry, and the log records `System` as the
91 // actor (NULL actor_id), not the member who happened to trip the threshold.
92 // Best-effort: a failure here is traced but does not fail the flag submission
93 // (the flag itself already committed; the post will re-trip on the next flag).
94 if let Some(threshold) = community.auto_hide_threshold
95 && threshold > 0
96 {
97 let hide = async {
98 let mut tx = state.db.begin().await?;
99 let hidden =
100 mt_db::mutations::auto_hide_if_threshold_met(&mut *tx, post_id, threshold).await?;
101 if hidden {
102 mt_db::mutations::insert_mod_log(
103 &mut *tx,
104 Some(community.id),
105 ModActor::System,
106 ModAction::AutoHidePost,
107 Some(post_data.author_id),
108 Some(post_id),
109 None,
110 )
111 .await?;
112 }
113 tx.commit().await?;
114 Ok::<(), sqlx::Error>(())
115 }
116 .await;
117 if let Err(e) = hide {
118 tracing::error!(error = ?e, "auto-hide: failed to hide/log post");
119 }
120 }
121
122 Ok(Redirect::to(&format!(
123 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+flagged"
124 )))
125 }
126
127 /// POST /p/{slug}/moderation/flags/{flag_id}/dismiss
128 #[tracing::instrument(skip_all)]
129 pub(super) async fn dismiss_flag_handler(
130 axum::extract::State(state): axum::extract::State<AppState>,
131 Path((slug, flag_id_str)): Path<(String, String)>,
132 RequireUser(user): RequireUser,
133 ) -> Result<Redirect, Response> {
134 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
135 let flag_id = parse_uuid(&flag_id_str)?;
136
137 // Verify flag belongs to this community before acting on it
138 let flag_exists = mt_db::queries::flag_belongs_to_community(&state.db, flag_id, community.id)
139 .await
140 .map_err(|e| {
141 tracing::error!(error = ?e, "db error checking flag community");
142 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
143 })?;
144 if !flag_exists {
145 return Err((StatusCode::NOT_FOUND, "Not found").into_response());
146 }
147
148 mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed")
149 .await
150 .map_err(|e| {
151 tracing::error!(error = ?e, "db error dismissing flag");
152 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
153 })?;
154
155 Ok(Redirect::to(&format!(
156 "/p/{slug}/moderation?toast=Flag+dismissed"
157 )))
158 }
159
160 /// POST /p/{slug}/moderation/flags/{flag_id}/remove
161 /// Mod-removes the flagged post and resolves all flags on that post.
162 #[tracing::instrument(skip_all)]
163 pub(super) async fn remove_flagged_post_handler(
164 axum::extract::State(state): axum::extract::State<AppState>,
165 Path((slug, flag_id_str)): Path<(String, String)>,
166 RequireUser(user): RequireUser,
167 ) -> Result<Redirect, Response> {
168 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
169 let flag_id = parse_uuid(&flag_id_str)?;
170
171 // Find the flag's post + thread, scoped to this community (scoping enforced
172 // in the query layer).
173 let (post_id, author_id, thread_id) =
174 mt_db::queries::get_flag_removal_target(&state.db, flag_id, community.id)
175 .await
176 .map_err(|e| {
177 tracing::error!(error = ?e, "db error fetching flag");
178 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
179 })?
180 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
181
182 // Mod-remove the post, resolve its flags, and write the audit row(s) on one
183 // transaction: the removal, the flag resolution, and the log all commit
184 // together or not at all. If the post is the OP, the whole thread is
185 // soft-deleted and that deletion is logged on the same tx.
186 let mut tx = begin_tx(&state.db).await?;
187 let removal = mt_db::mutations::mod_remove_post_cascade(&mut tx, post_id, user.user_id)
188 .await
189 .map_err(|e| {
190 tracing::error!(error = ?e, "db error removing flagged post");
191 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
192 })?;
193
194 mt_db::mutations::resolve_all_flags_for_post(&mut *tx, post_id, user.user_id, "removed")
195 .await
196 .map_err(|e| {
197 tracing::error!(error = ?e, "db error resolving flags");
198 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
199 })?;
200
201 audit(
202 &mut tx,
203 Some(community.id),
204 ModActor::User(user.user_id),
205 ModAction::RemovePostViaFlag,
206 Some(author_id),
207 Some(post_id),
208 None,
209 )
210 .await?;
211
212 if removal.thread_removed {
213 audit(
214 &mut tx,
215 Some(community.id),
216 ModActor::User(user.user_id),
217 ModAction::DeleteThread,
218 Some(author_id),
219 Some(thread_id),
220 None,
221 )
222 .await?;
223 }
224 commit_tx(tx).await?;
225
226 if removal.thread_removed {
227 return Ok(Redirect::to(&format!(
228 "/p/{slug}/moderation?toast=Thread+removed"
229 )));
230 }
231
232 Ok(Redirect::to(&format!(
233 "/p/{slug}/moderation?toast=Post+removed"
234 )))
235 }
236