Skip to main content

max / makenotwork

7.1 KB · 196 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, "Internal server error").into_response()
50 })?
51 .ok_or_else(|| (StatusCode::NOT_FOUND, "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, "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, 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 // Verify flag belongs to this community before acting on it
114 let flag_exists = mt_db::queries::flag_belongs_to_community(&state.db, flag_id, community.id)
115 .await
116 .map_err(|e| {
117 tracing::error!(error = ?e, "db error checking flag community");
118 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
119 })?;
120 if !flag_exists {
121 return Err((StatusCode::NOT_FOUND, "Not found").into_response());
122 }
123
124 mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed")
125 .await
126 .map_err(|e| {
127 tracing::error!(error = ?e, "db error dismissing flag");
128 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
129 })?;
130
131 Ok(Redirect::to(&format!(
132 "/p/{slug}/moderation?toast=Flag+dismissed"
133 )))
134 }
135
136 /// POST /p/{slug}/moderation/flags/{flag_id}/remove
137 /// Mod-removes the flagged post and resolves all flags on that post.
138 #[tracing::instrument(skip_all)]
139 pub(super) async fn remove_flagged_post_handler(
140 axum::extract::State(state): axum::extract::State<AppState>,
141 Path((slug, flag_id_str)): Path<(String, String)>,
142 MaybeUser(session_user): MaybeUser,
143 ) -> Result<Redirect, Response> {
144 let user = session_user
145 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
146
147 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
148 let flag_id = parse_uuid(&flag_id_str)?;
149
150 // Get the flag to find the post_id — scoped to this community
151 let flag_row: Option<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
152 "SELECT pf.post_id, p.author_id
153 FROM post_flags pf
154 JOIN posts p ON p.id = pf.post_id
155 JOIN threads t ON t.id = p.thread_id
156 JOIN categories c ON c.id = t.category_id
157 WHERE pf.id = $1 AND c.community_id = $2",
158 )
159 .bind(flag_id)
160 .bind(community.id)
161 .fetch_optional(&state.db)
162 .await
163 .map_err(|e| {
164 tracing::error!(error = ?e, "db error fetching flag");
165 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
166 })?;
167
168 let (post_id, author_id) = flag_row
169 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
170
171 // Mod-remove the post (idempotent — returns false if already removed)
172 let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
173 .await
174 .map_err(|e| {
175 tracing::error!(error = ?e, "db error removing flagged post");
176 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
177 })?;
178
179 // Resolve all flags on this post
180 mt_db::mutations::resolve_all_flags_for_post(&state.db, post_id, user.user_id, "removed")
181 .await
182 .map_err(|e| {
183 tracing::error!(error = ?e, "db error resolving flags");
184 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
185 })?;
186
187 log_mod_action(
188 &state.db, Some(community.id), user.user_id,
189 ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None,
190 ).await;
191
192 Ok(Redirect::to(&format!(
193 "/p/{slug}/moderation?toast=Post+removed"
194 )))
195 }
196