//! Community settings handlers (owner only). use axum::{ Form, extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::AppState; use crate::auth::RequireUser; use crate::csrf; use crate::templates::{ CommunitySettingsTemplate, EditCategoryTemplate, SettingsCategoryRow, TagBadge, }; use mt_core::types::{CommunityState, ModAction, ModActor}; use super::{ CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, audit, begin_tx, commit_tx, db_error, is_platform_admin, parse_uuid, require_mod_or_superadmin, require_owner, template_user, validate_title, }; #[tracing::instrument(skip_all)] pub(super) async fn community_settings( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); // `require_owner` already 403s a suspended community. let community = require_owner(&state, &slug, &user).await?; let db_categories = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(db_error)?; let cat_count = db_categories.len(); let categories = db_categories .into_iter() .enumerate() .map(|(i, c)| SettingsCategoryRow { id: c.id.to_string(), name: c.name, slug: c.slug, description: c.description, sort_order: c.sort_order, is_first: i == 0, is_last: i == cat_count - 1, }) .collect(); let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(db_error)?; let tags = db_tags .into_iter() .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug, }) .collect(); Ok(CommunitySettingsTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, community_description: community.description, auto_hide_threshold: community.auto_hide_threshold, categories, tags, }) } #[tracing::instrument(skip_all)] pub(super) async fn update_community_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; let description = form.description.trim(); if description.len() > 2048 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 2048 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; // Parse auto_hide_threshold: empty or "0" = disabled (None), otherwise positive integer let threshold = form .auto_hide_threshold .as_deref() .and_then(|s| s.trim().parse::().ok()) .filter(|&n| n > 0); let mut tx = begin_tx(&state.db).await?; mt_db::mutations::update_community(&mut *tx, community.id, name, desc_opt, threshold) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::EditSettings, None, None, None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Settings+saved" ))) } #[tracing::instrument(skip_all)] pub(super) async fn create_category_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; let cat_slug = form.slug.trim().to_lowercase(); if cat_slug.is_empty() || cat_slug.len() > 128 || !cat_slug .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Slug must be 1-128 characters, lowercase letters/numbers/hyphens only.", ) .into_response()); } let description = form.description.trim(); if description.len() > 1024 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 1024 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; // Put new category at the end let existing = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(db_error)?; let next_order = existing.iter().map(|c| c.sort_order).max().unwrap_or(0) + 1; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::create_category( &mut *tx, community.id, name, &cat_slug, desc_opt, next_order, ) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::CreateCategory, None, None, Some(name), ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+created" ))) } #[tracing::instrument(skip_all)] pub(super) async fn edit_category_form( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, session: Session, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; // C1: scope the load to the slug's community so an owner of A can't render // community B's category edit form (mismatch → 404, same as not found). let cat = mt_db::queries::get_category_in_community(&state.db, cat_id, community.id) .await .map_err(db_error)? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; Ok(EditCategoryTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, category_id: cat_id_str, category_name: cat.name, category_description: cat.description, }) } #[tracing::instrument(skip_all)] pub(super) async fn edit_category_handler( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; let name = validate_title(&form.name)?; let description = form.description.trim(); if description.len() > 1024 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 1024 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; let mut tx = begin_tx(&state.db).await?; let updated = mt_db::mutations::update_category(&mut *tx, cat_id, community.id, name, desc_opt) .await .map_err(db_error)?; if !updated { // Nothing changed (no such category in this community); the tx rolls back // on drop, so no empty audit row is written. return Err(StatusCode::NOT_FOUND.into_response()); } audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::EditCategory, None, Some(cat_id), None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+updated" ))) } #[tracing::instrument(skip_all)] pub(super) async fn move_category_handler( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; let categories = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(db_error)?; let pos = categories .iter() .position(|c| c.id == cat_id) .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let swap_pos = match form.direction.as_str() { "up" if pos > 0 => pos - 1, "down" if pos < categories.len() - 1 => pos + 1, _ => return Ok(Redirect::to(&format!("/p/{slug}/settings"))), }; mt_db::mutations::swap_category_order( &state.db, categories[pos].id, categories[pos].sort_order, categories[swap_pos].id, categories[swap_pos].sort_order, ) .await .map_err(db_error)?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+moved" ))) } // Tag management (owner only) #[tracing::instrument(skip_all)] pub(super) async fn create_tag_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; const MT_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { max_depth: 3, max_length: 64, semantic_depth: 0, }; let tag_slug = form.slug.trim().to_lowercase(); tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| { ( StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid tag slug: {e}"), ) .into_response() })?; mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug) .await .map_err(db_error)?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Tag+created" ))) } #[tracing::instrument(skip_all)] pub(super) async fn delete_tag_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = require_owner(&state, &slug, &user).await?; let tag_id = parse_uuid(&form.tag_id)?; let deleted = mt_db::mutations::delete_tag(&state.db, tag_id, community.id) .await .map_err(db_error)?; if !deleted { return Err(StatusCode::NOT_FOUND.into_response()); } Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Tag+deleted" ))) } /// `POST /p/{slug}/settings/state`, change community moderation state. /// /// Authorized for community Owner, Moderator, or platform admin. Transition /// to/from any state is allowed; semantics live in [`CommunityState`]. /// /// Logged as `ModAction::ChangeCommunityState` for audit. Returns 422 for an /// unknown state value (anything other than the four documented states). #[tracing::instrument(skip_all)] pub(super) async fn set_community_state_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (community, _role) = require_mod_or_superadmin(&state, &slug, &user).await?; // A suspended community is frozen to its owner/mods: only the platform admin // (who manages suspensions via `_admin`) may still change its state. if community.suspended_at.is_some() && !is_platform_admin(&state, &user) { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } let new_state = CommunityState::from_db(form.state.trim()).ok_or_else(|| { (StatusCode::UNPROCESSABLE_ENTITY, "Unknown community state.").into_response() })?; if new_state == community.state { return Ok(Redirect::to(&format!("/p/{slug}/settings?toast=No+change"))); } let mut tx = begin_tx(&state.db).await?; mt_db::mutations::set_community_state(&mut *tx, community.id, new_state) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::ChangeCommunityState, None, None, Some(new_state.as_str()), ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Community+state+updated" ))) }