Skip to main content

max / makenotwork

15.2 KB · 445 lines History Blame Raw
1 //! Moderation handlers — pin, lock, ban, mute, mod log.
2
3 use axum::{
4 extract::{Path, Query},
5 http::StatusCode,
6 response::{IntoResponse, Redirect, Response},
7 Form,
8 };
9 use tower_sessions::Session;
10
11 use crate::auth::MaybeUser;
12 use crate::csrf;
13 use crate::templates::*;
14 use crate::AppState;
15
16 use mt_core::types::{BanType, ModAction};
17
18 use super::{
19 get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner, log_mod_action,
20 parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm, PageQuery, UnbanForm,
21 };
22
23 #[tracing::instrument(skip_all)]
24 pub(super) async fn pin_thread_handler(
25 axum::extract::State(state): axum::extract::State<AppState>,
26 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
27 MaybeUser(session_user): MaybeUser,
28 ) -> Result<Redirect, Response> {
29 let user = session_user
30 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
31
32 let thread_data = get_thread(&state.db, &thread_id_str).await?;
33 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
34
35 if !is_mod_or_owner(&role) {
36 return Err(StatusCode::FORBIDDEN.into_response());
37 }
38
39 let new_pinned = !thread_data.pinned;
40 mt_db::mutations::set_thread_pinned(&state.db, thread_data.id, new_pinned)
41 .await
42 .map_err(|e| {
43 tracing::error!(error = ?e, "db error toggling pin");
44 StatusCode::INTERNAL_SERVER_ERROR.into_response()
45 })?;
46
47 let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread };
48 log_mod_action(
49 &state.db, Some(thread_data.community_id), user.user_id,
50 action, None, Some(thread_data.id), None,
51 ).await;
52
53 let toast = if new_pinned { "Thread+pinned" } else { "Thread+unpinned" };
54 Ok(Redirect::to(&format!(
55 "/p/{slug}/{category_slug}/{thread_id_str}?toast={toast}"
56 )))
57 }
58
59 #[tracing::instrument(skip_all)]
60 pub(super) async fn lock_thread_handler(
61 axum::extract::State(state): axum::extract::State<AppState>,
62 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
63 MaybeUser(session_user): MaybeUser,
64 ) -> Result<Redirect, Response> {
65 let user = session_user
66 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
67
68 let thread_data = get_thread(&state.db, &thread_id_str).await?;
69 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
70
71 if !is_mod_or_owner(&role) {
72 return Err(StatusCode::FORBIDDEN.into_response());
73 }
74
75 let new_locked = !thread_data.locked;
76 mt_db::mutations::set_thread_locked(&state.db, thread_data.id, new_locked)
77 .await
78 .map_err(|e| {
79 tracing::error!(error = ?e, "db error toggling lock");
80 StatusCode::INTERNAL_SERVER_ERROR.into_response()
81 })?;
82
83 let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread };
84 log_mod_action(
85 &state.db, Some(thread_data.community_id), user.user_id,
86 action, None, Some(thread_data.id), None,
87 ).await;
88
89 let toast = if new_locked { "Thread+locked" } else { "Thread+unlocked" };
90 Ok(Redirect::to(&format!(
91 "/p/{slug}/{category_slug}/{thread_id_str}?toast={toast}"
92 )))
93 }
94
95 // ============================================================================
96 // Post removal (mod/owner only)
97 // ============================================================================
98
99 #[tracing::instrument(skip_all)]
100 pub(super) async fn mod_remove_post_handler(
101 axum::extract::State(state): axum::extract::State<AppState>,
102 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
103 MaybeUser(session_user): MaybeUser,
104 ) -> Result<Redirect, Response> {
105 let user = session_user
106 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
107
108 let post_id = parse_uuid(&post_id_str)?;
109
110 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
111 .await
112 .map_err(|e| {
113 tracing::error!(error = ?e, "db error fetching post for removal");
114 StatusCode::INTERNAL_SERVER_ERROR.into_response()
115 })?
116 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
117
118 let role = get_role(&state.db, user.user_id, post_data.community_id).await?;
119
120 if !is_mod_or_owner(&role) {
121 return Err(StatusCode::FORBIDDEN.into_response());
122 }
123
124 let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
125 .await
126 .map_err(|e| {
127 tracing::error!(error = ?e, "db error removing post");
128 StatusCode::INTERNAL_SERVER_ERROR.into_response()
129 })?;
130
131 log_mod_action(
132 &state.db, Some(post_data.community_id), user.user_id,
133 ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None,
134 ).await;
135
136 Ok(Redirect::to(&format!(
137 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+removed"
138 )))
139 }
140
141 // ============================================================================
142 // Community moderation routes
143 // ============================================================================
144
145 #[tracing::instrument(skip_all)]
146 pub(super) async fn moderation_page(
147 axum::extract::State(state): axum::extract::State<AppState>,
148 Path(slug): Path<String>,
149 session: Session,
150 MaybeUser(session_user): MaybeUser,
151 ) -> Result<impl IntoResponse, Response> {
152 let csrf_token = Some(csrf::get_or_create_token(&session).await);
153 let user = session_user
154 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
155
156 let (community, role) = require_mod_or_owner(&state, &slug, &user).await?;
157
158 if community.suspended_at.is_some() {
159 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
160 }
161
162 // Opportunistic cleanup of expired bans/mutes
163 if let Err(e) = mt_db::mutations::cleanup_expired_bans(&state.db, community.id).await {
164 tracing::error!(error = %e, "failed to clean up expired bans");
165 }
166
167 let db_bans = mt_db::queries::list_community_bans(&state.db, community.id)
168 .await
169 .map_err(|e| {
170 tracing::error!(error = ?e, "db error listing bans");
171 StatusCode::INTERNAL_SERVER_ERROR.into_response()
172 })?;
173
174 let bans = db_bans
175 .into_iter()
176 .map(|b| BanListRow {
177 username: b.username,
178 display_name: b.display_name,
179 ban_type: b.ban_type.to_string(),
180 reason: b.reason,
181 expires: b.expires_at.map(mt_core::time_format::relative_timestamp),
182 created: mt_core::time_format::relative_timestamp(b.created_at),
183 banned_by: b.banned_by_username,
184 })
185 .collect();
186
187 let db_flags = mt_db::queries::list_pending_flags(&state.db, community.id)
188 .await
189 .map_err(|e| {
190 tracing::error!(error = ?e, "db error listing flags");
191 StatusCode::INTERNAL_SERVER_ERROR.into_response()
192 })?;
193
194 let pending_flags = db_flags
195 .into_iter()
196 .map(|f| FlagViewRow {
197 flag_id: f.flag_id.to_string(),
198 post_id: f.post_id.to_string(),
199 thread_id: f.thread_id.to_string(),
200 thread_title: f.thread_title,
201 category_slug: f.category_slug,
202 flagger_username: f.flagger_username,
203 reason: f.reason,
204 detail: f.detail,
205 created: mt_core::time_format::relative_timestamp(f.created_at),
206 })
207 .collect();
208
209 Ok(ModerationTemplate {
210 csrf_token,
211 session_user: Some(template_user(&user, state.config.platform_admin_id)),
212 mnw_base_url: state.config.mnw_base_url.clone(),
213 community_name: community.name,
214 community_slug: slug,
215 bans,
216 pending_flags,
217 is_owner: is_owner(&role),
218 })
219 }
220
221 #[tracing::instrument(skip_all)]
222 pub(super) async fn ban_user_handler(
223 axum::extract::State(state): axum::extract::State<AppState>,
224 Path(slug): Path<String>,
225 MaybeUser(session_user): MaybeUser,
226 Form(form): Form<BanForm>,
227 ) -> Result<Redirect, Response> {
228 let user = session_user
229 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
230
231 let (community, role) = require_mod_or_owner(&state, &slug, &user).await?;
232
233 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
234
235 // Prevent banning owners
236 let target_role = get_role(&state.db, target_id, community.id).await?;
237
238 if is_owner(&target_role) {
239 return Err((StatusCode::FORBIDDEN, "Cannot ban an owner.").into_response());
240 }
241
242 // Mods can't ban other mods — only owners can
243 if is_mod_or_owner(&target_role) && !is_owner(&role) {
244 return Err((StatusCode::FORBIDDEN, "Only owners can ban moderators.").into_response());
245 }
246
247 let expires_at = parse_duration(&form.duration)?;
248 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
249
250 if let Some(r) = reason
251 && r.len() > 1024
252 {
253 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
254 }
255
256 mt_db::mutations::create_community_ban(
257 &state.db, community.id, target_id, user.user_id,
258 BanType::Ban, reason, expires_at,
259 )
260 .await
261 .map_err(|e| {
262 tracing::error!(error = ?e, "db error creating ban");
263 StatusCode::INTERNAL_SERVER_ERROR.into_response()
264 })?;
265
266 log_mod_action(
267 &state.db, Some(community.id), user.user_id,
268 ModAction::Ban, Some(target_id), None, reason,
269 ).await;
270
271 Ok(Redirect::to(&format!(
272 "/p/{slug}/moderation?toast=User+banned"
273 )))
274 }
275
276 #[tracing::instrument(skip_all)]
277 pub(super) async fn unban_user_handler(
278 axum::extract::State(state): axum::extract::State<AppState>,
279 Path(slug): Path<String>,
280 MaybeUser(session_user): MaybeUser,
281 Form(form): Form<UnbanForm>,
282 ) -> Result<Redirect, Response> {
283 let user = session_user
284 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
285
286 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
287
288 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
289
290 mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Ban)
291 .await
292 .map_err(|e| {
293 tracing::error!(error = ?e, "db error removing ban");
294 StatusCode::INTERNAL_SERVER_ERROR.into_response()
295 })?;
296
297 log_mod_action(
298 &state.db, Some(community.id), user.user_id,
299 ModAction::Unban, Some(target_id), None, None,
300 ).await;
301
302 Ok(Redirect::to(&format!(
303 "/p/{slug}/moderation?toast=User+unbanned"
304 )))
305 }
306
307 #[tracing::instrument(skip_all)]
308 pub(super) async fn mute_user_handler(
309 axum::extract::State(state): axum::extract::State<AppState>,
310 Path(slug): Path<String>,
311 MaybeUser(session_user): MaybeUser,
312 Form(form): Form<BanForm>,
313 ) -> Result<Redirect, Response> {
314 let user = session_user
315 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
316
317 let (community, role) = require_mod_or_owner(&state, &slug, &user).await?;
318
319 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
320
321 // Prevent muting owners
322 let target_role = get_role(&state.db, target_id, community.id).await?;
323
324 if is_owner(&target_role) {
325 return Err((StatusCode::FORBIDDEN, "Cannot mute an owner.").into_response());
326 }
327
328 if is_mod_or_owner(&target_role) && !is_owner(&role) {
329 return Err((StatusCode::FORBIDDEN, "Only owners can mute moderators.").into_response());
330 }
331
332 let expires_at = parse_duration(&form.duration)?;
333 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
334
335 if let Some(r) = reason
336 && r.len() > 1024
337 {
338 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
339 }
340
341 mt_db::mutations::create_community_ban(
342 &state.db, community.id, target_id, user.user_id,
343 BanType::Mute, reason, expires_at,
344 )
345 .await
346 .map_err(|e| {
347 tracing::error!(error = ?e, "db error creating mute");
348 StatusCode::INTERNAL_SERVER_ERROR.into_response()
349 })?;
350
351 log_mod_action(
352 &state.db, Some(community.id), user.user_id,
353 ModAction::Mute, Some(target_id), None, reason,
354 ).await;
355
356 Ok(Redirect::to(&format!(
357 "/p/{slug}/moderation?toast=User+muted"
358 )))
359 }
360
361 #[tracing::instrument(skip_all)]
362 pub(super) async fn unmute_user_handler(
363 axum::extract::State(state): axum::extract::State<AppState>,
364 Path(slug): Path<String>,
365 MaybeUser(session_user): MaybeUser,
366 Form(form): Form<UnbanForm>,
367 ) -> Result<Redirect, Response> {
368 let user = session_user
369 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
370
371 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
372
373 let target_id = get_user_by_username(&state.db, form.username.trim()).await?;
374
375 mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Mute)
376 .await
377 .map_err(|e| {
378 tracing::error!(error = ?e, "db error removing mute");
379 StatusCode::INTERNAL_SERVER_ERROR.into_response()
380 })?;
381
382 log_mod_action(
383 &state.db, Some(community.id), user.user_id,
384 ModAction::Unmute, Some(target_id), None, None,
385 ).await;
386
387 Ok(Redirect::to(&format!(
388 "/p/{slug}/moderation?toast=User+unmuted"
389 )))
390 }
391
392 #[tracing::instrument(skip_all)]
393 pub(super) async fn mod_log_page(
394 axum::extract::State(state): axum::extract::State<AppState>,
395 Path(slug): Path<String>,
396 Query(page_query): Query<PageQuery>,
397 session: Session,
398 MaybeUser(session_user): MaybeUser,
399 ) -> Result<impl IntoResponse, Response> {
400 let csrf_token = Some(csrf::get_or_create_token(&session).await);
401 let user = session_user
402 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
403
404 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
405
406 let per_page: i64 = 50;
407 let total = mt_db::queries::count_mod_log(&state.db, community.id)
408 .await
409 .map_err(|e| {
410 tracing::error!(error = ?e, "db error counting mod log");
411 StatusCode::INTERNAL_SERVER_ERROR.into_response()
412 })?;
413
414 let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
415 let offset = pagination.offset(per_page);
416
417 let db_entries = mt_db::queries::list_mod_log(&state.db, community.id, per_page, offset)
418 .await
419 .map_err(|e| {
420 tracing::error!(error = ?e, "db error listing mod log");
421 StatusCode::INTERNAL_SERVER_ERROR.into_response()
422 })?;
423
424 let entries = db_entries
425 .into_iter()
426 .map(|e| ModLogRow {
427 actor: e.actor_username,
428 action: e.action.to_string(),
429 target: e.target_username,
430 reason: e.reason,
431 timestamp: mt_core::time_format::relative_timestamp(e.created_at),
432 })
433 .collect();
434
435 Ok(ModLogTemplate {
436 csrf_token,
437 session_user: Some(template_user(&user, state.config.platform_admin_id)),
438 mnw_base_url: state.config.mnw_base_url.clone(),
439 community_name: community.name,
440 community_slug: slug,
441 entries,
442 pagination,
443 })
444 }
445