Skip to main content

max / makenotwork

20.2 KB · 626 lines History Blame Raw
1 //! Community settings handlers (owner only).
2
3 use axum::{
4 Form,
5 extract::Path,
6 http::StatusCode,
7 response::{IntoResponse, Redirect, Response},
8 };
9 use tower_sessions::Session;
10
11 use livechat::ChatRooms;
12
13 use crate::AppState;
14 use crate::auth::RequireUser;
15 use crate::chat::{moderation::MtChatModeration, rooms::MtChatRooms};
16 use crate::csrf;
17 use crate::templates::{
18 CommunitySettingsTemplate, EditCategoryTemplate, SettingsCategoryRow, TagBadge,
19 };
20
21 use mt_core::types::{ChatPolicy, CommunityState, ModAction, ModActor};
22
23 use super::{
24 ChatSettingsForm, CleanSlateForm, CreateCategoryForm, CreateTagForm, DeleteTagForm,
25 EditCategoryFormData, MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, audit,
26 begin_tx, commit_tx, db_error, field_error, is_platform_admin, parse_uuid,
27 require_mod_or_superadmin, require_owner, template_user, validate_title,
28 };
29
30 #[tracing::instrument(skip_all)]
31 pub(super) async fn community_settings(
32 axum::extract::State(state): axum::extract::State<AppState>,
33 Path(slug): Path<String>,
34 session: Session,
35 RequireUser(user): RequireUser,
36 ) -> Result<impl IntoResponse, Response> {
37 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
38 // `require_owner` already 403s a suspended community.
39 let community = require_owner(&state, &slug, &user).await?;
40
41 let db_categories = mt_db::queries::list_categories_for_settings(&state.db, community.id)
42 .await
43 .map_err(db_error)?;
44
45 let cat_count = db_categories.len();
46 let categories = db_categories
47 .into_iter()
48 .enumerate()
49 .map(|(i, c)| SettingsCategoryRow {
50 id: c.id.to_string(),
51 name: c.name,
52 slug: c.slug,
53 description: c.description,
54 sort_order: c.sort_order,
55 is_first: i == 0,
56 is_last: i == cat_count - 1,
57 })
58 .collect();
59
60 // The room's own row rather than the community's, because policy and
61 // retention live behind the same dedicated query the chat routes use. Absent
62 // only if the community vanished between `require_owner` and here, in which
63 // case the defaults are as good an answer as any and better than a 500.
64 let chat = mt_db::queries::get_chat_room_by_slug(&state.db, &slug)
65 .await
66 .map_err(db_error)?;
67
68 let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id)
69 .await
70 .map_err(db_error)?;
71
72 let tags = db_tags
73 .into_iter()
74 .map(|t| TagBadge {
75 id: t.id.to_string(),
76 name: t.name,
77 slug: t.slug,
78 })
79 .collect();
80
81 Ok(CommunitySettingsTemplate {
82 csrf_token,
83 session_user: Some(template_user(&user, state.config.platform_admin_id)),
84 mnw_base_url: state.config.mnw_base_url.clone(),
85 community_name: community.name,
86 community_slug: slug,
87 community_description: community.description,
88 auto_hide_threshold: community.auto_hide_threshold,
89 chat_policy: chat.as_ref().map_or(ChatPolicy::Off, |c| c.policy).as_str(),
90 chat_policies: ChatPolicy::ALL,
91 chat_retention_hours: chat.as_ref().map_or(168, |c| c.retention_hours),
92 chat_max_messages: chat.as_ref().map_or(5_000, |c| c.max_messages),
93 chat_max_retention_hours: MAX_RETENTION_HOURS,
94 chat_message_ceiling: livechat::MAX_MESSAGES_CEILING,
95 categories,
96 tags,
97 })
98 }
99
100 /// The age ceiling as the form needs it.
101 ///
102 /// `MAX_AGE_CEILING` is a `Duration` because that is what `Retention` works in;
103 /// the column and the input are hours. Converting once here keeps the number in
104 /// one place: raising the crate's ceiling raises the form's `max` with it, and
105 /// nothing has to remember that 720 was ever written down.
106 #[expect(
107 clippy::cast_possible_truncation,
108 reason = "a ceiling in hours cannot approach i32::MAX"
109 )]
110 const MAX_RETENTION_HOURS: i32 = (livechat::MAX_AGE_CEILING.as_secs() / 3_600) as i32;
111
112 /// Save the chat policy and retention window (owner only).
113 ///
114 /// Separate from [`update_community_handler`] because the two are separate forms
115 /// on the page; one handler taking both would make each save silently rewrite
116 /// the other's fields with whatever the browser last rendered.
117 #[tracing::instrument(skip_all)]
118 pub(super) async fn chat_settings_handler(
119 axum::extract::State(state): axum::extract::State<AppState>,
120 Path(slug): Path<String>,
121 RequireUser(user): RequireUser,
122 Form(form): Form<ChatSettingsForm>,
123 ) -> Result<Redirect, Response> {
124 let community = require_owner(&state, &slug, &user).await?;
125
126 let policy = ChatPolicy::from_db(form.chat_policy.trim())
127 .ok_or_else(|| field_error("chat_policy", "Unknown chat policy."))?;
128
129 // Validated here against the crate's ceilings rather than left to migration
130 // 039's CHECKs. The CHECKs are the backstop for a call site that forgot;
131 // reaching them gives the owner a 500 where they should get a sentence
132 // telling them which bound they missed.
133 let retention_hours = bounded(
134 &form.retention_hours,
135 MAX_RETENTION_HOURS,
136 "retention_hours",
137 "retention window",
138 )?;
139 let max_messages = bounded(
140 &form.max_messages,
141 i32::try_from(livechat::MAX_MESSAGES_CEILING).unwrap_or(i32::MAX),
142 "max_messages",
143 "message cap",
144 )?;
145
146 let mut tx = begin_tx(&state.db).await?;
147 mt_db::mutations::update_chat_settings(
148 &mut *tx,
149 community.id,
150 policy.as_str(),
151 retention_hours,
152 max_messages,
153 )
154 .await
155 .map_err(db_error)?;
156 audit(
157 &mut tx,
158 Some(community.id),
159 ModActor::User(user.user_id),
160 ModAction::EditSettings,
161 None,
162 None,
163 Some(&format!(
164 "chat: {}, {retention_hours}h, {max_messages} messages",
165 policy.as_str()
166 )),
167 )
168 .await?;
169 commit_tx(tx).await?;
170
171 // Expiry is stamped on the row at insert, which is what makes the sweep one
172 // indexed delete. The cost of that choice is exactly this call: without it a
173 // window cut from 30 days to 1 would apply only to messages sent afterwards,
174 // and the owner who just shortened retention would still be holding 30 days
175 // of chat.
176 //
177 // After the commit rather than inside it: restamping walks every message in
178 // the room, and holding the settings transaction open for it would block the
179 // room's sends on an administrative action. A failure here leaves the new
180 // policy saved and the old expiry stamps in place, which the next save
181 // corrects and the sweep tolerates.
182 if let Err(e) =
183 mt_db::mutations::recompute_chat_expiry(&state.db, community.id, retention_hours).await
184 {
185 tracing::error!(error = ?e, community = %community.id, "chat expiry restamp failed");
186 }
187
188 Ok(Redirect::to(&format!(
189 "/p/{slug}/settings?toast=Chat+settings+saved"
190 )))
191 }
192
193 /// Parse one positive bound from the form, refusing anything past the ceiling.
194 ///
195 /// Reported through `field_error` rather than a bare 422 so the message lands
196 /// inline next to the input that was wrong, which is what the rest of the app's
197 /// forms do and what a screen with three of them needs.
198 #[allow(clippy::result_large_err)]
199 fn bounded(raw: &str, ceiling: i32, field: &'static str, what: &str) -> Result<i32, Response> {
200 let value: i32 = raw
201 .trim()
202 .parse()
203 .map_err(|_| field_error(field, format!("The {what} must be a whole number.")))?;
204
205 if value < 1 || value > ceiling {
206 return Err(field_error(
207 field,
208 format!("The {what} must be between 1 and {ceiling}."),
209 ));
210 }
211 Ok(value)
212 }
213
214 /// Empty the room now (owner only).
215 ///
216 /// Not gated on the room being open. An owner who has just turned chat off is
217 /// the likeliest person to want the backlog gone, and refusing them would mean
218 /// switching chat back on to clear it.
219 #[tracing::instrument(skip_all)]
220 pub(super) async fn wipe_chat_handler(
221 axum::extract::State(state): axum::extract::State<AppState>,
222 Path(slug): Path<String>,
223 RequireUser(user): RequireUser,
224 Form(form): Form<CleanSlateForm>,
225 ) -> Result<Redirect, Response> {
226 require_owner(&state, &slug, &user).await?;
227
228 // Typed-phrase confirmation, the same check and the same form the admin
229 // clean slate uses. Trim only; case is significant. Checked here rather
230 // than in the browser because a client-side dialog is a suggestion.
231 if form.confirm.trim() != slug {
232 return Err(field_error(
233 "confirm",
234 "Confirmation phrase did not match the community slug.",
235 ));
236 }
237
238 let room = MtChatRooms::new(state.db.clone())
239 .resolve(&slug)
240 .await
241 .map_err(|e| {
242 tracing::error!(error = ?e, "chat room resolve failed during wipe");
243 crate::error_page::internal_error()
244 })?
245 .ok_or_else(crate::error_page::not_found)?;
246
247 let removed = state
248 .chat
249 .wipe_room(
250 &MtChatModeration::for_owner(state.db.clone()),
251 livechat::UserId(user.user_id),
252 &room,
253 )
254 .await
255 .map_err(|e| {
256 tracing::error!(error = ?e, "chat wipe failed");
257 crate::error_page::internal_error()
258 })?;
259
260 Ok(Redirect::to(&format!(
261 "/p/{slug}/settings?toast={removed}+chat+messages+deleted"
262 )))
263 }
264
265 #[tracing::instrument(skip_all)]
266 pub(super) async fn update_community_handler(
267 axum::extract::State(state): axum::extract::State<AppState>,
268 Path(slug): Path<String>,
269 RequireUser(user): RequireUser,
270 Form(form): Form<UpdateCommunityForm>,
271 ) -> Result<Redirect, Response> {
272 let community = require_owner(&state, &slug, &user).await?;
273
274 let name = validate_title(&form.name)?;
275
276 let description = form.description.trim();
277 if description.len() > 2048 {
278 return Err((
279 StatusCode::UNPROCESSABLE_ENTITY,
280 "Description must be at most 2048 characters.",
281 )
282 .into_response());
283 }
284 let desc_opt = if description.is_empty() {
285 None
286 } else {
287 Some(description)
288 };
289
290 // Parse auto_hide_threshold: empty or "0" = disabled (None), otherwise positive integer
291 let threshold = form
292 .auto_hide_threshold
293 .as_deref()
294 .and_then(|s| s.trim().parse::<i32>().ok())
295 .filter(|&n| n > 0);
296
297 let mut tx = begin_tx(&state.db).await?;
298 mt_db::mutations::update_community(&mut *tx, community.id, name, desc_opt, threshold)
299 .await
300 .map_err(db_error)?;
301 audit(
302 &mut tx,
303 Some(community.id),
304 ModActor::User(user.user_id),
305 ModAction::EditSettings,
306 None,
307 None,
308 None,
309 )
310 .await?;
311 commit_tx(tx).await?;
312
313 Ok(Redirect::to(&format!(
314 "/p/{slug}/settings?toast=Settings+saved"
315 )))
316 }
317
318 #[tracing::instrument(skip_all)]
319 pub(super) async fn create_category_handler(
320 axum::extract::State(state): axum::extract::State<AppState>,
321 Path(slug): Path<String>,
322 RequireUser(user): RequireUser,
323 Form(form): Form<CreateCategoryForm>,
324 ) -> Result<Redirect, Response> {
325 let community = require_owner(&state, &slug, &user).await?;
326
327 let name = validate_title(&form.name)?;
328
329 let cat_slug = form.slug.trim().to_lowercase();
330 if cat_slug.is_empty()
331 || cat_slug.len() > 128
332 || !cat_slug
333 .chars()
334 .all(|c| c.is_ascii_alphanumeric() || c == '-')
335 {
336 return Err((
337 StatusCode::UNPROCESSABLE_ENTITY,
338 "Slug must be 1-128 characters, lowercase letters/numbers/hyphens only.",
339 )
340 .into_response());
341 }
342
343 let description = form.description.trim();
344 if description.len() > 1024 {
345 return Err((
346 StatusCode::UNPROCESSABLE_ENTITY,
347 "Description must be at most 1024 characters.",
348 )
349 .into_response());
350 }
351 let desc_opt = if description.is_empty() {
352 None
353 } else {
354 Some(description)
355 };
356
357 // Put new category at the end
358 let existing = mt_db::queries::list_categories_for_settings(&state.db, community.id)
359 .await
360 .map_err(db_error)?;
361 let next_order = existing.iter().map(|c| c.sort_order).max().unwrap_or(0) + 1;
362
363 let mut tx = begin_tx(&state.db).await?;
364 mt_db::mutations::create_category(
365 &mut *tx,
366 community.id,
367 name,
368 &cat_slug,
369 desc_opt,
370 next_order,
371 )
372 .await
373 .map_err(db_error)?;
374 audit(
375 &mut tx,
376 Some(community.id),
377 ModActor::User(user.user_id),
378 ModAction::CreateCategory,
379 None,
380 None,
381 Some(name),
382 )
383 .await?;
384 commit_tx(tx).await?;
385
386 Ok(Redirect::to(&format!(
387 "/p/{slug}/settings?toast=Category+created"
388 )))
389 }
390
391 #[tracing::instrument(skip_all)]
392 pub(super) async fn edit_category_form(
393 axum::extract::State(state): axum::extract::State<AppState>,
394 Path((slug, cat_id_str)): Path<(String, String)>,
395 session: Session,
396 RequireUser(user): RequireUser,
397 ) -> Result<impl IntoResponse, Response> {
398 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
399 let community = require_owner(&state, &slug, &user).await?;
400
401 let cat_id = parse_uuid(&cat_id_str)?;
402
403 // C1: scope the load to the slug's community so an owner of A can't render
404 // community B's category edit form (mismatch → 404, same as not found).
405 let cat = mt_db::queries::get_category_in_community(&state.db, cat_id, community.id)
406 .await
407 .map_err(db_error)?
408 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
409
410 Ok(EditCategoryTemplate {
411 csrf_token,
412 session_user: Some(template_user(&user, state.config.platform_admin_id)),
413 mnw_base_url: state.config.mnw_base_url.clone(),
414 community_name: community.name,
415 community_slug: slug,
416 category_id: cat_id_str,
417 category_name: cat.name,
418 category_description: cat.description,
419 })
420 }
421
422 #[tracing::instrument(skip_all)]
423 pub(super) async fn edit_category_handler(
424 axum::extract::State(state): axum::extract::State<AppState>,
425 Path((slug, cat_id_str)): Path<(String, String)>,
426 RequireUser(user): RequireUser,
427 Form(form): Form<EditCategoryFormData>,
428 ) -> Result<Redirect, Response> {
429 let community = require_owner(&state, &slug, &user).await?;
430
431 let cat_id = parse_uuid(&cat_id_str)?;
432
433 let name = validate_title(&form.name)?;
434
435 let description = form.description.trim();
436 if description.len() > 1024 {
437 return Err((
438 StatusCode::UNPROCESSABLE_ENTITY,
439 "Description must be at most 1024 characters.",
440 )
441 .into_response());
442 }
443 let desc_opt = if description.is_empty() {
444 None
445 } else {
446 Some(description)
447 };
448
449 let mut tx = begin_tx(&state.db).await?;
450 let updated = mt_db::mutations::update_category(&mut *tx, cat_id, community.id, name, desc_opt)
451 .await
452 .map_err(db_error)?;
453 if !updated {
454 // Nothing changed (no such category in this community); the tx rolls back
455 // on drop, so no empty audit row is written.
456 return Err(StatusCode::NOT_FOUND.into_response());
457 }
458 audit(
459 &mut tx,
460 Some(community.id),
461 ModActor::User(user.user_id),
462 ModAction::EditCategory,
463 None,
464 Some(cat_id),
465 None,
466 )
467 .await?;
468 commit_tx(tx).await?;
469
470 Ok(Redirect::to(&format!(
471 "/p/{slug}/settings?toast=Category+updated"
472 )))
473 }
474
475 #[tracing::instrument(skip_all)]
476 pub(super) async fn move_category_handler(
477 axum::extract::State(state): axum::extract::State<AppState>,
478 Path((slug, cat_id_str)): Path<(String, String)>,
479 RequireUser(user): RequireUser,
480 Form(form): Form<MoveCategoryForm>,
481 ) -> Result<Redirect, Response> {
482 let community = require_owner(&state, &slug, &user).await?;
483
484 let cat_id = parse_uuid(&cat_id_str)?;
485
486 let categories = mt_db::queries::list_categories_for_settings(&state.db, community.id)
487 .await
488 .map_err(db_error)?;
489
490 let pos = categories
491 .iter()
492 .position(|c| c.id == cat_id)
493 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
494
495 let swap_pos = match form.direction.as_str() {
496 "up" if pos > 0 => pos - 1,
497 "down" if pos < categories.len() - 1 => pos + 1,
498 _ => return Ok(Redirect::to(&format!("/p/{slug}/settings"))),
499 };
500
501 mt_db::mutations::swap_category_order(
502 &state.db,
503 categories[pos].id,
504 categories[pos].sort_order,
505 categories[swap_pos].id,
506 categories[swap_pos].sort_order,
507 )
508 .await
509 .map_err(db_error)?;
510
511 Ok(Redirect::to(&format!(
512 "/p/{slug}/settings?toast=Category+moved"
513 )))
514 }
515
516 // Tag management (owner only)
517
518 #[tracing::instrument(skip_all)]
519 pub(super) async fn create_tag_handler(
520 axum::extract::State(state): axum::extract::State<AppState>,
521 Path(slug): Path<String>,
522 RequireUser(user): RequireUser,
523 Form(form): Form<CreateTagForm>,
524 ) -> Result<Redirect, Response> {
525 let community = require_owner(&state, &slug, &user).await?;
526
527 let name = validate_title(&form.name)?;
528
529 const MT_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
530 max_depth: 3,
531 max_length: 64,
532 semantic_depth: 0,
533 };
534
535 let tag_slug = form.slug.trim().to_lowercase();
536 tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| {
537 (
538 StatusCode::UNPROCESSABLE_ENTITY,
539 format!("Invalid tag slug: {e}"),
540 )
541 .into_response()
542 })?;
543
544 mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug)
545 .await
546 .map_err(db_error)?;
547
548 Ok(Redirect::to(&format!(
549 "/p/{slug}/settings?toast=Tag+created"
550 )))
551 }
552
553 #[tracing::instrument(skip_all)]
554 pub(super) async fn delete_tag_handler(
555 axum::extract::State(state): axum::extract::State<AppState>,
556 Path(slug): Path<String>,
557 RequireUser(user): RequireUser,
558 Form(form): Form<DeleteTagForm>,
559 ) -> Result<Redirect, Response> {
560 let community = require_owner(&state, &slug, &user).await?;
561
562 let tag_id = parse_uuid(&form.tag_id)?;
563
564 let deleted = mt_db::mutations::delete_tag(&state.db, tag_id, community.id)
565 .await
566 .map_err(db_error)?;
567 if !deleted {
568 return Err(StatusCode::NOT_FOUND.into_response());
569 }
570
571 Ok(Redirect::to(&format!(
572 "/p/{slug}/settings?toast=Tag+deleted"
573 )))
574 }
575
576 /// `POST /p/{slug}/settings/state`, change community moderation state.
577 ///
578 /// Authorized for community Owner, Moderator, or platform admin. Transition
579 /// to/from any state is allowed; semantics live in [`CommunityState`].
580 ///
581 /// Logged as `ModAction::ChangeCommunityState` for audit. Returns 422 for an
582 /// unknown state value (anything other than the four documented states).
583 #[tracing::instrument(skip_all)]
584 pub(super) async fn set_community_state_handler(
585 axum::extract::State(state): axum::extract::State<AppState>,
586 Path(slug): Path<String>,
587 RequireUser(user): RequireUser,
588 Form(form): Form<SetCommunityStateForm>,
589 ) -> Result<Redirect, Response> {
590 let (community, _role) = require_mod_or_superadmin(&state, &slug, &user).await?;
591
592 // A suspended community is frozen to its owner/mods: only the platform admin
593 // (who manages suspensions via `_admin`) may still change its state.
594 if community.suspended_at.is_some() && !is_platform_admin(&state, &user) {
595 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
596 }
597
598 let new_state = CommunityState::from_db(form.state.trim()).ok_or_else(|| {
599 (StatusCode::UNPROCESSABLE_ENTITY, "Unknown community state.").into_response()
600 })?;
601
602 if new_state == community.state {
603 return Ok(Redirect::to(&format!("/p/{slug}/settings?toast=No+change")));
604 }
605
606 let mut tx = begin_tx(&state.db).await?;
607 mt_db::mutations::set_community_state(&mut *tx, community.id, new_state)
608 .await
609 .map_err(db_error)?;
610 audit(
611 &mut tx,
612 Some(community.id),
613 ModActor::User(user.user_id),
614 ModAction::ChangeCommunityState,
615 None,
616 None,
617 Some(new_state.as_str()),
618 )
619 .await?;
620 commit_tx(tx).await?;
621
622 Ok(Redirect::to(&format!(
623 "/p/{slug}/settings?toast=Community+state+updated"
624 )))
625 }
626