Skip to main content

max / makenotwork

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