Skip to main content

max / multithreaded

12.6 KB · 387 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::ModAction;
17
18 use super::{
19 log_mod_action, parse_uuid, require_owner, template_user, validate_title,
20 CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm,
21 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 mt_db::mutations::update_category(&state.db, cat_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
263 log_mod_action(
264 &state.db, Some(community.id), user.user_id,
265 ModAction::EditCategory, None, Some(cat_id), None,
266 ).await;
267
268 Ok(Redirect::to(&format!(
269 "/p/{slug}/settings?toast=Category+updated"
270 )))
271 }
272
273 #[tracing::instrument(skip_all)]
274 pub(super) async fn move_category_handler(
275 axum::extract::State(state): axum::extract::State<AppState>,
276 Path((slug, cat_id_str)): Path<(String, String)>,
277 MaybeUser(session_user): MaybeUser,
278 Form(form): Form<MoveCategoryForm>,
279 ) -> Result<Redirect, Response> {
280 let user = session_user
281 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
282
283 let community = require_owner(&state, &slug, &user).await?;
284
285 let cat_id = parse_uuid(&cat_id_str)?;
286
287 let categories = mt_db::queries::list_categories_for_settings(&state.db, community.id)
288 .await
289 .map_err(|e| {
290 tracing::error!(error = ?e, "db error listing categories");
291 StatusCode::INTERNAL_SERVER_ERROR.into_response()
292 })?;
293
294 let pos = categories.iter().position(|c| c.id == cat_id)
295 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
296
297 let swap_pos = match form.direction.as_str() {
298 "up" if pos > 0 => pos - 1,
299 "down" if pos < categories.len() - 1 => pos + 1,
300 _ => return Ok(Redirect::to(&format!("/p/{slug}/settings"))),
301 };
302
303 mt_db::mutations::swap_category_order(
304 &state.db,
305 categories[pos].id,
306 categories[pos].sort_order,
307 categories[swap_pos].id,
308 categories[swap_pos].sort_order,
309 )
310 .await
311 .map_err(|e| {
312 tracing::error!(error = ?e, "db error swapping category order");
313 StatusCode::INTERNAL_SERVER_ERROR.into_response()
314 })?;
315
316 Ok(Redirect::to(&format!(
317 "/p/{slug}/settings?toast=Category+moved"
318 )))
319 }
320
321 // ============================================================================
322 // Tag management (owner only)
323 // ============================================================================
324
325 #[tracing::instrument(skip_all)]
326 pub(super) async fn create_tag_handler(
327 axum::extract::State(state): axum::extract::State<AppState>,
328 Path(slug): Path<String>,
329 MaybeUser(session_user): MaybeUser,
330 Form(form): Form<CreateTagForm>,
331 ) -> Result<Redirect, Response> {
332 let user = session_user
333 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
334
335 let community = require_owner(&state, &slug, &user).await?;
336
337 let name = validate_title(&form.name)?;
338
339 const MT_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
340 max_depth: 3,
341 max_length: 64,
342 semantic_depth: 0,
343 };
344
345 let tag_slug = form.slug.trim().to_lowercase();
346 tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| {
347 (StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid tag slug: {}", e.0)).into_response()
348 })?;
349
350 mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug)
351 .await
352 .map_err(|e| {
353 tracing::error!(error = ?e, "db error creating tag");
354 StatusCode::INTERNAL_SERVER_ERROR.into_response()
355 })?;
356
357 Ok(Redirect::to(&format!(
358 "/p/{slug}/settings?toast=Tag+created"
359 )))
360 }
361
362 #[tracing::instrument(skip_all)]
363 pub(super) async fn delete_tag_handler(
364 axum::extract::State(state): axum::extract::State<AppState>,
365 Path(slug): Path<String>,
366 MaybeUser(session_user): MaybeUser,
367 Form(form): Form<DeleteTagForm>,
368 ) -> Result<Redirect, Response> {
369 let user = session_user
370 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
371
372 let _community = require_owner(&state, &slug, &user).await?;
373
374 let tag_id = parse_uuid(&form.tag_id)?;
375
376 mt_db::mutations::delete_tag(&state.db, tag_id)
377 .await
378 .map_err(|e| {
379 tracing::error!(error = ?e, "db error deleting tag");
380 StatusCode::INTERNAL_SERVER_ERROR.into_response()
381 })?;
382
383 Ok(Redirect::to(&format!(
384 "/p/{slug}/settings?toast=Tag+deleted"
385 )))
386 }
387