Skip to main content

max / makenotwork

13.3 KB · 444 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 crate::AppState;
12 use crate::auth::RequireUser;
13 use crate::csrf;
14 use crate::templates::{
15 CommunitySettingsTemplate, EditCategoryTemplate, SettingsCategoryRow, TagBadge,
16 };
17
18 use mt_core::types::{CommunityState, ModAction, ModActor};
19
20 use super::{
21 CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm,
22 SetCommunityStateForm, UpdateCommunityForm, audit, begin_tx, commit_tx, db_error,
23 is_platform_admin, parse_uuid, require_mod_or_superadmin, require_owner, template_user,
24 validate_title,
25 };
26
27 #[tracing::instrument(skip_all)]
28 pub(super) async fn community_settings(
29 axum::extract::State(state): axum::extract::State<AppState>,
30 Path(slug): Path<String>,
31 session: Session,
32 RequireUser(user): RequireUser,
33 ) -> Result<impl IntoResponse, Response> {
34 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
35 // `require_owner` already 403s a suspended community.
36 let community = require_owner(&state, &slug, &user).await?;
37
38 let db_categories = mt_db::queries::list_categories_for_settings(&state.db, community.id)
39 .await
40 .map_err(db_error)?;
41
42 let cat_count = db_categories.len();
43 let categories = db_categories
44 .into_iter()
45 .enumerate()
46 .map(|(i, c)| SettingsCategoryRow {
47 id: c.id.to_string(),
48 name: c.name,
49 slug: c.slug,
50 description: c.description,
51 sort_order: c.sort_order,
52 is_first: i == 0,
53 is_last: i == cat_count - 1,
54 })
55 .collect();
56
57 let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id)
58 .await
59 .map_err(db_error)?;
60
61 let tags = db_tags
62 .into_iter()
63 .map(|t| TagBadge {
64 id: t.id.to_string(),
65 name: t.name,
66 slug: t.slug,
67 })
68 .collect();
69
70 Ok(CommunitySettingsTemplate {
71 csrf_token,
72 session_user: Some(template_user(&user, state.config.platform_admin_id)),
73 mnw_base_url: state.config.mnw_base_url.clone(),
74 community_name: community.name,
75 community_slug: slug,
76 community_description: community.description,
77 auto_hide_threshold: community.auto_hide_threshold,
78 categories,
79 tags,
80 })
81 }
82
83 #[tracing::instrument(skip_all)]
84 pub(super) async fn update_community_handler(
85 axum::extract::State(state): axum::extract::State<AppState>,
86 Path(slug): Path<String>,
87 RequireUser(user): RequireUser,
88 Form(form): Form<UpdateCommunityForm>,
89 ) -> Result<Redirect, Response> {
90 let community = require_owner(&state, &slug, &user).await?;
91
92 let name = validate_title(&form.name)?;
93
94 let description = form.description.trim();
95 if description.len() > 2048 {
96 return Err((
97 StatusCode::UNPROCESSABLE_ENTITY,
98 "Description must be at most 2048 characters.",
99 )
100 .into_response());
101 }
102 let desc_opt = if description.is_empty() {
103 None
104 } else {
105 Some(description)
106 };
107
108 // Parse auto_hide_threshold: empty or "0" = disabled (None), otherwise positive integer
109 let threshold = form
110 .auto_hide_threshold
111 .as_deref()
112 .and_then(|s| s.trim().parse::<i32>().ok())
113 .filter(|&n| n > 0);
114
115 let mut tx = begin_tx(&state.db).await?;
116 mt_db::mutations::update_community(&mut *tx, community.id, name, desc_opt, threshold)
117 .await
118 .map_err(db_error)?;
119 audit(
120 &mut tx,
121 Some(community.id),
122 ModActor::User(user.user_id),
123 ModAction::EditSettings,
124 None,
125 None,
126 None,
127 )
128 .await?;
129 commit_tx(tx).await?;
130
131 Ok(Redirect::to(&format!(
132 "/p/{slug}/settings?toast=Settings+saved"
133 )))
134 }
135
136 #[tracing::instrument(skip_all)]
137 pub(super) async fn create_category_handler(
138 axum::extract::State(state): axum::extract::State<AppState>,
139 Path(slug): Path<String>,
140 RequireUser(user): RequireUser,
141 Form(form): Form<CreateCategoryForm>,
142 ) -> Result<Redirect, Response> {
143 let community = require_owner(&state, &slug, &user).await?;
144
145 let name = validate_title(&form.name)?;
146
147 let cat_slug = form.slug.trim().to_lowercase();
148 if cat_slug.is_empty()
149 || cat_slug.len() > 128
150 || !cat_slug
151 .chars()
152 .all(|c| c.is_ascii_alphanumeric() || c == '-')
153 {
154 return Err((
155 StatusCode::UNPROCESSABLE_ENTITY,
156 "Slug must be 1-128 characters, lowercase letters/numbers/hyphens only.",
157 )
158 .into_response());
159 }
160
161 let description = form.description.trim();
162 if description.len() > 1024 {
163 return Err((
164 StatusCode::UNPROCESSABLE_ENTITY,
165 "Description must be at most 1024 characters.",
166 )
167 .into_response());
168 }
169 let desc_opt = if description.is_empty() {
170 None
171 } else {
172 Some(description)
173 };
174
175 // Put new category at the end
176 let existing = mt_db::queries::list_categories_for_settings(&state.db, community.id)
177 .await
178 .map_err(db_error)?;
179 let next_order = existing.iter().map(|c| c.sort_order).max().unwrap_or(0) + 1;
180
181 let mut tx = begin_tx(&state.db).await?;
182 mt_db::mutations::create_category(
183 &mut *tx,
184 community.id,
185 name,
186 &cat_slug,
187 desc_opt,
188 next_order,
189 )
190 .await
191 .map_err(db_error)?;
192 audit(
193 &mut tx,
194 Some(community.id),
195 ModActor::User(user.user_id),
196 ModAction::CreateCategory,
197 None,
198 None,
199 Some(name),
200 )
201 .await?;
202 commit_tx(tx).await?;
203
204 Ok(Redirect::to(&format!(
205 "/p/{slug}/settings?toast=Category+created"
206 )))
207 }
208
209 #[tracing::instrument(skip_all)]
210 pub(super) async fn edit_category_form(
211 axum::extract::State(state): axum::extract::State<AppState>,
212 Path((slug, cat_id_str)): Path<(String, String)>,
213 session: Session,
214 RequireUser(user): RequireUser,
215 ) -> Result<impl IntoResponse, Response> {
216 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
217 let community = require_owner(&state, &slug, &user).await?;
218
219 let cat_id = parse_uuid(&cat_id_str)?;
220
221 // C1: scope the load to the slug's community so an owner of A can't render
222 // community B's category edit form (mismatch → 404, same as not found).
223 let cat = mt_db::queries::get_category_in_community(&state.db, cat_id, community.id)
224 .await
225 .map_err(db_error)?
226 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
227
228 Ok(EditCategoryTemplate {
229 csrf_token,
230 session_user: Some(template_user(&user, state.config.platform_admin_id)),
231 mnw_base_url: state.config.mnw_base_url.clone(),
232 community_name: community.name,
233 community_slug: slug,
234 category_id: cat_id_str,
235 category_name: cat.name,
236 category_description: cat.description,
237 })
238 }
239
240 #[tracing::instrument(skip_all)]
241 pub(super) async fn edit_category_handler(
242 axum::extract::State(state): axum::extract::State<AppState>,
243 Path((slug, cat_id_str)): Path<(String, String)>,
244 RequireUser(user): RequireUser,
245 Form(form): Form<EditCategoryFormData>,
246 ) -> Result<Redirect, Response> {
247 let community = require_owner(&state, &slug, &user).await?;
248
249 let cat_id = parse_uuid(&cat_id_str)?;
250
251 let name = validate_title(&form.name)?;
252
253 let description = form.description.trim();
254 if description.len() > 1024 {
255 return Err((
256 StatusCode::UNPROCESSABLE_ENTITY,
257 "Description must be at most 1024 characters.",
258 )
259 .into_response());
260 }
261 let desc_opt = if description.is_empty() {
262 None
263 } else {
264 Some(description)
265 };
266
267 let mut tx = begin_tx(&state.db).await?;
268 let updated = mt_db::mutations::update_category(&mut *tx, cat_id, community.id, name, desc_opt)
269 .await
270 .map_err(db_error)?;
271 if !updated {
272 // Nothing changed (no such category in this community); the tx rolls back
273 // on drop, so no empty audit row is written.
274 return Err(StatusCode::NOT_FOUND.into_response());
275 }
276 audit(
277 &mut tx,
278 Some(community.id),
279 ModActor::User(user.user_id),
280 ModAction::EditCategory,
281 None,
282 Some(cat_id),
283 None,
284 )
285 .await?;
286 commit_tx(tx).await?;
287
288 Ok(Redirect::to(&format!(
289 "/p/{slug}/settings?toast=Category+updated"
290 )))
291 }
292
293 #[tracing::instrument(skip_all)]
294 pub(super) async fn move_category_handler(
295 axum::extract::State(state): axum::extract::State<AppState>,
296 Path((slug, cat_id_str)): Path<(String, String)>,
297 RequireUser(user): RequireUser,
298 Form(form): Form<MoveCategoryForm>,
299 ) -> Result<Redirect, Response> {
300 let community = require_owner(&state, &slug, &user).await?;
301
302 let cat_id = parse_uuid(&cat_id_str)?;
303
304 let categories = mt_db::queries::list_categories_for_settings(&state.db, community.id)
305 .await
306 .map_err(db_error)?;
307
308 let pos = categories
309 .iter()
310 .position(|c| c.id == cat_id)
311 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
312
313 let swap_pos = match form.direction.as_str() {
314 "up" if pos > 0 => pos - 1,
315 "down" if pos < categories.len() - 1 => pos + 1,
316 _ => return Ok(Redirect::to(&format!("/p/{slug}/settings"))),
317 };
318
319 mt_db::mutations::swap_category_order(
320 &state.db,
321 categories[pos].id,
322 categories[pos].sort_order,
323 categories[swap_pos].id,
324 categories[swap_pos].sort_order,
325 )
326 .await
327 .map_err(db_error)?;
328
329 Ok(Redirect::to(&format!(
330 "/p/{slug}/settings?toast=Category+moved"
331 )))
332 }
333
334 // Tag management (owner only)
335
336 #[tracing::instrument(skip_all)]
337 pub(super) async fn create_tag_handler(
338 axum::extract::State(state): axum::extract::State<AppState>,
339 Path(slug): Path<String>,
340 RequireUser(user): RequireUser,
341 Form(form): Form<CreateTagForm>,
342 ) -> Result<Redirect, Response> {
343 let community = require_owner(&state, &slug, &user).await?;
344
345 let name = validate_title(&form.name)?;
346
347 const MT_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
348 max_depth: 3,
349 max_length: 64,
350 semantic_depth: 0,
351 };
352
353 let tag_slug = form.slug.trim().to_lowercase();
354 tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| {
355 (
356 StatusCode::UNPROCESSABLE_ENTITY,
357 format!("Invalid tag slug: {e}"),
358 )
359 .into_response()
360 })?;
361
362 mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug)
363 .await
364 .map_err(db_error)?;
365
366 Ok(Redirect::to(&format!(
367 "/p/{slug}/settings?toast=Tag+created"
368 )))
369 }
370
371 #[tracing::instrument(skip_all)]
372 pub(super) async fn delete_tag_handler(
373 axum::extract::State(state): axum::extract::State<AppState>,
374 Path(slug): Path<String>,
375 RequireUser(user): RequireUser,
376 Form(form): Form<DeleteTagForm>,
377 ) -> Result<Redirect, Response> {
378 let community = require_owner(&state, &slug, &user).await?;
379
380 let tag_id = parse_uuid(&form.tag_id)?;
381
382 let deleted = mt_db::mutations::delete_tag(&state.db, tag_id, community.id)
383 .await
384 .map_err(db_error)?;
385 if !deleted {
386 return Err(StatusCode::NOT_FOUND.into_response());
387 }
388
389 Ok(Redirect::to(&format!(
390 "/p/{slug}/settings?toast=Tag+deleted"
391 )))
392 }
393
394 /// `POST /p/{slug}/settings/state`, change community moderation state.
395 ///
396 /// Authorized for community Owner, Moderator, or platform admin. Transition
397 /// to/from any state is allowed; semantics live in [`CommunityState`].
398 ///
399 /// Logged as `ModAction::ChangeCommunityState` for audit. Returns 422 for an
400 /// unknown state value (anything other than the four documented states).
401 #[tracing::instrument(skip_all)]
402 pub(super) async fn set_community_state_handler(
403 axum::extract::State(state): axum::extract::State<AppState>,
404 Path(slug): Path<String>,
405 RequireUser(user): RequireUser,
406 Form(form): Form<SetCommunityStateForm>,
407 ) -> Result<Redirect, Response> {
408 let (community, _role) = require_mod_or_superadmin(&state, &slug, &user).await?;
409
410 // A suspended community is frozen to its owner/mods: only the platform admin
411 // (who manages suspensions via `_admin`) may still change its state.
412 if community.suspended_at.is_some() && !is_platform_admin(&state, &user) {
413 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
414 }
415
416 let new_state = CommunityState::from_db(form.state.trim()).ok_or_else(|| {
417 (StatusCode::UNPROCESSABLE_ENTITY, "Unknown community state.").into_response()
418 })?;
419
420 if new_state == community.state {
421 return Ok(Redirect::to(&format!("/p/{slug}/settings?toast=No+change")));
422 }
423
424 let mut tx = begin_tx(&state.db).await?;
425 mt_db::mutations::set_community_state(&mut *tx, community.id, new_state)
426 .await
427 .map_err(db_error)?;
428 audit(
429 &mut tx,
430 Some(community.id),
431 ModActor::User(user.user_id),
432 ModAction::ChangeCommunityState,
433 None,
434 None,
435 Some(new_state.as_str()),
436 )
437 .await?;
438 commit_tx(tx).await?;
439
440 Ok(Redirect::to(&format!(
441 "/p/{slug}/settings?toast=Community+state+updated"
442 )))
443 }
444