//! Community settings handlers (owner only). use axum::{ Form, extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use livechat::ChatRooms; use crate::AppState; use crate::auth::RequireUser; use crate::chat::{moderation::MtChatModeration, rooms::MtChatRooms}; use crate::csrf; use crate::templates::{ CommunitySettingsTemplate, EditCategoryTemplate, SettingsCategoryRow, TagBadge, }; use mt_core::types::{ChatPolicy, CommunityState, ModAction, ModActor}; use super::{ ChatSettingsForm, CleanSlateForm, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, audit, begin_tx, commit_tx, db_error, field_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(); // The room's own row rather than the community's, because policy and // retention live behind the same dedicated query the chat routes use. Absent // only if the community vanished between `require_owner` and here, in which // case the defaults are as good an answer as any and better than a 500. let chat = mt_db::queries::get_chat_room_by_slug(&state.db, &slug) .await .map_err(db_error)?; 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, chat_policy: chat.as_ref().map_or(ChatPolicy::Off, |c| c.policy).as_str(), chat_policies: ChatPolicy::ALL, chat_retention_hours: chat.as_ref().map_or(168, |c| c.retention_hours), chat_max_messages: chat.as_ref().map_or(5_000, |c| c.max_messages), chat_max_retention_hours: MAX_RETENTION_HOURS, chat_message_ceiling: livechat::MAX_MESSAGES_CEILING, categories, tags, }) } /// The age ceiling as the form needs it. /// /// `MAX_AGE_CEILING` is a `Duration` because that is what `Retention` works in; /// the column and the input are hours. Converting once here keeps the number in /// one place: raising the crate's ceiling raises the form's `max` with it, and /// nothing has to remember that 720 was ever written down. #[expect( clippy::cast_possible_truncation, reason = "a ceiling in hours cannot approach i32::MAX" )] const MAX_RETENTION_HOURS: i32 = (livechat::MAX_AGE_CEILING.as_secs() / 3_600) as i32; /// Save the chat policy and retention window (owner only). /// /// Separate from [`update_community_handler`] because the two are separate forms /// on the page; one handler taking both would make each save silently rewrite /// the other's fields with whatever the browser last rendered. #[tracing::instrument(skip_all)] pub(super) async fn chat_settings_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 policy = ChatPolicy::from_db(form.chat_policy.trim()) .ok_or_else(|| field_error("chat_policy", "Unknown chat policy."))?; // Validated here against the crate's ceilings rather than left to migration // 039's CHECKs. The CHECKs are the backstop for a call site that forgot; // reaching them gives the owner a 500 where they should get a sentence // telling them which bound they missed. let retention_hours = bounded( &form.retention_hours, MAX_RETENTION_HOURS, "retention_hours", "retention window", )?; let max_messages = bounded( &form.max_messages, i32::try_from(livechat::MAX_MESSAGES_CEILING).unwrap_or(i32::MAX), "max_messages", "message cap", )?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::update_chat_settings( &mut *tx, community.id, policy.as_str(), retention_hours, max_messages, ) .await .map_err(db_error)?; audit( &mut tx, Some(community.id), ModActor::User(user.user_id), ModAction::EditSettings, None, None, Some(&format!( "chat: {}, {retention_hours}h, {max_messages} messages", policy.as_str() )), ) .await?; commit_tx(tx).await?; // Expiry is stamped on the row at insert, which is what makes the sweep one // indexed delete. The cost of that choice is exactly this call: without it a // window cut from 30 days to 1 would apply only to messages sent afterwards, // and the owner who just shortened retention would still be holding 30 days // of chat. // // After the commit rather than inside it: restamping walks every message in // the room, and holding the settings transaction open for it would block the // room's sends on an administrative action. A failure here leaves the new // policy saved and the old expiry stamps in place, which the next save // corrects and the sweep tolerates. if let Err(e) = mt_db::mutations::recompute_chat_expiry(&state.db, community.id, retention_hours).await { tracing::error!(error = ?e, community = %community.id, "chat expiry restamp failed"); } Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Chat+settings+saved" ))) } /// Parse one positive bound from the form, refusing anything past the ceiling. /// /// Reported through `field_error` rather than a bare 422 so the message lands /// inline next to the input that was wrong, which is what the rest of the app's /// forms do and what a screen with three of them needs. #[allow(clippy::result_large_err)] fn bounded(raw: &str, ceiling: i32, field: &'static str, what: &str) -> Result { let value: i32 = raw .trim() .parse() .map_err(|_| field_error(field, format!("The {what} must be a whole number.")))?; if value < 1 || value > ceiling { return Err(field_error( field, format!("The {what} must be between 1 and {ceiling}."), )); } Ok(value) } /// Empty the room now (owner only). /// /// Not gated on the room being open. An owner who has just turned chat off is /// the likeliest person to want the backlog gone, and refusing them would mean /// switching chat back on to clear it. #[tracing::instrument(skip_all)] pub(super) async fn wipe_chat_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { require_owner(&state, &slug, &user).await?; // Typed-phrase confirmation, the same check and the same form the admin // clean slate uses. Trim only; case is significant. Checked here rather // than in the browser because a client-side dialog is a suggestion. if form.confirm.trim() != slug { return Err(field_error( "confirm", "Confirmation phrase did not match the community slug.", )); } let room = MtChatRooms::new(state.db.clone()) .resolve(&slug) .await .map_err(|e| { tracing::error!(error = ?e, "chat room resolve failed during wipe"); crate::error_page::internal_error() })? .ok_or_else(crate::error_page::not_found)?; let removed = state .chat .wipe_room( &MtChatModeration::for_owner(state.db.clone()), livechat::UserId(user.user_id), &room, ) .await .map_err(|e| { tracing::error!(error = ?e, "chat wipe failed"); crate::error_page::internal_error() })?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast={removed}+chat+messages+deleted" ))) } #[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" ))) }