max / makenotwork
16 files changed,
+1363 insertions,
-25 deletions
| @@ -58,6 +58,78 @@ impl fmt::Display for CommunityRole { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | // ============================================================================ | |
| 61 | + | // CommunityState — moderation state machine | |
| 62 | + | // ============================================================================ | |
| 63 | + | ||
| 64 | + | /// Community-level moderation state. Distinct from platform suspension | |
| 65 | + | /// (`communities.suspended_at`); this is what owners/mods control. | |
| 66 | + | /// | |
| 67 | + | /// - `Active`: normal operation. | |
| 68 | + | /// - `Restricted`: only mods/owners can start new threads. Existing threads | |
| 69 | + | /// still accept replies. | |
| 70 | + | /// - `Frozen`: read-only for everyone except mods performing mod actions. | |
| 71 | + | /// - `Archived`: behaves like `Frozen` and is hidden from default community | |
| 72 | + | /// listings (surfaces under an explicit archived filter). Reactivation = | |
| 73 | + | /// transition back to `Active`. | |
| 74 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] | |
| 75 | + | #[sqlx(type_name = "TEXT", rename_all = "lowercase")] | |
| 76 | + | pub enum CommunityState { | |
| 77 | + | Active, | |
| 78 | + | Restricted, | |
| 79 | + | Frozen, | |
| 80 | + | Archived, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | impl Serialize for CommunityState { | |
| 84 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 85 | + | serializer.serialize_str(self.as_str()) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl CommunityState { | |
| 90 | + | pub fn from_db(s: &str) -> Option<Self> { | |
| 91 | + | match s { | |
| 92 | + | "active" => Some(Self::Active), | |
| 93 | + | "restricted" => Some(Self::Restricted), | |
| 94 | + | "frozen" => Some(Self::Frozen), | |
| 95 | + | "archived" => Some(Self::Archived), | |
| 96 | + | _ => None, | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | pub fn as_str(self) -> &'static str { | |
| 101 | + | match self { | |
| 102 | + | Self::Active => "active", | |
| 103 | + | Self::Restricted => "restricted", | |
| 104 | + | Self::Frozen => "frozen", | |
| 105 | + | Self::Archived => "archived", | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Hidden from default community listings (still reachable by direct URL). | |
| 110 | + | pub fn is_hidden_from_default_listing(self) -> bool { | |
| 111 | + | matches!(self, Self::Archived) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Whether a non-mod is allowed to start a new thread in this state. | |
| 115 | + | pub fn allows_new_threads_for_members(self) -> bool { | |
| 116 | + | matches!(self, Self::Active) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Whether a non-mod is allowed to reply / edit / otherwise write in | |
| 120 | + | /// this state. | |
| 121 | + | pub fn allows_writes_for_members(self) -> bool { | |
| 122 | + | matches!(self, Self::Active | Self::Restricted) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | impl fmt::Display for CommunityState { | |
| 127 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 128 | + | f.write_str(self.as_str()) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // ============================================================================ | |
| 61 | 133 | // BanType — ban or mute | |
| 62 | 134 | // ============================================================================ | |
| 63 | 135 | ||
| @@ -112,6 +184,8 @@ pub enum ModAction { | |||
| 112 | 184 | AutoHidePost, | |
| 113 | 185 | RemovePostViaFlag, | |
| 114 | 186 | RemoveImage, | |
| 187 | + | ChangeCommunityState, | |
| 188 | + | CleanSlateCommunity, | |
| 115 | 189 | } | |
| 116 | 190 | ||
| 117 | 191 | impl ModAction { | |
| @@ -137,6 +211,8 @@ impl ModAction { | |||
| 137 | 211 | Self::AutoHidePost => "auto_hide_post", | |
| 138 | 212 | Self::RemovePostViaFlag => "remove_post_via_flag", | |
| 139 | 213 | Self::RemoveImage => "remove_image", | |
| 214 | + | Self::ChangeCommunityState => "change_community_state", | |
| 215 | + | Self::CleanSlateCommunity => "clean_slate_community", | |
| 140 | 216 | } | |
| 141 | 217 | } | |
| 142 | 218 | } | |
| @@ -249,6 +325,38 @@ mod tests { | |||
| 249 | 325 | } | |
| 250 | 326 | ||
| 251 | 327 | #[test] | |
| 328 | + | fn community_state_roundtrip() { | |
| 329 | + | for s in [ | |
| 330 | + | CommunityState::Active, | |
| 331 | + | CommunityState::Restricted, | |
| 332 | + | CommunityState::Frozen, | |
| 333 | + | CommunityState::Archived, | |
| 334 | + | ] { | |
| 335 | + | assert_eq!(CommunityState::from_db(s.as_str()), Some(s)); | |
| 336 | + | } | |
| 337 | + | assert_eq!(CommunityState::from_db("bogus"), None); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn community_state_predicates() { | |
| 342 | + | assert!(CommunityState::Active.allows_new_threads_for_members()); | |
| 343 | + | assert!(CommunityState::Active.allows_writes_for_members()); | |
| 344 | + | ||
| 345 | + | // Restricted blocks new threads but allows replies. | |
| 346 | + | assert!(!CommunityState::Restricted.allows_new_threads_for_members()); | |
| 347 | + | assert!(CommunityState::Restricted.allows_writes_for_members()); | |
| 348 | + | ||
| 349 | + | // Frozen blocks all member writes. | |
| 350 | + | assert!(!CommunityState::Frozen.allows_new_threads_for_members()); | |
| 351 | + | assert!(!CommunityState::Frozen.allows_writes_for_members()); | |
| 352 | + | ||
| 353 | + | // Archived blocks writes and hides from default listings. | |
| 354 | + | assert!(!CommunityState::Archived.allows_writes_for_members()); | |
| 355 | + | assert!(CommunityState::Archived.is_hidden_from_default_listing()); | |
| 356 | + | assert!(!CommunityState::Active.is_hidden_from_default_listing()); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 252 | 360 | fn mod_action_display() { | |
| 253 | 361 | assert_eq!(ModAction::PinThread.as_str(), "pin_thread"); | |
| 254 | 362 | assert_eq!(ModAction::Ban.as_str(), "ban"); |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Database write mutations — inserts, updates, deletes. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | - | use mt_core::types::{BanType, ModAction}; | |
| 4 | + | use mt_core::types::{BanType, CommunityState, ModAction}; | |
| 5 | 5 | use sqlx::PgPool; | |
| 6 | 6 | use uuid::Uuid; | |
| 7 | 7 | ||
| @@ -536,6 +536,132 @@ pub async fn unsuspend_community( | |||
| 536 | 536 | Ok(()) | |
| 537 | 537 | } | |
| 538 | 538 | ||
| 539 | + | /// Set the community moderation state. See [`CommunityState`] for semantics. | |
| 540 | + | #[tracing::instrument(skip_all)] | |
| 541 | + | pub async fn set_community_state( | |
| 542 | + | pool: &PgPool, | |
| 543 | + | community_id: Uuid, | |
| 544 | + | state: CommunityState, | |
| 545 | + | ) -> Result<(), sqlx::Error> { | |
| 546 | + | sqlx::query("UPDATE communities SET state = $2 WHERE id = $1") | |
| 547 | + | .bind(community_id) | |
| 548 | + | .bind(state.as_str()) | |
| 549 | + | .execute(pool) | |
| 550 | + | .await?; | |
| 551 | + | Ok(()) | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | /// Result of a clean-slate operation. | |
| 555 | + | pub struct CleanSlateResult { | |
| 556 | + | /// Number of threads deleted (excluding the system reset thread that's | |
| 557 | + | /// then inserted). Useful for the success toast. | |
| 558 | + | pub deleted_thread_count: i64, | |
| 559 | + | /// ID of the system "Community reset" thread created in the first | |
| 560 | + | /// category. `None` if the community has no categories — clean-slate | |
| 561 | + | /// still deletes threads but has nowhere to post the notice. | |
| 562 | + | pub system_thread_id: Option<Uuid>, | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | /// Clean-slate a community: delete all threads (and the posts / footnotes / | |
| 566 | + | /// endorsements / flags / read-positions that cascade from them) while | |
| 567 | + | /// preserving the community row, categories, memberships, bans, mutes, and | |
| 568 | + | /// tags. Posts a system thread "Community reset by <actor> on <date>" | |
| 569 | + | /// in the first category by `sort_order`. | |
| 570 | + | /// | |
| 571 | + | /// Authorization is the caller's responsibility (see `routes/admin.rs`); this | |
| 572 | + | /// mutation only enforces atomicity. | |
| 573 | + | #[tracing::instrument(skip_all)] | |
| 574 | + | pub async fn clean_slate_community( | |
| 575 | + | pool: &PgPool, | |
| 576 | + | community_id: Uuid, | |
| 577 | + | actor_id: Uuid, | |
| 578 | + | actor_display: &str, | |
| 579 | + | ) -> Result<CleanSlateResult, sqlx::Error> { | |
| 580 | + | let mut tx = pool.begin().await?; | |
| 581 | + | ||
| 582 | + | // Delete every thread whose category belongs to this community. Cascades | |
| 583 | + | // reap posts, footnotes, endorsements, flags, read-positions, link | |
| 584 | + | // previews, mentions, and tag joins. | |
| 585 | + | let deleted: i64 = sqlx::query_scalar( | |
| 586 | + | "WITH d AS ( | |
| 587 | + | DELETE FROM threads | |
| 588 | + | WHERE category_id IN (SELECT id FROM categories WHERE community_id = $1) | |
| 589 | + | RETURNING 1 | |
| 590 | + | ) | |
| 591 | + | SELECT COUNT(*) FROM d", | |
| 592 | + | ) | |
| 593 | + | .bind(community_id) | |
| 594 | + | .fetch_one(&mut *tx) | |
| 595 | + | .await?; | |
| 596 | + | ||
| 597 | + | // Pick the first category by sort_order to host the reset notice. None | |
| 598 | + | // means the community has no categories — nothing to post into. | |
| 599 | + | let first_category: Option<Uuid> = sqlx::query_scalar( | |
| 600 | + | "SELECT id FROM categories | |
| 601 | + | WHERE community_id = $1 | |
| 602 | + | ORDER BY sort_order | |
| 603 | + | LIMIT 1", | |
| 604 | + | ) | |
| 605 | + | .bind(community_id) | |
| 606 | + | .fetch_optional(&mut *tx) | |
| 607 | + | .await?; | |
| 608 | + | ||
| 609 | + | let system_thread_id = if let Some(cat_id) = first_category { | |
| 610 | + | let now = Utc::now(); | |
| 611 | + | let date = now.format("%Y-%m-%d").to_string(); | |
| 612 | + | let title = format!("Community reset by {actor_display} on {date}"); | |
| 613 | + | let body_md = format!( | |
| 614 | + | "This community was reset by **{actor_display}** on {date}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.", | |
| 615 | + | ); | |
| 616 | + | let body_html = format!( | |
| 617 | + | "<p>This community was reset by <strong>{}</strong> on {}. All previous threads have been cleared. Settings, categories, members, and bans are preserved.</p>", | |
| 618 | + | html_escape(actor_display), | |
| 619 | + | date, | |
| 620 | + | ); | |
| 621 | + | ||
| 622 | + | let thread_id: (Uuid,) = sqlx::query_as( | |
| 623 | + | "INSERT INTO threads (category_id, author_id, title, pinned, locked) | |
| 624 | + | VALUES ($1, $2, $3, TRUE, TRUE) | |
| 625 | + | RETURNING id", | |
| 626 | + | ) | |
| 627 | + | .bind(cat_id) | |
| 628 | + | .bind(actor_id) | |
| 629 | + | .bind(&title) | |
| 630 | + | .fetch_one(&mut *tx) | |
| 631 | + | .await?; | |
| 632 | + | ||
| 633 | + | sqlx::query( | |
| 634 | + | "INSERT INTO posts (thread_id, author_id, body_markdown, body_html) | |
| 635 | + | VALUES ($1, $2, $3, $4)", | |
| 636 | + | ) | |
| 637 | + | .bind(thread_id.0) | |
| 638 | + | .bind(actor_id) | |
| 639 | + | .bind(&body_md) | |
| 640 | + | .bind(&body_html) | |
| 641 | + | .execute(&mut *tx) | |
| 642 | + | .await?; | |
| 643 | + | ||
| 644 | + | Some(thread_id.0) | |
| 645 | + | } else { | |
| 646 | + | None | |
| 647 | + | }; | |
| 648 | + | ||
| 649 | + | tx.commit().await?; | |
| 650 | + | Ok(CleanSlateResult { deleted_thread_count: deleted, system_thread_id }) | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | /// Minimal HTML-escape for actor display names embedded in the reset notice. | |
| 654 | + | /// We render the notice as a literal HTML string (skipping the markdown | |
| 655 | + | /// pipeline) so we don't have to thread renderer config into the mutation. | |
| 656 | + | fn html_escape(input: &str) -> String { | |
| 657 | + | input | |
| 658 | + | .replace('&', "&") | |
| 659 | + | .replace('<', "<") | |
| 660 | + | .replace('>', ">") | |
| 661 | + | .replace('"', """) | |
| 662 | + | .replace('\'', "'") | |
| 663 | + | } | |
| 664 | + | ||
| 539 | 665 | /// Suspend a user. | |
| 540 | 666 | #[tracing::instrument(skip_all)] | |
| 541 | 667 | pub async fn suspend_user( |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Database read queries — projection structs and SQL. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | - | use mt_core::types::{BanType, CommunityRole, ModAction}; | |
| 4 | + | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction}; | |
| 5 | 5 | use sqlx::PgPool; | |
| 6 | 6 | use uuid::Uuid; | |
| 7 | 7 | ||
| @@ -17,6 +17,7 @@ pub struct CommunityRow { | |||
| 17 | 17 | pub description: Option<String>, | |
| 18 | 18 | pub suspended_at: Option<DateTime<Utc>>, | |
| 19 | 19 | pub auto_hide_threshold: Option<i32>, | |
| 20 | + | pub state: CommunityState, | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | #[derive(sqlx::FromRow)] | |
| @@ -167,7 +168,10 @@ pub struct CommunityListRow { | |||
| 167 | 168 | pub thread_count: i64, | |
| 168 | 169 | } | |
| 169 | 170 | ||
| 170 | - | /// List non-suspended communities with category and thread counts (paginated). | |
| 171 | + | /// List non-suspended, non-archived communities with category and thread counts (paginated). | |
| 172 | + | /// | |
| 173 | + | /// Archived communities are hidden from the default listing — see | |
| 174 | + | /// [`list_archived_communities`] for the explicit archived view. | |
| 171 | 175 | #[tracing::instrument(skip_all)] | |
| 172 | 176 | pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 173 | 177 | sqlx::query_as::<_, CommunityListRow>( | |
| @@ -178,6 +182,7 @@ pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result< | |||
| 178 | 182 | LEFT JOIN categories c ON c.community_id = co.id | |
| 179 | 183 | LEFT JOIN threads t ON t.category_id = c.id | |
| 180 | 184 | WHERE co.suspended_at IS NULL | |
| 185 | + | AND co.state <> 'archived' | |
| 181 | 186 | GROUP BY co.id | |
| 182 | 187 | ORDER BY co.name | |
| 183 | 188 | LIMIT $1 OFFSET $2", | |
| @@ -188,12 +193,50 @@ pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result< | |||
| 188 | 193 | .await | |
| 189 | 194 | } | |
| 190 | 195 | ||
| 191 | - | /// Count non-suspended communities. | |
| 196 | + | /// Count non-suspended, non-archived communities. | |
| 192 | 197 | #[tracing::instrument(skip_all)] | |
| 193 | 198 | pub async fn count_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 194 | - | sqlx::query_scalar("SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL") | |
| 195 | - | .fetch_one(pool) | |
| 196 | - | .await | |
| 199 | + | sqlx::query_scalar( | |
| 200 | + | "SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL AND state <> 'archived'", | |
| 201 | + | ) | |
| 202 | + | .fetch_one(pool) | |
| 203 | + | .await | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// List archived communities. Used by the explicit `?filter=archived` view; never | |
| 207 | + | /// merged with the default listing. | |
| 208 | + | #[tracing::instrument(skip_all)] | |
| 209 | + | pub async fn list_archived_communities( | |
| 210 | + | pool: &PgPool, | |
| 211 | + | limit: i64, | |
| 212 | + | offset: i64, | |
| 213 | + | ) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 214 | + | sqlx::query_as::<_, CommunityListRow>( | |
| 215 | + | "SELECT co.name, co.slug, co.description, | |
| 216 | + | COUNT(DISTINCT c.id) AS category_count, | |
| 217 | + | COUNT(DISTINCT t.id) AS thread_count | |
| 218 | + | FROM communities co | |
| 219 | + | LEFT JOIN categories c ON c.community_id = co.id | |
| 220 | + | LEFT JOIN threads t ON t.category_id = c.id | |
| 221 | + | WHERE co.suspended_at IS NULL | |
| 222 | + | AND co.state = 'archived' | |
| 223 | + | GROUP BY co.id | |
| 224 | + | ORDER BY co.name | |
| 225 | + | LIMIT $1 OFFSET $2", | |
| 226 | + | ) | |
| 227 | + | .bind(limit) | |
| 228 | + | .bind(offset) | |
| 229 | + | .fetch_all(pool) | |
| 230 | + | .await | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[tracing::instrument(skip_all)] | |
| 234 | + | pub async fn count_archived_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 235 | + | sqlx::query_scalar( | |
| 236 | + | "SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL AND state = 'archived'", | |
| 237 | + | ) | |
| 238 | + | .fetch_one(pool) | |
| 239 | + | .await | |
| 197 | 240 | } | |
| 198 | 241 | ||
| 199 | 242 | #[tracing::instrument(skip_all)] | |
| @@ -202,7 +245,8 @@ pub async fn get_community_by_slug( | |||
| 202 | 245 | slug: &str, | |
| 203 | 246 | ) -> Result<Option<CommunityRow>, sqlx::Error> { | |
| 204 | 247 | sqlx::query_as::<_, CommunityRow>( | |
| 205 | - | "SELECT id, name, slug, description, suspended_at, auto_hide_threshold FROM communities WHERE slug = $1", | |
| 248 | + | "SELECT id, name, slug, description, suspended_at, auto_hide_threshold, state | |
| 249 | + | FROM communities WHERE slug = $1", | |
| 206 | 250 | ) | |
| 207 | 251 | .bind(slug) | |
| 208 | 252 | .fetch_optional(pool) |
| @@ -0,0 +1,23 @@ | |||
| 1 | + | -- Community moderation state machine. | |
| 2 | + | -- | |
| 3 | + | -- Distinct from `suspended_at` (platform-admin action). This is community-level | |
| 4 | + | -- moderation by owners/mods/superadmin. | |
| 5 | + | -- | |
| 6 | + | -- States: | |
| 7 | + | -- active — normal operation (default) | |
| 8 | + | -- restricted — block new thread creation for non-mods (existing threads still accept replies) | |
| 9 | + | -- frozen — read-only for everyone except mods doing mod actions | |
| 10 | + | -- archived — frozen + hidden from default community listings; surfaces under | |
| 11 | + | -- an explicit archived filter; reactivation = set back to active | |
| 12 | + | -- | |
| 13 | + | -- Authorization for transitions: community Owner/Moderator OR platform admin. | |
| 14 | + | ||
| 15 | + | ALTER TABLE communities | |
| 16 | + | ADD COLUMN state TEXT NOT NULL DEFAULT 'active' | |
| 17 | + | CHECK (state IN ('active', 'restricted', 'frozen', 'archived')); | |
| 18 | + | ||
| 19 | + | -- Partial index for the archived filter view — most communities are active, so | |
| 20 | + | -- a partial index keeps the listing query cheap. | |
| 21 | + | CREATE INDEX IF NOT EXISTS idx_communities_archived | |
| 22 | + | ON communities (name) | |
| 23 | + | WHERE state = 'archived'; |
| @@ -15,7 +15,10 @@ use crate::AppState; | |||
| 15 | 15 | ||
| 16 | 16 | use mt_core::types::ModAction; | |
| 17 | 17 | ||
| 18 | - | use super::{log_mod_action, parse_uuid, AdminSearchQuery, SuspendForm}; | |
| 18 | + | use super::{ | |
| 19 | + | get_community, log_mod_action, parse_uuid, template_user, AdminSearchQuery, | |
| 20 | + | CleanSlateForm, SuspendForm, | |
| 21 | + | }; | |
| 19 | 22 | ||
| 20 | 23 | #[tracing::instrument(skip_all)] | |
| 21 | 24 | pub(super) async fn admin_dashboard( | |
| @@ -171,3 +174,109 @@ pub(super) async fn unsuspend_user_handler( | |||
| 171 | 174 | ||
| 172 | 175 | Ok(Redirect::to("/_admin?toast=User+unsuspended")) | |
| 173 | 176 | } | |
| 177 | + | ||
| 178 | + | // ============================================================================ | |
| 179 | + | // Dedicated admin view per community: state machine + clean-slate. | |
| 180 | + | // ============================================================================ | |
| 181 | + | ||
| 182 | + | #[tracing::instrument(skip_all)] | |
| 183 | + | pub(super) async fn admin_community_detail( | |
| 184 | + | axum::extract::State(state): axum::extract::State<AppState>, | |
| 185 | + | session: Session, | |
| 186 | + | PlatformAdmin(admin): PlatformAdmin, | |
| 187 | + | Path(slug): Path<String>, | |
| 188 | + | ) -> Result<AdminCommunityTemplate, Response> { | |
| 189 | + | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 190 | + | let community = get_community(&state.db, &slug).await?; | |
| 191 | + | ||
| 192 | + | let thread_count: i64 = sqlx::query_scalar( | |
| 193 | + | "SELECT COUNT(*) FROM threads t | |
| 194 | + | JOIN categories c ON c.id = t.category_id | |
| 195 | + | WHERE c.community_id = $1", | |
| 196 | + | ) | |
| 197 | + | .bind(community.id) | |
| 198 | + | .fetch_one(&state.db) | |
| 199 | + | .await | |
| 200 | + | .map_err(|e| { | |
| 201 | + | tracing::error!(error = ?e, "db error counting threads"); | |
| 202 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 203 | + | })?; | |
| 204 | + | ||
| 205 | + | let member_count = mt_db::queries::count_community_members(&state.db, community.id) | |
| 206 | + | .await | |
| 207 | + | .map_err(|e| { | |
| 208 | + | tracing::error!(error = ?e, "db error counting members"); | |
| 209 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 210 | + | })?; | |
| 211 | + | ||
| 212 | + | let suspension_reason: Option<String> = if community.suspended_at.is_some() { | |
| 213 | + | sqlx::query_scalar("SELECT suspension_reason FROM communities WHERE id = $1") | |
| 214 | + | .bind(community.id) | |
| 215 | + | .fetch_one(&state.db) | |
| 216 | + | .await | |
| 217 | + | .ok() | |
| 218 | + | .flatten() | |
| 219 | + | } else { | |
| 220 | + | None | |
| 221 | + | }; | |
| 222 | + | ||
| 223 | + | Ok(AdminCommunityTemplate { | |
| 224 | + | csrf_token, | |
| 225 | + | session_user: Some(template_user(&admin, state.config.platform_admin_id)), | |
| 226 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 227 | + | community_name: community.name, | |
| 228 | + | community_slug: slug, | |
| 229 | + | current_state: community.state.as_str(), | |
| 230 | + | thread_count, | |
| 231 | + | member_count, | |
| 232 | + | is_suspended: community.suspended_at.is_some(), | |
| 233 | + | suspension_reason, | |
| 234 | + | }) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[tracing::instrument(skip_all)] | |
| 238 | + | pub(super) async fn admin_community_clean_slate_handler( | |
| 239 | + | axum::extract::State(state): axum::extract::State<AppState>, | |
| 240 | + | PlatformAdmin(admin): PlatformAdmin, | |
| 241 | + | Path(slug): Path<String>, | |
| 242 | + | Form(form): Form<CleanSlateForm>, | |
| 243 | + | ) -> Result<Redirect, Response> { | |
| 244 | + | let community = get_community(&state.db, &slug).await?; | |
| 245 | + | ||
| 246 | + | // GitHub-style typed-phrase confirmation: must match the community slug | |
| 247 | + | // exactly. Trim only — case is significant. | |
| 248 | + | if form.confirm.trim() != slug { | |
| 249 | + | return Err(( | |
| 250 | + | StatusCode::UNPROCESSABLE_ENTITY, | |
| 251 | + | "Confirmation phrase did not match the community slug.", | |
| 252 | + | ) | |
| 253 | + | .into_response()); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | let result = mt_db::mutations::clean_slate_community( | |
| 257 | + | &state.db, | |
| 258 | + | community.id, | |
| 259 | + | admin.user_id, | |
| 260 | + | &admin.username, | |
| 261 | + | ) | |
| 262 | + | .await | |
| 263 | + | .map_err(|e| { | |
| 264 | + | tracing::error!(error = ?e, "clean-slate failed"); | |
| 265 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 266 | + | })?; | |
| 267 | + | ||
| 268 | + | log_mod_action( | |
| 269 | + | &state.db, | |
| 270 | + | Some(community.id), | |
| 271 | + | admin.user_id, | |
| 272 | + | ModAction::CleanSlateCommunity, | |
| 273 | + | None, | |
| 274 | + | result.system_thread_id, | |
| 275 | + | Some(&format!("deleted {} threads", result.deleted_thread_count)), | |
| 276 | + | ) | |
| 277 | + | .await; | |
| 278 | + | ||
| 279 | + | Ok(Redirect::to(&format!( | |
| 280 | + | "/_admin/communities/{slug}?toast=Community+reset" | |
| 281 | + | ))) | |
| 282 | + | } |
| @@ -147,6 +147,8 @@ pub(in crate::routes) async fn toggle_endorsement_handler( | |||
| 147 | 147 | // Check community access (suspension + ban) — no mute check since endorsing is not content | |
| 148 | 148 | let community = get_community(&state.db, &slug).await?; | |
| 149 | 149 | check_community_access(&state.db, &community, Some(user.user_id)).await?; | |
| 150 | + | // Endorsement is a write action, so it's blocked by Frozen/Archived state. | |
| 151 | + | check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; | |
| 150 | 152 | ||
| 151 | 153 | // Check platform suspension | |
| 152 | 154 | let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id) |
| @@ -19,28 +19,39 @@ use mt_core::types::{SortColumn, SortOrder}; | |||
| 19 | 19 | ||
| 20 | 20 | use super::super::{ | |
| 21 | 21 | check_community_access, get_community, get_role, is_mod_or_owner, is_owner, | |
| 22 | - | parse_uuid, template_user, CategoryQuery, PageQuery, | |
| 22 | + | parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery, | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | /// Forum directory — lists local communities (paginated). | |
| 26 | + | /// | |
| 27 | + | /// `?filter=archived` switches to the archived-only listing. The default view | |
| 28 | + | /// excludes archived communities; they remain reachable by direct URL. | |
| 26 | 29 | #[tracing::instrument(skip_all)] | |
| 27 | 30 | pub(in crate::routes) async fn forum_directory( | |
| 28 | 31 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 29 | - | Query(page_query): Query<PageQuery>, | |
| 32 | + | Query(query): Query<ForumDirectoryQuery>, | |
| 30 | 33 | session: Session, | |
| 31 | 34 | MaybeUser(session_user): MaybeUser, | |
| 32 | 35 | ) -> impl IntoResponse { | |
| 33 | 36 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 37 | + | let viewing_archived = query.filter.as_deref() == Some("archived"); | |
| 34 | 38 | ||
| 35 | 39 | let per_page: i64 = 25; | |
| 36 | - | let total = mt_db::queries::count_communities(&state.db) | |
| 37 | - | .await | |
| 38 | - | .unwrap_or(0); | |
| 39 | - | let pagination = Pagination::new(page_query.page.unwrap_or(1), total, per_page); | |
| 40 | + | let total = if viewing_archived { | |
| 41 | + | mt_db::queries::count_archived_communities(&state.db).await | |
| 42 | + | } else { | |
| 43 | + | mt_db::queries::count_communities(&state.db).await | |
| 44 | + | } | |
| 45 | + | .unwrap_or(0); | |
| 46 | + | let pagination = Pagination::new(query.page.unwrap_or(1), total, per_page); | |
| 40 | 47 | let offset = (pagination.current_page as i64 - 1) * per_page; | |
| 41 | 48 | ||
| 42 | - | let communities = mt_db::queries::list_communities(&state.db, per_page, offset) | |
| 43 | - | .await | |
| 49 | + | let rows = if viewing_archived { | |
| 50 | + | mt_db::queries::list_archived_communities(&state.db, per_page, offset).await | |
| 51 | + | } else { | |
| 52 | + | mt_db::queries::list_communities(&state.db, per_page, offset).await | |
| 53 | + | }; | |
| 54 | + | let communities = rows | |
| 44 | 55 | .unwrap_or_default() | |
| 45 | 56 | .into_iter() | |
| 46 | 57 | .map(|c| CommunityDirectoryRow { | |
| @@ -60,6 +71,7 @@ pub(in crate::routes) async fn forum_directory( | |||
| 60 | 71 | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 61 | 72 | communities, | |
| 62 | 73 | pagination, | |
| 74 | + | viewing_archived, | |
| 63 | 75 | } | |
| 64 | 76 | } | |
| 65 | 77 |
| @@ -7,7 +7,7 @@ use axum::{ | |||
| 7 | 7 | use chrono::{DateTime, Duration, Utc}; | |
| 8 | 8 | use uuid::Uuid; | |
| 9 | 9 | ||
| 10 | - | use mt_core::types::{CommunityRole, ModAction}; | |
| 10 | + | use mt_core::types::{CommunityRole, CommunityState, ModAction}; | |
| 11 | 11 | ||
| 12 | 12 | use crate::auth; | |
| 13 | 13 | use crate::templates::*; | |
| @@ -313,3 +313,110 @@ pub(crate) async fn require_mod_or_owner( | |||
| 313 | 313 | } | |
| 314 | 314 | Ok((community, role)) | |
| 315 | 315 | } | |
| 316 | + | ||
| 317 | + | // ============================================================================ | |
| 318 | + | // Superadmin authorization | |
| 319 | + | // ============================================================================ | |
| 320 | + | ||
| 321 | + | /// Whether `user` is the configured platform admin. | |
| 322 | + | /// | |
| 323 | + | /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real | |
| 324 | + | /// permissions system is deferred. See `docs/todo.md` § Community Moderation | |
| 325 | + | /// Enforcement. | |
| 326 | + | pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool { | |
| 327 | + | state | |
| 328 | + | .config | |
| 329 | + | .platform_admin_id | |
| 330 | + | .is_some_and(|id| id == user.user_id) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// True if the user can perform mod actions in this community: either a | |
| 334 | + | /// community Owner/Moderator, or the platform admin (who can act on any | |
| 335 | + | /// community). Used by [`check_community_state`] and by the state-change route. | |
| 336 | + | pub(crate) fn is_mod_or_superadmin( | |
| 337 | + | state: &AppState, | |
| 338 | + | user: &auth::SessionUser, | |
| 339 | + | role: &Option<CommunityRole>, | |
| 340 | + | ) -> bool { | |
| 341 | + | is_mod_or_owner(role) || is_platform_admin(state, user) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | /// Fetch community + verify the user is a mod, owner, or platform admin. | |
| 345 | + | /// | |
| 346 | + | /// Returns `(community, role)` — `role` is `None` when the user is the platform | |
| 347 | + | /// admin but holds no role in this specific community. | |
| 348 | + | #[tracing::instrument(skip_all)] | |
| 349 | + | pub(crate) async fn require_mod_or_superadmin( | |
| 350 | + | state: &AppState, | |
| 351 | + | slug: &str, | |
| 352 | + | user: &auth::SessionUser, | |
| 353 | + | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 354 | + | let community = get_community(&state.db, slug).await?; | |
| 355 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 356 | + | if !is_mod_or_superadmin(state, user, &role) { | |
| 357 | + | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 358 | + | } | |
| 359 | + | Ok((community, role)) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // ============================================================================ | |
| 363 | + | // Community state enforcement | |
| 364 | + | // ============================================================================ | |
| 365 | + | ||
| 366 | + | /// Whether a write attempt is starting a new thread or extending an existing | |
| 367 | + | /// one. Restricted communities block `NewThread` for non-mods but still accept | |
| 368 | + | /// `ContinueExisting` writes. | |
| 369 | + | #[derive(Debug, Clone, Copy)] | |
| 370 | + | pub(crate) enum WriteScope { | |
| 371 | + | NewThread, | |
| 372 | + | ContinueExisting, | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// Convenience: combine role lookup with [`check_community_state`]. Use this | |
| 376 | + | /// in write handlers that don't already need the role for other purposes. | |
| 377 | + | #[tracing::instrument(skip_all)] | |
| 378 | + | pub(crate) async fn check_write_state( | |
| 379 | + | state: &AppState, | |
| 380 | + | community: &mt_db::queries::CommunityRow, | |
| 381 | + | user: &auth::SessionUser, | |
| 382 | + | scope: WriteScope, | |
| 383 | + | ) -> Result<(), Response> { | |
| 384 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 385 | + | let is_mod_or_super = is_mod_or_superadmin(state, user, &role); | |
| 386 | + | check_community_state(community.state, scope, is_mod_or_super) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | /// Gate a write against the community's [`CommunityState`]. | |
| 390 | + | /// | |
| 391 | + | /// Mods/owners and the platform admin bypass all state restrictions. Members | |
| 392 | + | /// follow the state's `allows_*` predicates. Returns 403 with a state-specific | |
| 393 | + | /// message on denial — message text is what the user will see in the toast. | |
| 394 | + | /// | |
| 395 | + | /// Note: this is independent of [`check_write_access`] (which covers | |
| 396 | + | /// suspension/ban/mute). Call both in write handlers. | |
| 397 | + | #[allow(clippy::result_large_err)] | |
| 398 | + | pub(crate) fn check_community_state( | |
| 399 | + | community_state: CommunityState, | |
| 400 | + | scope: WriteScope, | |
| 401 | + | is_mod_or_super: bool, | |
| 402 | + | ) -> Result<(), Response> { | |
| 403 | + | if is_mod_or_super { | |
| 404 | + | return Ok(()); | |
| 405 | + | } | |
| 406 | + | let allowed = match scope { | |
| 407 | + | WriteScope::NewThread => community_state.allows_new_threads_for_members(), | |
| 408 | + | WriteScope::ContinueExisting => community_state.allows_writes_for_members(), | |
| 409 | + | }; | |
| 410 | + | if allowed { | |
| 411 | + | return Ok(()); | |
| 412 | + | } | |
| 413 | + | let msg = match (community_state, scope) { | |
| 414 | + | (CommunityState::Restricted, WriteScope::NewThread) => { | |
| 415 | + | "New threads are restricted in this community." | |
| 416 | + | } | |
| 417 | + | (CommunityState::Frozen, _) => "This community is frozen.", | |
| 418 | + | (CommunityState::Archived, _) => "This community is archived.", | |
| 419 | + | _ => "Action not allowed in the community's current state.", | |
| 420 | + | }; | |
| 421 | + | Err((StatusCode::FORBIDDEN, msg).into_response()) | |
| 422 | + | } |
| @@ -13,12 +13,12 @@ use crate::csrf; | |||
| 13 | 13 | use crate::templates::*; | |
| 14 | 14 | use crate::AppState; | |
| 15 | 15 | ||
| 16 | - | use mt_core::types::ModAction; | |
| 16 | + | use mt_core::types::{CommunityState, ModAction}; | |
| 17 | 17 | ||
| 18 | 18 | use super::{ | |
| 19 | - | log_mod_action, parse_uuid, require_owner, template_user, validate_title, | |
| 20 | - | CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm, | |
| 21 | - | UpdateCommunityForm, | |
| 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 | 22 | }; | |
| 23 | 23 | ||
| 24 | 24 | #[tracing::instrument(skip_all)] | |
| @@ -390,3 +390,59 @@ pub(super) async fn delete_tag_handler( | |||
| 390 | 390 | "/p/{slug}/settings?toast=Tag+deleted" | |
| 391 | 391 | ))) | |
| 392 | 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 | + | } |
| @@ -22,6 +22,7 @@ | |||
| 22 | 22 | <a href="{{ mnw_base_url }}/creators">Creators</a> | |
| 23 | 23 | <a href="{{ mnw_base_url }}/docs">Docs</a> | |
| 24 | 24 | <a href="{{ mnw_base_url }}/policy">Policy</a> | |
| 25 | + | <a href="{{ mnw_base_url }}/guide/moderation">Forum moderation</a> | |
| 25 | 26 | </div> | |
| 26 | 27 | <span>Powered by <a href="{{ mnw_base_url }}/">Makenot<span class="dot">.</span>work</a></span> | |
| 27 | 28 | <span class="footer-sep">·</span> |
| @@ -88,7 +88,7 @@ | |||
| 88 | 88 | <tbody> | |
| 89 | 89 | {% for c in communities %} | |
| 90 | 90 | <tr> | |
| 91 | - | <td>{{ c.name }}</td> | |
| 91 | + | <td><a href="/_admin/communities/{{ c.slug }}">{{ c.name }}</a></td> | |
| 92 | 92 | <td class="settings-mono">{{ c.slug }}</td> | |
| 93 | 93 | <td> | |
| 94 | 94 | {% if c.is_suspended %} |
| @@ -0,0 +1,80 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | ||
| 3 | + | {% block title %}{{ community_name }} — Admin — Multithreaded{% endblock %} | |
| 4 | + | ||
| 5 | + | {% block head %}<meta name="robots" content="noindex">{% endblock %} | |
| 6 | + | ||
| 7 | + | {% block header %}{% include "partials/site_header.html" %}{% endblock %} | |
| 8 | + | ||
| 9 | + | {% block content %} | |
| 10 | + | <div class="container"> | |
| 11 | + | <div class="breadcrumb"> | |
| 12 | + | <a href="/_admin">Admin</a> | |
| 13 | + | <span class="sep">/</span> | |
| 14 | + | <a href="/p/{{ community_slug }}">{{ community_name }}</a> | |
| 15 | + | </div> | |
| 16 | + | <h2 class="section-heading">Community administration — {{ community_name }}</h2> | |
| 17 | + | ||
| 18 | + | <div class="settings-section"> | |
| 19 | + | <h3>Overview</h3> | |
| 20 | + | <ul class="account-status"> | |
| 21 | + | <li>Slug: <strong>{{ community_slug }}</strong></li> | |
| 22 | + | <li>State: <strong>{{ current_state }}</strong></li> | |
| 23 | + | <li>Suspension: | |
| 24 | + | {% if is_suspended %} | |
| 25 | + | <strong>suspended</strong> | |
| 26 | + | {% if let Some(reason) = suspension_reason %} — {{ reason }}{% endif %} | |
| 27 | + | {% else %} | |
| 28 | + | none | |
| 29 | + | {% endif %} | |
| 30 | + | </li> | |
| 31 | + | <li>Threads (current): <strong>{{ thread_count }}</strong></li> | |
| 32 | + | <li>Members: <strong>{{ member_count }}</strong></li> | |
| 33 | + | </ul> | |
| 34 | + | </div> | |
| 35 | + | ||
| 36 | + | <div class="settings-section"> | |
| 37 | + | <h3>Moderation state</h3> | |
| 38 | + | <p class="form-help"> | |
| 39 | + | Change the community's state machine. Mods can also do this from the community | |
| 40 | + | settings page; admin changes are logged the same way. | |
| 41 | + | </p> | |
| 42 | + | <form method="post" action="/p/{{ community_slug }}/settings/state" class="form-container"> | |
| 43 | + | <div class="form-group"> | |
| 44 | + | <label for="state">State</label> | |
| 45 | + | <select id="state" name="state"> | |
| 46 | + | <option value="active" {% if current_state == "active" %}selected{% endif %}>Active — normal operation</option> | |
| 47 | + | <option value="restricted" {% if current_state == "restricted" %}selected{% endif %}>Restricted — only mods can start new threads</option> | |
| 48 | + | <option value="frozen" {% if current_state == "frozen" %}selected{% endif %}>Frozen — read-only for non-mods</option> | |
| 49 | + | <option value="archived" {% if current_state == "archived" %}selected{% endif %}>Archived — frozen + hidden from default listing</option> | |
| 50 | + | </select> | |
| 51 | + | </div> | |
| 52 | + | <div class="form-actions"> | |
| 53 | + | <button type="submit" class="primary">Update state</button> | |
| 54 | + | </div> | |
| 55 | + | </form> | |
| 56 | + | </div> | |
| 57 | + | ||
| 58 | + | <div class="settings-section danger-zone"> | |
| 59 | + | <h3>Clean slate</h3> | |
| 60 | + | <p class="form-help"> | |
| 61 | + | Delete every thread, post, footnote, endorsement, and flag in this community. | |
| 62 | + | Settings, categories, members, and bans are preserved. A pinned, locked notice | |
| 63 | + | thread is posted in the first category recording who reset the forum and when. | |
| 64 | + | </p> | |
| 65 | + | <p class="form-help"> | |
| 66 | + | <strong>This cannot be undone.</strong> To confirm, type the community slug | |
| 67 | + | <code>{{ community_slug }}</code> in the box below. | |
| 68 | + | </p> | |
| 69 | + | <form method="post" action="/_admin/communities/{{ community_slug }}/clean-slate" class="form-container"> | |
| 70 | + | <div class="form-group"> | |
| 71 | + | <label for="confirm">Confirmation</label> | |
| 72 | + | <input type="text" id="confirm" name="confirm" placeholder="{{ community_slug }}" autocomplete="off" required> | |
| 73 | + | </div> | |
| 74 | + | <div class="form-actions"> | |
| 75 | + | <button type="submit" class="danger">Wipe community threads</button> | |
| 76 | + | </div> | |
| 77 | + | </form> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | {% endblock %} |
| @@ -9,10 +9,19 @@ | |||
| 9 | 9 | {% block content %} | |
| 10 | 10 | <div class="container"> | |
| 11 | 11 | <div class="page-header"> | |
| 12 | - | <h1>Forums</h1> | |
| 12 | + | <h1>{% if viewing_archived %}Archived forums{% else %}Forums{% endif %}</h1> | |
| 13 | + | <div class="page-header-actions"> | |
| 14 | + | {% if viewing_archived %} | |
| 15 | + | <a href="/">Back to active forums</a> | |
| 16 | + | {% else %} | |
| 17 | + | <a href="/?filter=archived">View archived</a> | |
| 18 | + | {% endif %} | |
| 19 | + | </div> | |
| 13 | 20 | </div> | |
| 14 | 21 | {% if communities.is_empty() %} | |
| 15 | - | <div class="empty-state">No communities yet.</div> | |
| 22 | + | <div class="empty-state"> | |
| 23 | + | {% if viewing_archived %}No archived communities.{% else %}No communities yet.{% endif %} | |
| 24 | + | </div> | |
| 16 | 25 | {% else %} | |
| 17 | 26 | <table class="directory-table"> | |
| 18 | 27 | <thead> |
| @@ -0,0 +1,216 @@ | |||
| 1 | + | //! Dedicated superadmin community view + clean-slate. | |
| 2 | + | //! | |
| 3 | + | //! Covers: | |
| 4 | + | //! * Admin community detail page renders for superadmin; 404 for others | |
| 5 | + | //! * Clean-slate wipes threads/posts, preserves settings/categories/members, | |
| 6 | + | //! posts a pinned+locked system thread, logs the mod action | |
| 7 | + | //! * Typed-phrase confirmation enforced | |
| 8 | + | ||
| 9 | + | use crate::harness::TestHarness; | |
| 10 | + | use axum::http::StatusCode; | |
| 11 | + | use uuid::Uuid; | |
| 12 | + | ||
| 13 | + | async fn login_admin(h: &mut TestHarness, admin_id: Uuid) { | |
| 14 | + | sqlx::query( | |
| 15 | + | "INSERT INTO users (mnw_account_id, username, display_name) \ | |
| 16 | + | VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 17 | + | ) | |
| 18 | + | .bind(admin_id) | |
| 19 | + | .execute(&h.db) | |
| 20 | + | .await | |
| 21 | + | .unwrap(); | |
| 22 | + | h.client.get("/").await; | |
| 23 | + | h.client | |
| 24 | + | .post_json( | |
| 25 | + | "/_test/login", | |
| 26 | + | &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) | |
| 27 | + | .to_string(), | |
| 28 | + | ) | |
| 29 | + | .await; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[tokio::test] | |
| 33 | + | async fn admin_community_page_renders_for_superadmin() { | |
| 34 | + | let admin_id = Uuid::new_v4(); | |
| 35 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 36 | + | let comm_id = h.create_community("Test", "test").await; | |
| 37 | + | let _cat_id = h.create_category(comm_id, "General", "general").await; | |
| 38 | + | login_admin(&mut h, admin_id).await; | |
| 39 | + | ||
| 40 | + | let resp = h.client.get("/_admin/communities/test").await; | |
| 41 | + | assert_eq!(resp.status, StatusCode::OK); | |
| 42 | + | assert!(resp.text.contains("Community administration")); | |
| 43 | + | assert!(resp.text.contains("Clean slate")); | |
| 44 | + | assert!(resp.text.contains("Moderation state")); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[tokio::test] | |
| 48 | + | async fn admin_community_page_404_for_non_admin() { | |
| 49 | + | let mut h = TestHarness::new().await; | |
| 50 | + | let comm_id = h.create_community("Test", "test").await; | |
| 51 | + | let _cat_id = h.create_category(comm_id, "General", "general").await; | |
| 52 | + | let _ = h.login_as("notadmin").await; | |
| 53 | + | ||
| 54 | + | let resp = h.client.get("/_admin/communities/test").await; | |
| 55 | + | assert_eq!(resp.status, StatusCode::NOT_FOUND); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | #[tokio::test] | |
| 59 | + | async fn clean_slate_wipes_threads_and_logs_action() { | |
| 60 | + | let admin_id = Uuid::new_v4(); | |
| 61 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 62 | + | let comm_id = h.create_community("Test", "test").await; | |
| 63 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 64 | + | ||
| 65 | + | // Seed an author + threads to wipe. | |
| 66 | + | let author_id = h.login_as("seedauthor").await; | |
| 67 | + | h.add_membership(author_id, comm_id, "member").await; | |
| 68 | + | let _thread_a = h.create_thread_with_post(cat_id, author_id, "First", "body A").await; | |
| 69 | + | let _thread_b = h.create_thread_with_post(cat_id, author_id, "Second", "body B").await; | |
| 70 | + | h.client.post_form("/auth/logout", "").await; | |
| 71 | + | ||
| 72 | + | login_admin(&mut h, admin_id).await; | |
| 73 | + | h.client.get("/_admin/communities/test").await; | |
| 74 | + | let resp = h | |
| 75 | + | .client | |
| 76 | + | .post_form("/_admin/communities/test/clean-slate", "confirm=test") | |
| 77 | + | .await; | |
| 78 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 79 | + | ||
| 80 | + | // Originals gone; one system thread remains (pinned + locked). | |
| 81 | + | let titles: Vec<(String, bool, bool)> = sqlx::query_as( | |
| 82 | + | "SELECT t.title, t.pinned, t.locked | |
| 83 | + | FROM threads t JOIN categories c ON c.id = t.category_id | |
| 84 | + | WHERE c.community_id = $1", | |
| 85 | + | ) | |
| 86 | + | .bind(comm_id) | |
| 87 | + | .fetch_all(&h.db) | |
| 88 | + | .await | |
| 89 | + | .unwrap(); | |
| 90 | + | assert_eq!(titles.len(), 1, "expected only the system reset thread"); | |
| 91 | + | let (title, pinned, locked) = &titles[0]; | |
| 92 | + | assert!(title.starts_with("Community reset by superadmin")); | |
| 93 | + | assert!(*pinned, "system thread should be pinned"); | |
| 94 | + | assert!(*locked, "system thread should be locked"); | |
| 95 | + | ||
| 96 | + | // Mod log records the action. | |
| 97 | + | let action_count: i64 = sqlx::query_scalar( | |
| 98 | + | "SELECT COUNT(*) FROM mod_log | |
| 99 | + | WHERE community_id = $1 AND action = 'clean_slate_community'", | |
| 100 | + | ) | |
| 101 | + | .bind(comm_id) | |
| 102 | + | .fetch_one(&h.db) | |
| 103 | + | .await | |
| 104 | + | .unwrap(); | |
| 105 | + | assert_eq!(action_count, 1); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[tokio::test] | |
| 109 | + | async fn clean_slate_preserves_categories_members_tags() { | |
| 110 | + | let admin_id = Uuid::new_v4(); | |
| 111 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 112 | + | let comm_id = h.create_community("Test", "test").await; | |
| 113 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 114 | + | ||
| 115 | + | let author_id = h.login_as("seed2").await; | |
| 116 | + | h.add_membership(author_id, comm_id, "member").await; | |
| 117 | + | h.create_thread_with_post(cat_id, author_id, "First", "body").await; | |
| 118 | + | h.client.post_form("/auth/logout", "").await; | |
| 119 | + | ||
| 120 | + | login_admin(&mut h, admin_id).await; | |
| 121 | + | h.client.get("/_admin/communities/test").await; | |
| 122 | + | h.client | |
| 123 | + | .post_form("/_admin/communities/test/clean-slate", "confirm=test") | |
| 124 | + | .await; | |
| 125 | + | ||
| 126 | + | // Category survives. | |
| 127 | + | let cat_count: i64 = | |
| 128 | + | sqlx::query_scalar("SELECT COUNT(*) FROM categories WHERE community_id = $1") | |
| 129 | + | .bind(comm_id) | |
| 130 | + | .fetch_one(&h.db) | |
| 131 | + | .await | |
| 132 | + | .unwrap(); | |
| 133 | + | assert!(cat_count >= 1); | |
| 134 | + | ||
| 135 | + | // Membership survives. | |
| 136 | + | let member_count: i64 = sqlx::query_scalar( | |
| 137 | + | "SELECT COUNT(*) FROM memberships WHERE community_id = $1 AND user_id = $2", | |
| 138 | + | ) | |
| 139 | + | .bind(comm_id) | |
| 140 | + | .bind(author_id) | |
| 141 | + | .fetch_one(&h.db) | |
| 142 | + | .await | |
| 143 | + | .unwrap(); | |
| 144 | + | assert_eq!(member_count, 1, "membership should be preserved"); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[tokio::test] | |
| 148 | + | async fn clean_slate_rejects_wrong_confirmation_phrase() { | |
| 149 | + | let admin_id = Uuid::new_v4(); | |
| 150 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 151 | + | let comm_id = h.create_community("Test", "test").await; | |
| 152 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 153 | + | let author_id = h.login_as("seed3").await; | |
| 154 | + | h.add_membership(author_id, comm_id, "member").await; | |
| 155 | + | h.create_thread_with_post(cat_id, author_id, "Survive me", "body").await; | |
| 156 | + | h.client.post_form("/auth/logout", "").await; | |
| 157 | + | ||
| 158 | + | login_admin(&mut h, admin_id).await; | |
| 159 | + | h.client.get("/_admin/communities/test").await; | |
| 160 | + | let resp = h | |
| 161 | + | .client | |
| 162 | + | .post_form("/_admin/communities/test/clean-slate", "confirm=nope") | |
| 163 | + | .await; | |
| 164 | + | assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); | |
| 165 | + | ||
| 166 | + | // Threads survive. | |
| 167 | + | let count: i64 = sqlx::query_scalar( | |
| 168 | + | "SELECT COUNT(*) FROM threads t JOIN categories c ON c.id = t.category_id | |
| 169 | + | WHERE c.community_id = $1", | |
| 170 | + | ) | |
| 171 | + | .bind(comm_id) | |
| 172 | + | .fetch_one(&h.db) | |
| 173 | + | .await | |
| 174 | + | .unwrap(); | |
| 175 | + | assert_eq!(count, 1, "thread should still exist after rejected clean-slate"); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[tokio::test] | |
| 179 | + | async fn clean_slate_404_for_non_admin() { | |
| 180 | + | let mut h = TestHarness::new().await; | |
| 181 | + | let comm_id = h.create_community("Test", "test").await; | |
| 182 | + | let _cat_id = h.create_category(comm_id, "General", "general").await; | |
| 183 | + | let _ = h.login_as("notadmin").await; | |
| 184 | + | ||
| 185 | + | h.client.get("/").await; | |
| 186 | + | let resp = h | |
| 187 | + | .client | |
| 188 | + | .post_form("/_admin/communities/test/clean-slate", "confirm=test") | |
| 189 | + | .await; | |
| 190 | + | assert_eq!(resp.status, StatusCode::NOT_FOUND); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[tokio::test] | |
| 194 | + | async fn clean_slate_with_no_categories_skips_system_thread() { | |
| 195 | + | let admin_id = Uuid::new_v4(); | |
| 196 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 197 | + | let comm_id = h.create_community("Empty", "empty").await; | |
| 198 | + | // No category — clean-slate should succeed but skip the system thread. | |
| 199 | + | login_admin(&mut h, admin_id).await; | |
| 200 | + | h.client.get("/_admin/communities/empty").await; | |
| 201 | + | let resp = h | |
| 202 | + | .client | |
| 203 | + | .post_form("/_admin/communities/empty/clean-slate", "confirm=empty") | |
| 204 | + | .await; | |
| 205 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 206 | + | ||
| 207 | + | let count: i64 = sqlx::query_scalar( | |
| 208 | + | "SELECT COUNT(*) FROM threads t JOIN categories c ON c.id = t.category_id | |
| 209 | + | WHERE c.community_id = $1", | |
| 210 | + | ) | |
| 211 | + | .bind(comm_id) | |
| 212 | + | .fetch_one(&h.db) | |
| 213 | + | .await | |
| 214 | + | .unwrap(); | |
| 215 | + | assert_eq!(count, 0); | |
| 216 | + | } |
| @@ -0,0 +1,379 @@ | |||
| 1 | + | //! Community moderation state machine — enforcement and state-change tests. | |
| 2 | + | //! | |
| 3 | + | //! Predicate semantics (which actions are allowed in each state) live in | |
| 4 | + | //! `mt-core::types::CommunityState` unit tests. These tests exercise the | |
| 5 | + | //! wire-up: that write handlers consult the state, mods/superadmin bypass, | |
| 6 | + | //! and the state-change route is authorized correctly. | |
| 7 | + | ||
| 8 | + | use crate::harness::TestHarness; | |
| 9 | + | use axum::http::StatusCode; | |
| 10 | + | use uuid::Uuid; | |
| 11 | + | ||
| 12 | + | // ── Setup ── | |
| 13 | + | ||
| 14 | + | /// Build a community in the requested state with a category, returning | |
| 15 | + | /// (community_id, category_id). | |
| 16 | + | async fn setup_community(h: &mut TestHarness, state: &str) -> (Uuid, Uuid) { | |
| 17 | + | let comm_id = h.create_community("Test", "test").await; | |
| 18 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 19 | + | sqlx::query("UPDATE communities SET state = $2 WHERE id = $1") | |
| 20 | + | .bind(comm_id) | |
| 21 | + | .bind(state) | |
| 22 | + | .execute(&h.db) | |
| 23 | + | .await | |
| 24 | + | .expect("set state"); | |
| 25 | + | (comm_id, cat_id) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ── Restricted: block new threads, allow replies ── | |
| 29 | + | ||
| 30 | + | #[tokio::test] | |
| 31 | + | async fn restricted_blocks_member_new_thread() { | |
| 32 | + | let mut h = TestHarness::new().await; | |
| 33 | + | let user_id = h.login_as("rmem").await; | |
| 34 | + | let (comm_id, _cat) = setup_community(&mut h, "restricted").await; | |
| 35 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 36 | + | ||
| 37 | + | h.client.get("/p/test/general/new").await; | |
| 38 | + | let resp = h | |
| 39 | + | .client | |
| 40 | + | .post_form("/p/test/general/new", "title=Blocked&body=No") | |
| 41 | + | .await; | |
| 42 | + | assert_eq!(resp.status, StatusCode::FORBIDDEN); | |
| 43 | + | assert!(resp.text.contains("restricted"), "body: {}", resp.text); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[tokio::test] | |
| 47 | + | async fn restricted_allows_member_reply() { | |
| 48 | + | let mut h = TestHarness::new().await; | |
| 49 | + | let user_id = h.login_as("rmem2").await; | |
| 50 | + | let (comm_id, cat_id) = setup_community(&mut h, "restricted").await; | |
| 51 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 52 | + | let thread_id = h | |
| 53 | + | .create_thread_with_post(cat_id, user_id, "Existing", "OP") | |
| 54 | + | .await; | |
| 55 | + | ||
| 56 | + | let url = format!("/p/test/general/{}", thread_id); | |
| 57 | + | h.client.get(&url).await; | |
| 58 | + | let resp = h | |
| 59 | + | .client | |
| 60 | + | .post_form(&format!("{}/reply", url), "body=Hello+still") | |
| 61 | + | .await; | |
| 62 | + | assert!(resp.status.is_redirection() || resp.status.is_success(), "status: {}", resp.status); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[tokio::test] | |
| 66 | + | async fn restricted_mod_can_create_thread() { | |
| 67 | + | let mut h = TestHarness::new().await; | |
| 68 | + | let user_id = h.login_as("rmod").await; | |
| 69 | + | let (comm_id, _cat) = setup_community(&mut h, "restricted").await; | |
| 70 | + | h.add_membership(user_id, comm_id, "moderator").await; | |
| 71 | + | ||
| 72 | + | h.client.get("/p/test/general/new").await; | |
| 73 | + | let resp = h | |
| 74 | + | .client | |
| 75 | + | .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed") | |
| 76 | + | .await; | |
| 77 | + | assert!(resp.status.is_redirection(), "mod should bypass, got {}", resp.status); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ── Frozen: block all member writes ── | |
| 81 | + | ||
| 82 | + | #[tokio::test] | |
| 83 | + | async fn frozen_blocks_member_reply() { | |
| 84 | + | let mut h = TestHarness::new().await; | |
| 85 | + | let user_id = h.login_as("fmem").await; | |
| 86 | + | let (comm_id, cat_id) = setup_community(&mut h, "active").await; | |
| 87 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 88 | + | let thread_id = h | |
| 89 | + | .create_thread_with_post(cat_id, user_id, "Thread", "OP") | |
| 90 | + | .await; | |
| 91 | + | // Freeze after thread exists | |
| 92 | + | sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") | |
| 93 | + | .bind(comm_id) | |
| 94 | + | .execute(&h.db) | |
| 95 | + | .await | |
| 96 | + | .unwrap(); | |
| 97 | + | ||
| 98 | + | let url = format!("/p/test/general/{}", thread_id); | |
| 99 | + | h.client.get(&url).await; | |
| 100 | + | let resp = h | |
| 101 | + | .client | |
| 102 | + | .post_form(&format!("{}/reply", url), "body=Reply") | |
| 103 | + | .await; | |
| 104 | + | assert_eq!(resp.status, StatusCode::FORBIDDEN); | |
| 105 | + | assert!(resp.text.contains("frozen")); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[tokio::test] | |
| 109 | + | async fn frozen_blocks_member_endorsement() { | |
| 110 | + | let mut h = TestHarness::new().await; | |
| 111 | + | let author_id = h.login_as("fauth").await; | |
| 112 | + | let (comm_id, cat_id) = setup_community(&mut h, "active").await; | |
| 113 | + | h.add_membership(author_id, comm_id, "member").await; | |
| 114 | + | let thread_id = h | |
| 115 | + | .create_thread_with_post(cat_id, author_id, "T", "OP") | |
| 116 | + | .await; | |
| 117 | + | let post_id: Uuid = | |
| 118 | + | sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1") | |
| 119 | + | .bind(thread_id) | |
| 120 | + | .fetch_one(&h.db) | |
| 121 | + | .await | |
| 122 | + | .unwrap(); | |
| 123 | + | ||
| 124 | + | // Switch to a different user and freeze the community | |
| 125 | + | h.client.post_form("/auth/logout", "").await; | |
| 126 | + | let other_id = h.login_as("fother").await; | |
| 127 | + | h.add_membership(other_id, comm_id, "member").await; | |
| 128 | + | sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") | |
| 129 | + | .bind(comm_id) | |
| 130 | + | .execute(&h.db) | |
| 131 | + | .await | |
| 132 | + | .unwrap(); | |
| 133 | + | ||
| 134 | + | let url = format!("/p/test/general/{}/posts/{}/endorse", thread_id, post_id); | |
| 135 | + | h.client.get(&format!("/p/test/general/{}", thread_id)).await; | |
| 136 | + | let resp = h.client.post_form(&url, "").await; | |
| 137 | + | assert_eq!(resp.status, StatusCode::FORBIDDEN); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[tokio::test] | |
| 141 | + | async fn frozen_mod_can_reply() { | |
| 142 | + | let mut h = TestHarness::new().await; | |
| 143 | + | let user_id = h.login_as("fmod").await; | |
| 144 | + | let (comm_id, cat_id) = setup_community(&mut h, "active").await; | |
| 145 | + | h.add_membership(user_id, comm_id, "moderator").await; | |
| 146 | + | let thread_id = h | |
| 147 | + | .create_thread_with_post(cat_id, user_id, "T", "OP") | |
| 148 | + | .await; | |
| 149 | + | sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") | |
| 150 | + | .bind(comm_id) | |
| 151 | + | .execute(&h.db) | |
| 152 | + | .await | |
| 153 | + | .unwrap(); | |
| 154 | + | ||
| 155 | + | let url = format!("/p/test/general/{}", thread_id); | |
| 156 | + | h.client.get(&url).await; | |
| 157 | + | let resp = h | |
| 158 | + | .client | |
| 159 | + | .post_form(&format!("{}/reply", url), "body=Mod+reply") | |
| 160 | + | .await; | |
| 161 | + | assert!(resp.status.is_redirection(), "mod reply blocked: {}", resp.status); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // ── Superadmin override ── | |
| 165 | + | ||
| 166 | + | #[tokio::test] | |
| 167 | + | async fn superadmin_can_reply_in_frozen_without_role() { | |
| 168 | + | // Superadmin is a platform-level user with no community role here. | |
| 169 | + | let admin_id = Uuid::new_v4(); | |
| 170 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 171 | + | ||
| 172 | + | // Seed a community + author with role, then a thread. | |
| 173 | + | let author_id = h.login_as("sauth").await; | |
| 174 | + | let (comm_id, cat_id) = setup_community(&mut h, "active").await; | |
| 175 | + | h.add_membership(author_id, comm_id, "member").await; | |
| 176 | + | let thread_id = h | |
| 177 | + | .create_thread_with_post(cat_id, author_id, "T", "OP") | |
| 178 | + | .await; | |
| 179 | + | sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") | |
| 180 | + | .bind(comm_id) | |
| 181 | + | .execute(&h.db) | |
| 182 | + | .await | |
| 183 | + | .unwrap(); | |
| 184 | + | ||
| 185 | + | // Become the superadmin — explicitly no community role. | |
| 186 | + | h.client.post_form("/auth/logout", "").await; | |
| 187 | + | sqlx::query( | |
| 188 | + | "INSERT INTO users (mnw_account_id, username, display_name) \ | |
| 189 | + | VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 190 | + | ) | |
| 191 | + | .bind(admin_id) | |
| 192 | + | .execute(&h.db) | |
| 193 | + | .await | |
| 194 | + | .unwrap(); | |
| 195 | + | h.client.get("/").await; | |
| 196 | + | h.client | |
| 197 | + | .post_json( | |
| 198 | + | "/_test/login", | |
| 199 | + | &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) | |
| 200 | + | .to_string(), | |
| 201 | + | ) | |
| 202 | + | .await; | |
| 203 | + | ||
| 204 | + | let url = format!("/p/test/general/{}", thread_id); | |
| 205 | + | h.client.get(&url).await; | |
| 206 | + | let resp = h | |
| 207 | + | .client | |
| 208 | + | .post_form(&format!("{}/reply", url), "body=Super+reply") | |
| 209 | + | .await; | |
| 210 | + | assert!( | |
| 211 | + | resp.status.is_redirection(), | |
| 212 | + | "superadmin reply blocked: status={} body={}", | |
| 213 | + | resp.status, | |
| 214 | + | resp.text | |
| 215 | + | ); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // ── Archived: same as frozen + hidden from default listing ── | |
| 219 | + | ||
| 220 | + | #[tokio::test] | |
| 221 | + | async fn archived_blocks_member_reply() { | |
| 222 | + | let mut h = TestHarness::new().await; | |
| 223 | + | let user_id = h.login_as("amem").await; | |
| 224 | + | let (comm_id, cat_id) = setup_community(&mut h, "active").await; | |
| 225 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 226 | + | let thread_id = h | |
| 227 | + | .create_thread_with_post(cat_id, user_id, "T", "OP") | |
| 228 | + | .await; | |
| 229 | + | sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") | |
| 230 | + | .bind(comm_id) | |
| 231 | + | .execute(&h.db) | |
| 232 | + | .await | |
| 233 | + | .unwrap(); | |
| 234 | + | ||
| 235 | + | let url = format!("/p/test/general/{}", thread_id); | |
| 236 | + | h.client.get(&url).await; | |
| 237 | + | let resp = h | |
| 238 | + | .client | |
| 239 | + | .post_form(&format!("{}/reply", url), "body=No") | |
| 240 | + | .await; | |
| 241 | + | assert_eq!(resp.status, StatusCode::FORBIDDEN); | |
| 242 | + | assert!(resp.text.contains("archived")); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[tokio::test] | |
| 246 | + | async fn archived_excluded_from_default_listing() { | |
| 247 | + | let mut h = TestHarness::new().await; | |
| 248 | + | h.create_community("Active", "active-comm").await; | |
| 249 | + | let arch_id = h.create_community("Archived", "arch-comm").await; | |
| 250 | + | sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") | |
| 251 | + | .bind(arch_id) | |
| 252 | + | .execute(&h.db) | |
| 253 | + | .await | |
| 254 | + | .unwrap(); | |
| 255 | + | ||
| 256 | + | let resp = h.client.get("/").await; | |
| 257 | + | assert!(resp.text.contains("Active")); | |
| 258 | + | assert!( | |
| 259 | + | !resp.text.contains("/p/arch-comm"), | |
| 260 | + | "archived community should not appear in default listing" | |
| 261 | + | ); | |
| 262 | + | ||
| 263 | + | let resp = h.client.get("/?filter=archived").await; | |
| 264 | + | assert!(resp.text.contains("/p/arch-comm"), "archived view should show it"); | |
| 265 | + | assert!( | |
| 266 | + | !resp.text.contains("/p/active-comm"), | |
| 267 | + | "archived view should not include active communities" | |
| 268 | + | ); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // ── State-change route ── | |
| 272 | + | ||
| 273 | + | #[tokio::test] | |
| 274 | + | async fn owner_can_change_state() { | |
| 275 | + | let mut h = TestHarness::new().await; | |
| 276 | + | let user_id = h.login_as("owner").await; | |
| 277 | + | let (comm_id, _cat) = setup_community(&mut h, "active").await; | |
| 278 | + | h.add_membership(user_id, comm_id, "owner").await; | |
| 279 | + | ||
| 280 | + | // Prime CSRF + cookie | |
| 281 | + | h.client.get("/p/test/settings").await; | |
| 282 | + | let resp = h | |
| 283 | + | .client | |
| 284 | + | .post_form("/p/test/settings/state", "state=frozen") | |
| 285 | + | .await; | |
| 286 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 287 | + | ||
| 288 | + | let state: String = | |
| 289 | + | sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") | |
| 290 | + | .bind(comm_id) | |
| 291 | + | .fetch_one(&h.db) | |
| 292 | + | .await | |
| 293 | + | .unwrap(); | |
| 294 | + | assert_eq!(state, "frozen"); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[tokio::test] | |
| 298 | + | async fn superadmin_can_change_state_without_role() { | |
| 299 | + | let admin_id = Uuid::new_v4(); | |
| 300 | + | let mut h = TestHarness::new_with_admin(admin_id).await; | |
| 301 | + | let (comm_id, _cat) = setup_community(&mut h, "active").await; | |
| 302 | + | sqlx::query( | |
| 303 | + | "INSERT INTO users (mnw_account_id, username, display_name) \ | |
| 304 | + | VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 305 | + | ) | |
| 306 | + | .bind(admin_id) | |
| 307 | + | .execute(&h.db) | |
| 308 | + | .await | |
| 309 | + | .unwrap(); | |
| 310 | + | h.client.get("/").await; | |
| 311 | + | h.client | |
| 312 | + | .post_json( | |
| 313 | + | "/_test/login", | |
| 314 | + | &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) | |
| 315 | + | .to_string(), | |
| 316 | + | ) | |
| 317 | + | .await; | |
| 318 | + | ||
| 319 | + | h.client.get("/p/test/settings").await; | |
| 320 | + | let resp = h | |
| 321 | + | .client | |
| 322 | + | .post_form("/p/test/settings/state", "state=archived") | |
| 323 | + | .await; | |
| 324 | + | assert!(resp.status.is_redirection(), "status: {}", resp.status); | |
| 325 | + | ||
| 326 | + | let state: String = | |
| 327 | + | sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") | |
| 328 | + | .bind(comm_id) | |
| 329 | + | .fetch_one(&h.db) | |
| 330 | + | .await | |
| 331 | + | .unwrap(); | |
| 332 | + | assert_eq!(state, "archived"); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | #[tokio::test] | |
| 336 | + | async fn member_cannot_change_state() { | |
| 337 | + | let mut h = TestHarness::new().await; | |
| 338 | + | let user_id = h.login_as("nope").await; | |
| 339 | + | let (comm_id, _cat) = setup_community(&mut h, "active").await; | |
| 340 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 341 | + | ||
| 342 | + | h.client.get("/p/test/settings").await; | |
| 343 | + | let resp = h | |
| 344 | + | .client | |
| 345 | + | .post_form("/p/test/settings/state", "state=frozen") | |
| 346 | + | .await; | |
| 347 | + | assert_eq!(resp.status, StatusCode::FORBIDDEN); | |
| 348 | + | ||
| 349 | + | let state: String = | |
| 350 | + | sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") | |
| 351 | + | .bind(comm_id) | |
| 352 | + | .fetch_one(&h.db) | |
| 353 | + | .await | |
| 354 | + | .unwrap(); | |
| 355 | + | assert_eq!(state, "active", "state should be unchanged"); | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | #[tokio::test] | |
| 359 | + | async fn state_change_rejects_unknown_value() { | |
| 360 | + | let mut h = TestHarness::new().await; | |
| 361 | + | let user_id = h.login_as("ownerbad").await; | |
| 362 | + | let (comm_id, _cat) = setup_community(&mut h, "active").await; | |
| 363 | + | h.add_membership(user_id, comm_id, "owner").await; | |
| 364 | + | ||
| 365 | + | h.client.get("/p/test/settings").await; | |
| 366 | + | let resp = h | |
| 367 | + | .client | |
| 368 | + | .post_form("/p/test/settings/state", "state=bogus") | |
| 369 | + | .await; | |
| 370 | + | assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); | |
| 371 | + | ||
| 372 | + | let state: String = | |
| 373 | + | sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") | |
| 374 | + | .bind(comm_id) | |
| 375 | + | .fetch_one(&h.db) | |
| 376 | + | .await | |
| 377 | + | .unwrap(); | |
| 378 | + | assert_eq!(state, "active", "state should be unchanged on validation error"); | |
| 379 | + | } |
| @@ -0,0 +1,66 @@ | |||
| 1 | + | # Forum moderation policy | |
| 2 | + | ||
| 3 | + | How MNW handles communities that drift into neglect or hostility. This is the public version of the policy; the working internal version lives in the Multithreaded source tree. | |
| 4 | + | ||
| 5 | + | ## Principle | |
| 6 | + | ||
| 7 | + | MNW communities are creator-moderated. We are not the content police. But we do enforce a quality floor: communities that stop being maintained will be addressed, because neglected spaces harm fans and reflect on the platform. | |
| 8 | + | ||
| 9 | + | The goal is always to give the creator a real path forward, not to punish. | |
| 10 | + | ||
| 11 | + | ## State ladder | |
| 12 | + | ||
| 13 | + | Every community sits in one of four states. State changes are reversible — a community can move freely between them. The audit log records every transition with the actor and timestamp. | |
| 14 | + | ||
| 15 | + | ### Active | |
| 16 | + | ||
| 17 | + | The default. Members can start threads, reply, and use forum features as their Fan+ status allows. | |
| 18 | + | ||
| 19 | + | ### Restricted | |
| 20 | + | ||
| 21 | + | New thread creation is disabled for non-moderator accounts. Existing threads still accept replies. Mods and platform admin keep full access. | |
| 22 | + | ||
| 23 | + | Typical trigger: unresolved flags older than ~14 days, or a pattern of reports going unaddressed. | |
| 24 | + | ||
| 25 | + | ### Frozen | |
| 26 | + | ||
| 27 | + | Read-only for everyone except moderators performing moderation actions. Members cannot post, reply, footnote, or endorse. Mods can clear the backlog, ban bad actors, and remove posts — once the queue is addressed, they unfreeze. | |
| 28 | + | ||
| 29 | + | Typical trigger: continued inaction after a Restricted period. | |
| 30 | + | ||
| 31 | + | ### Archived | |
| 32 | + | ||
| 33 | + | Behaves like Frozen and is hidden from the default forum directory. Still reachable by direct URL. Surfaces under the explicit "View archived" filter. | |
| 34 | + | ||
| 35 | + | Reactivation is just a state change back to Active. | |
| 36 | + | ||
| 37 | + | ## Clean slate | |
| 38 | + | ||
| 39 | + | A creator (or platform admin acting on the creator's behalf) can request a clean slate: every thread, post, footnote, endorsement, and flag is deleted. The community row, categories, settings, members, bans, and mutes are preserved. | |
| 40 | + | ||
| 41 | + | After the wipe, a single pinned, locked notice is posted in the first category recording who reset the forum and when. No explanation. No blame. No platform statement. | |
| 42 | + | ||
| 43 | + | The clean slate is irreversible at the data level, so the admin UI requires typing the community slug to confirm — the same pattern GitHub uses for repository deletion. | |
| 44 | + | ||
| 45 | + | The creator's account, project, tier, payment history, and fan relationships are untouched by a clean slate. | |
| 46 | + | ||
| 47 | + | ## What MNW never does | |
| 48 | + | ||
| 49 | + | - Public announcement explaining why a community was reset or moved between states. | |
| 50 | + | - Marking the creator's profile, project page, or storefront with the moderation history. | |
| 51 | + | - Discussing the situation with other creators or fans. | |
| 52 | + | - Using forum moderation history as a factor in unrelated account decisions. | |
| 53 | + | - Naming or shaming in any context. | |
| 54 | + | ||
| 55 | + | ## Authorization | |
| 56 | + | ||
| 57 | + | State changes and clean slates require either: | |
| 58 | + | ||
| 59 | + | - A community **Owner** or **Moderator** acting within that community, or | |
| 60 | + | - The **platform admin** acting from the dedicated admin view. | |
| 61 | + | ||
| 62 | + | A robust per-permission system is on the roadmap; for now, this two-layer model is what the Multithreaded forum software enforces. | |
| 63 | + | ||
| 64 | + | ## Reporting a problem | |
| 65 | + | ||
| 66 | + | If you encounter content or behavior that violates a community's rules, use the flag button on the post. Flags route to that community's moderators first. If the community's mods are non-responsive, MNW staff review unresolved flags as part of platform-level monitoring. |