max / multithreaded
27 files changed,
+1421 insertions,
-591 deletions
| @@ -1008,6 +1008,17 @@ dependencies = [ | |||
| 1008 | 1008 | ] | |
| 1009 | 1009 | ||
| 1010 | 1010 | [[package]] | |
| 1011 | + | name = "docengine" | |
| 1012 | + | version = "0.3.0" | |
| 1013 | + | dependencies = [ | |
| 1014 | + | "ammonia", | |
| 1015 | + | "pulldown-cmark", | |
| 1016 | + | "regex-lite", | |
| 1017 | + | "serde", | |
| 1018 | + | "uuid", | |
| 1019 | + | ] | |
| 1020 | + | ||
| 1021 | + | [[package]] | |
| 1011 | 1022 | name = "dotenvy" | |
| 1012 | 1023 | version = "0.15.7" | |
| 1013 | 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2070,6 +2081,7 @@ name = "mt-db" | |||
| 2070 | 2081 | version = "0.3.1" | |
| 2071 | 2082 | dependencies = [ | |
| 2072 | 2083 | "chrono", | |
| 2084 | + | "mt-core", | |
| 2073 | 2085 | "serde", | |
| 2074 | 2086 | "sqlx", | |
| 2075 | 2087 | "tracing", | |
| @@ -2097,16 +2109,17 @@ dependencies = [ | |||
| 2097 | 2109 | name = "multithreaded" | |
| 2098 | 2110 | version = "0.3.1" | |
| 2099 | 2111 | dependencies = [ | |
| 2100 | - | "ammonia", | |
| 2101 | 2112 | "askama", | |
| 2102 | 2113 | "aws-config", | |
| 2103 | 2114 | "aws-sdk-s3", | |
| 2104 | 2115 | "axum", | |
| 2105 | 2116 | "base64", | |
| 2106 | 2117 | "chrono", | |
| 2118 | + | "docengine", | |
| 2107 | 2119 | "dotenvy", | |
| 2108 | 2120 | "governor", | |
| 2109 | 2121 | "hex", | |
| 2122 | + | "hmac", | |
| 2110 | 2123 | "http-body-util", | |
| 2111 | 2124 | "mt-core", | |
| 2112 | 2125 | "mt-db", | |
| @@ -2118,6 +2131,7 @@ dependencies = [ | |||
| 2118 | 2131 | "serde_json", | |
| 2119 | 2132 | "sha2", | |
| 2120 | 2133 | "sqlx", | |
| 2134 | + | "tagtree", | |
| 2121 | 2135 | "time", | |
| 2122 | 2136 | "tokio", | |
| 2123 | 2137 | "tower", | |
| @@ -3462,6 +3476,10 @@ dependencies = [ | |||
| 3462 | 3476 | ] | |
| 3463 | 3477 | ||
| 3464 | 3478 | [[package]] | |
| 3479 | + | name = "tagtree" | |
| 3480 | + | version = "0.3.0" | |
| 3481 | + | ||
| 3482 | + | [[package]] | |
| 3465 | 3483 | name = "tendril" | |
| 3466 | 3484 | version = "0.4.3" | |
| 3467 | 3485 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -30,6 +30,7 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } | |||
| 30 | 30 | # HTTP client / crypto | |
| 31 | 31 | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 32 | 32 | sha2 = "0.10" | |
| 33 | + | hmac = "0.12" | |
| 33 | 34 | base64 = "0.22" | |
| 34 | 35 | rand = "0.8" | |
| 35 | 36 | ||
| @@ -48,12 +49,12 @@ governor = "0.8" | |||
| 48 | 49 | chrono = { version = "0.4", features = ["serde"] } | |
| 49 | 50 | uuid = { version = "1", features = ["v4", "serde"] } | |
| 50 | 51 | pulldown-cmark = "0.12" | |
| 51 | - | ammonia = "4" | |
| 52 | 52 | askama = "0.13" | |
| 53 | 53 | ||
| 54 | 54 | # Internal crates | |
| 55 | 55 | mt-core = { path = "crates/mt-core" } | |
| 56 | 56 | mt-db = { path = "crates/mt-db" } | |
| 57 | + | tagtree = { path = "../tagtree" } | |
| 57 | 58 | ||
| 58 | 59 | [package] | |
| 59 | 60 | name = "multithreaded" | |
| @@ -82,13 +83,15 @@ sha2 = { workspace = true } | |||
| 82 | 83 | base64 = { workspace = true } | |
| 83 | 84 | rand = { workspace = true } | |
| 84 | 85 | pulldown-cmark = { workspace = true } | |
| 85 | - | ammonia = { workspace = true } | |
| 86 | + | docengine = { path = "../docengine", features = ["mentions", "quotes"] } | |
| 87 | + | tagtree = { workspace = true } | |
| 86 | 88 | tower_governor = { workspace = true } | |
| 87 | 89 | governor = { workspace = true } | |
| 88 | 90 | aws-sdk-s3 = { workspace = true } | |
| 89 | 91 | aws-config = { workspace = true } | |
| 90 | 92 | dotenvy = "0.15" | |
| 91 | 93 | hex = "0.4" | |
| 94 | + | hmac = { workspace = true } | |
| 92 | 95 | regex-lite = "0.1" | |
| 93 | 96 | urlencoding = "2" | |
| 94 | 97 | time = "0.3" |
| @@ -1,3 +1,4 @@ | |||
| 1 | 1 | //! Core domain types and utilities for Multithreaded. | |
| 2 | 2 | ||
| 3 | 3 | pub mod time_format; | |
| 4 | + | pub mod types; |
| @@ -0,0 +1,263 @@ | |||
| 1 | + | //! Typed enums replacing raw string values across the codebase. | |
| 2 | + | ||
| 3 | + | use std::fmt; | |
| 4 | + | ||
| 5 | + | // ============================================================================ | |
| 6 | + | // CommunityRole — owner, moderator, member | |
| 7 | + | // ============================================================================ | |
| 8 | + | ||
| 9 | + | /// Role a user holds within a community. | |
| 10 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | + | pub enum CommunityRole { | |
| 12 | + | Owner, | |
| 13 | + | Moderator, | |
| 14 | + | Member, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl CommunityRole { | |
| 18 | + | /// Parse from the string stored in the database. Returns `None` for unrecognised values. | |
| 19 | + | pub fn from_db(s: &str) -> Option<Self> { | |
| 20 | + | match s { | |
| 21 | + | "owner" => Some(Self::Owner), | |
| 22 | + | "moderator" => Some(Self::Moderator), | |
| 23 | + | "member" => Some(Self::Member), | |
| 24 | + | _ => None, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// The string stored in the database. | |
| 29 | + | pub fn as_str(self) -> &'static str { | |
| 30 | + | match self { | |
| 31 | + | Self::Owner => "owner", | |
| 32 | + | Self::Moderator => "moderator", | |
| 33 | + | Self::Member => "member", | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | pub fn is_mod_or_owner(self) -> bool { | |
| 38 | + | matches!(self, Self::Owner | Self::Moderator) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | pub fn is_owner(self) -> bool { | |
| 42 | + | matches!(self, Self::Owner) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl fmt::Display for CommunityRole { | |
| 47 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 48 | + | f.write_str(self.as_str()) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // ============================================================================ | |
| 53 | + | // BanType — ban or mute | |
| 54 | + | // ============================================================================ | |
| 55 | + | ||
| 56 | + | /// Type of community restriction. | |
| 57 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 58 | + | pub enum BanType { | |
| 59 | + | Ban, | |
| 60 | + | Mute, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | impl BanType { | |
| 64 | + | pub fn as_str(self) -> &'static str { | |
| 65 | + | match self { | |
| 66 | + | Self::Ban => "ban", | |
| 67 | + | Self::Mute => "mute", | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | impl fmt::Display for BanType { | |
| 73 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 74 | + | f.write_str(self.as_str()) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ============================================================================ | |
| 79 | + | // ModAction — actions recorded in the mod log | |
| 80 | + | // ============================================================================ | |
| 81 | + | ||
| 82 | + | /// Action recorded in the moderation log. | |
| 83 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 84 | + | pub enum ModAction { | |
| 85 | + | PinThread, | |
| 86 | + | UnpinThread, | |
| 87 | + | LockThread, | |
| 88 | + | UnlockThread, | |
| 89 | + | RemovePost, | |
| 90 | + | DeleteThread, | |
| 91 | + | Ban, | |
| 92 | + | Unban, | |
| 93 | + | Mute, | |
| 94 | + | Unmute, | |
| 95 | + | EditSettings, | |
| 96 | + | CreateCategory, | |
| 97 | + | EditCategory, | |
| 98 | + | SuspendCommunity, | |
| 99 | + | UnsuspendCommunity, | |
| 100 | + | SuspendUser, | |
| 101 | + | UnsuspendUser, | |
| 102 | + | AutoHidePost, | |
| 103 | + | RemovePostViaFlag, | |
| 104 | + | RemoveImage, | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | impl ModAction { | |
| 108 | + | pub fn as_str(self) -> &'static str { | |
| 109 | + | match self { | |
| 110 | + | Self::PinThread => "pin_thread", | |
| 111 | + | Self::UnpinThread => "unpin_thread", | |
| 112 | + | Self::LockThread => "lock_thread", | |
| 113 | + | Self::UnlockThread => "unlock_thread", | |
| 114 | + | Self::RemovePost => "remove_post", | |
| 115 | + | Self::DeleteThread => "delete_thread", | |
| 116 | + | Self::Ban => "ban", | |
| 117 | + | Self::Unban => "unban", | |
| 118 | + | Self::Mute => "mute", | |
| 119 | + | Self::Unmute => "unmute", | |
| 120 | + | Self::EditSettings => "edit_settings", | |
| 121 | + | Self::CreateCategory => "create_category", | |
| 122 | + | Self::EditCategory => "edit_category", | |
| 123 | + | Self::SuspendCommunity => "suspend_community", | |
| 124 | + | Self::UnsuspendCommunity => "unsuspend_community", | |
| 125 | + | Self::SuspendUser => "suspend_user", | |
| 126 | + | Self::UnsuspendUser => "unsuspend_user", | |
| 127 | + | Self::AutoHidePost => "auto_hide_post", | |
| 128 | + | Self::RemovePostViaFlag => "remove_post_via_flag", | |
| 129 | + | Self::RemoveImage => "remove_image", | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | impl fmt::Display for ModAction { | |
| 135 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 136 | + | f.write_str(self.as_str()) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // ============================================================================ | |
| 141 | + | // SortOrder — ascending or descending | |
| 142 | + | // ============================================================================ | |
| 143 | + | ||
| 144 | + | /// Sort direction for paginated listings. | |
| 145 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 146 | + | pub enum SortOrder { | |
| 147 | + | Asc, | |
| 148 | + | Desc, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | impl SortOrder { | |
| 152 | + | /// Parse from query string value; defaults to `Desc` for unrecognised input. | |
| 153 | + | pub fn from_query(s: Option<&str>) -> Self { | |
| 154 | + | match s { | |
| 155 | + | Some("asc") => Self::Asc, | |
| 156 | + | _ => Self::Desc, | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | pub fn as_str(self) -> &'static str { | |
| 161 | + | match self { | |
| 162 | + | Self::Asc => "asc", | |
| 163 | + | Self::Desc => "desc", | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | impl fmt::Display for SortOrder { | |
| 169 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 170 | + | f.write_str(self.as_str()) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // ============================================================================ | |
| 175 | + | // SortColumn — which column to sort threads by | |
| 176 | + | // ============================================================================ | |
| 177 | + | ||
| 178 | + | /// Column used to sort thread listings. | |
| 179 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 180 | + | pub enum SortColumn { | |
| 181 | + | Activity, | |
| 182 | + | Replies, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | impl SortColumn { | |
| 186 | + | /// Parse from query string value; defaults to `Activity` for unrecognised input. | |
| 187 | + | pub fn from_query(s: Option<&str>) -> Self { | |
| 188 | + | match s { | |
| 189 | + | Some("replies") => Self::Replies, | |
| 190 | + | _ => Self::Activity, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | pub fn as_str(self) -> &'static str { | |
| 195 | + | match self { | |
| 196 | + | Self::Activity => "activity", | |
| 197 | + | Self::Replies => "replies", | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | impl fmt::Display for SortColumn { | |
| 203 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 204 | + | f.write_str(self.as_str()) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[cfg(test)] | |
| 209 | + | mod tests { | |
| 210 | + | use super::*; | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn community_role_roundtrip() { | |
| 214 | + | for role in [CommunityRole::Owner, CommunityRole::Moderator, CommunityRole::Member] { | |
| 215 | + | assert_eq!(CommunityRole::from_db(role.as_str()), Some(role)); | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn community_role_unknown_returns_none() { | |
| 221 | + | assert_eq!(CommunityRole::from_db("admin"), None); | |
| 222 | + | assert_eq!(CommunityRole::from_db(""), None); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn community_role_permissions() { | |
| 227 | + | assert!(CommunityRole::Owner.is_mod_or_owner()); | |
| 228 | + | assert!(CommunityRole::Owner.is_owner()); | |
| 229 | + | assert!(CommunityRole::Moderator.is_mod_or_owner()); | |
| 230 | + | assert!(!CommunityRole::Moderator.is_owner()); | |
| 231 | + | assert!(!CommunityRole::Member.is_mod_or_owner()); | |
| 232 | + | assert!(!CommunityRole::Member.is_owner()); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn ban_type_display() { | |
| 237 | + | assert_eq!(BanType::Ban.as_str(), "ban"); | |
| 238 | + | assert_eq!(BanType::Mute.as_str(), "mute"); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn mod_action_display() { | |
| 243 | + | assert_eq!(ModAction::PinThread.as_str(), "pin_thread"); | |
| 244 | + | assert_eq!(ModAction::Ban.as_str(), "ban"); | |
| 245 | + | assert_eq!(ModAction::RemoveImage.as_str(), "remove_image"); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn sort_order_from_query() { | |
| 250 | + | assert_eq!(SortOrder::from_query(Some("asc")), SortOrder::Asc); | |
| 251 | + | assert_eq!(SortOrder::from_query(Some("desc")), SortOrder::Desc); | |
| 252 | + | assert_eq!(SortOrder::from_query(Some("bogus")), SortOrder::Desc); | |
| 253 | + | assert_eq!(SortOrder::from_query(None), SortOrder::Desc); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn sort_column_from_query() { | |
| 258 | + | assert_eq!(SortColumn::from_query(Some("replies")), SortColumn::Replies); | |
| 259 | + | assert_eq!(SortColumn::from_query(Some("activity")), SortColumn::Activity); | |
| 260 | + | assert_eq!(SortColumn::from_query(Some("bogus")), SortColumn::Activity); | |
| 261 | + | assert_eq!(SortColumn::from_query(None), SortColumn::Activity); | |
| 262 | + | } | |
| 263 | + | } |
| @@ -4,6 +4,7 @@ version.workspace = true | |||
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| 7 | + | mt-core = { workspace = true } | |
| 7 | 8 | sqlx = { workspace = true } | |
| 8 | 9 | chrono = { workspace = true } | |
| 9 | 10 | uuid = { workspace = true } |
| @@ -1,9 +1,99 @@ | |||
| 1 | 1 | //! Database write mutations — inserts, updates, deletes. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | + | use mt_core::types::{BanType, ModAction}; | |
| 4 | 5 | use sqlx::PgPool; | |
| 5 | 6 | use uuid::Uuid; | |
| 6 | 7 | ||
| 8 | + | /// Create a new community and return its ID. | |
| 9 | + | #[tracing::instrument(skip_all)] | |
| 10 | + | pub async fn create_community( | |
| 11 | + | pool: &PgPool, | |
| 12 | + | name: &str, | |
| 13 | + | slug: &str, | |
| 14 | + | description: Option<&str>, | |
| 15 | + | ) -> Result<Uuid, sqlx::Error> { | |
| 16 | + | let row: (Uuid,) = sqlx::query_as( | |
| 17 | + | "INSERT INTO communities (name, slug, description) | |
| 18 | + | VALUES ($1, $2, $3) | |
| 19 | + | RETURNING id", | |
| 20 | + | ) | |
| 21 | + | .bind(name) | |
| 22 | + | .bind(slug) | |
| 23 | + | .bind(description) | |
| 24 | + | .fetch_one(pool) | |
| 25 | + | .await?; | |
| 26 | + | Ok(row.0) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Upsert a user from MNW account data. Creates the user if they don't exist, | |
| 30 | + | /// updates username/display_name if they do. | |
| 31 | + | #[tracing::instrument(skip_all)] | |
| 32 | + | pub async fn upsert_user( | |
| 33 | + | pool: &PgPool, | |
| 34 | + | mnw_account_id: Uuid, | |
| 35 | + | username: &str, | |
| 36 | + | display_name: Option<&str>, | |
| 37 | + | ) -> Result<(), sqlx::Error> { | |
| 38 | + | sqlx::query( | |
| 39 | + | "INSERT INTO users (mnw_account_id, username, display_name) | |
| 40 | + | VALUES ($1, $2, $3) | |
| 41 | + | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 42 | + | SET username = $2, display_name = $3, updated_at = now()", | |
| 43 | + | ) | |
| 44 | + | .bind(mnw_account_id) | |
| 45 | + | .bind(username) | |
| 46 | + | .bind(display_name) | |
| 47 | + | .execute(pool) | |
| 48 | + | .await?; | |
| 49 | + | Ok(()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Ensure a user has a membership in a community with the given role. | |
| 53 | + | /// Creates membership if none exists, does nothing if already a member. | |
| 54 | + | #[tracing::instrument(skip_all)] | |
| 55 | + | pub async fn ensure_membership_with_role( | |
| 56 | + | pool: &PgPool, | |
| 57 | + | user_id: Uuid, | |
| 58 | + | community_id: Uuid, | |
| 59 | + | role: &str, | |
| 60 | + | ) -> Result<(), sqlx::Error> { | |
| 61 | + | sqlx::query( | |
| 62 | + | "INSERT INTO memberships (user_id, community_id, role) | |
| 63 | + | VALUES ($1, $2, $3) | |
| 64 | + | ON CONFLICT (user_id, community_id) DO NOTHING", | |
| 65 | + | ) | |
| 66 | + | .bind(user_id) | |
| 67 | + | .bind(community_id) | |
| 68 | + | .bind(role) | |
| 69 | + | .execute(pool) | |
| 70 | + | .await?; | |
| 71 | + | Ok(()) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Create a thread with an external reference. Returns the thread ID. | |
| 75 | + | #[tracing::instrument(skip_all)] | |
| 76 | + | pub async fn create_thread_with_external_ref( | |
| 77 | + | pool: &PgPool, | |
| 78 | + | category_id: Uuid, | |
| 79 | + | author_id: Uuid, | |
| 80 | + | title: &str, | |
| 81 | + | external_ref: &str, | |
| 82 | + | ) -> Result<Uuid, sqlx::Error> { | |
| 83 | + | let row: (Uuid,) = sqlx::query_as( | |
| 84 | + | "INSERT INTO threads (category_id, author_id, title, external_ref) | |
| 85 | + | VALUES ($1, $2, $3, $4) | |
| 86 | + | RETURNING id", | |
| 87 | + | ) | |
| 88 | + | .bind(category_id) | |
| 89 | + | .bind(author_id) | |
| 90 | + | .bind(title) | |
| 91 | + | .bind(external_ref) | |
| 92 | + | .fetch_one(pool) | |
| 93 | + | .await?; | |
| 94 | + | Ok(row.0) | |
| 95 | + | } | |
| 96 | + | ||
| 7 | 97 | /// Ensure a user has a membership in a community. Creates a 'member' role if none exists. | |
| 8 | 98 | #[tracing::instrument(skip_all)] | |
| 9 | 99 | pub async fn ensure_membership( | |
| @@ -44,7 +134,7 @@ pub async fn create_thread( | |||
| 44 | 134 | Ok(row.0) | |
| 45 | 135 | } | |
| 46 | 136 | ||
| 47 | - | /// Insert a new post and bump the thread's last_activity_at. | |
| 137 | + | /// Insert a new post and bump the thread's last_activity_at atomically. | |
| 48 | 138 | #[tracing::instrument(skip_all)] | |
| 49 | 139 | pub async fn create_post( | |
| 50 | 140 | pool: &PgPool, | |
| @@ -53,6 +143,8 @@ pub async fn create_post( | |||
| 53 | 143 | body_markdown: &str, | |
| 54 | 144 | body_html: &str, | |
| 55 | 145 | ) -> Result<Uuid, sqlx::Error> { | |
| 146 | + | let mut tx = pool.begin().await?; | |
| 147 | + | ||
| 56 | 148 | let row: (Uuid,) = sqlx::query_as( | |
| 57 | 149 | "INSERT INTO posts (thread_id, author_id, body_markdown, body_html) | |
| 58 | 150 | VALUES ($1, $2, $3, $4) | |
| @@ -62,14 +154,15 @@ pub async fn create_post( | |||
| 62 | 154 | .bind(author_id) | |
| 63 | 155 | .bind(body_markdown) | |
| 64 | 156 | .bind(body_html) | |
| 65 | - | .fetch_one(pool) | |
| 157 | + | .fetch_one(&mut *tx) | |
| 66 | 158 | .await?; | |
| 67 | 159 | ||
| 68 | 160 | sqlx::query("UPDATE threads SET last_activity_at = now() WHERE id = $1") | |
| 69 | 161 | .bind(thread_id) | |
| 70 | - | .execute(pool) | |
| 162 | + | .execute(&mut *tx) | |
| 71 | 163 | .await?; | |
| 72 | 164 | ||
| 165 | + | tx.commit().await?; | |
| 73 | 166 | Ok(row.0) | |
| 74 | 167 | } | |
| 75 | 168 | ||
| @@ -311,7 +404,7 @@ pub async fn create_community_ban( | |||
| 311 | 404 | community_id: Uuid, | |
| 312 | 405 | user_id: Uuid, | |
| 313 | 406 | banned_by: Uuid, | |
| 314 | - | ban_type: &str, | |
| 407 | + | ban_type: BanType, | |
| 315 | 408 | reason: Option<&str>, | |
| 316 | 409 | expires_at: Option<DateTime<Utc>>, | |
| 317 | 410 | ) -> Result<Uuid, sqlx::Error> { | |
| @@ -325,7 +418,7 @@ pub async fn create_community_ban( | |||
| 325 | 418 | .bind(community_id) | |
| 326 | 419 | .bind(user_id) | |
| 327 | 420 | .bind(banned_by) | |
| 328 | - | .bind(ban_type) | |
| 421 | + | .bind(ban_type.as_str()) | |
| 329 | 422 | .bind(reason) | |
| 330 | 423 | .bind(expires_at) | |
| 331 | 424 | .fetch_one(pool) | |
| @@ -352,7 +445,7 @@ pub async fn remove_community_ban( | |||
| 352 | 445 | pool: &PgPool, | |
| 353 | 446 | community_id: Uuid, | |
| 354 | 447 | user_id: Uuid, | |
| 355 | - | ban_type: &str, | |
| 448 | + | ban_type: BanType, | |
| 356 | 449 | ) -> Result<(), sqlx::Error> { | |
| 357 | 450 | sqlx::query( | |
| 358 | 451 | "DELETE FROM community_bans | |
| @@ -360,7 +453,7 @@ pub async fn remove_community_ban( | |||
| 360 | 453 | ) | |
| 361 | 454 | .bind(community_id) | |
| 362 | 455 | .bind(user_id) | |
| 363 | - | .bind(ban_type) | |
| 456 | + | .bind(ban_type.as_str()) | |
| 364 | 457 | .execute(pool) | |
| 365 | 458 | .await?; | |
| 366 | 459 | Ok(()) | |
| @@ -372,7 +465,7 @@ pub async fn insert_mod_log( | |||
| 372 | 465 | pool: &PgPool, | |
| 373 | 466 | community_id: Option<Uuid>, | |
| 374 | 467 | actor_id: Uuid, | |
| 375 | - | action: &str, | |
| 468 | + | action: ModAction, | |
| 376 | 469 | target_user: Option<Uuid>, | |
| 377 | 470 | target_id: Option<Uuid>, | |
| 378 | 471 | reason: Option<&str>, | |
| @@ -383,7 +476,7 @@ pub async fn insert_mod_log( | |||
| 383 | 476 | ) | |
| 384 | 477 | .bind(community_id) | |
| 385 | 478 | .bind(actor_id) | |
| 386 | - | .bind(action) | |
| 479 | + | .bind(action.as_str()) | |
| 387 | 480 | .bind(target_user) | |
| 388 | 481 | .bind(target_id) | |
| 389 | 482 | .bind(reason) |
| @@ -98,6 +98,61 @@ pub struct PostForEdit { | |||
| 98 | 98 | pub category_slug: String, | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | /// A category row with its ID for internal API lookups. | |
| 102 | + | #[derive(sqlx::FromRow)] | |
| 103 | + | pub struct CategoryIdRow { | |
| 104 | + | pub id: Uuid, | |
| 105 | + | pub name: String, | |
| 106 | + | pub slug: String, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Look up a category by community_id and category slug. Returns the category ID. | |
| 110 | + | #[tracing::instrument(skip_all)] | |
| 111 | + | pub async fn get_category_by_community_and_slug( | |
| 112 | + | pool: &PgPool, | |
| 113 | + | community_id: Uuid, | |
| 114 | + | category_slug: &str, | |
| 115 | + | ) -> Result<Option<CategoryIdRow>, sqlx::Error> { | |
| 116 | + | sqlx::query_as::<_, CategoryIdRow>( | |
| 117 | + | "SELECT id, name, slug FROM categories WHERE community_id = $1 AND slug = $2", | |
| 118 | + | ) | |
| 119 | + | .bind(community_id) | |
| 120 | + | .bind(category_slug) | |
| 121 | + | .fetch_optional(pool) | |
| 122 | + | .await | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Look up a thread by its external reference (e.g., "mnw:item:uuid"). | |
| 126 | + | #[tracing::instrument(skip_all)] | |
| 127 | + | pub async fn get_thread_by_external_ref( | |
| 128 | + | pool: &PgPool, | |
| 129 | + | external_ref: &str, | |
| 130 | + | ) -> Result<Option<(Uuid,)>, sqlx::Error> { | |
| 131 | + | sqlx::query_as::<_, (Uuid,)>( | |
| 132 | + | "SELECT id FROM threads WHERE external_ref = $1", | |
| 133 | + | ) | |
| 134 | + | .bind(external_ref) | |
| 135 | + | .fetch_optional(pool) | |
| 136 | + | .await | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Get thread stats: post count and last activity timestamp. | |
| 140 | + | #[tracing::instrument(skip_all)] | |
| 141 | + | #[allow(clippy::type_complexity)] | |
| 142 | + | pub async fn get_thread_stats( | |
| 143 | + | pool: &PgPool, | |
| 144 | + | thread_id: Uuid, | |
| 145 | + | ) -> Result<Option<(i64, Option<DateTime<Utc>>)>, sqlx::Error> { | |
| 146 | + | sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>( | |
| 147 | + | "SELECT COUNT(p.id), MAX(p.created_at) | |
| 148 | + | FROM posts p | |
| 149 | + | WHERE p.thread_id = $1", | |
| 150 | + | ) | |
| 151 | + | .bind(thread_id) | |
| 152 | + | .fetch_optional(pool) | |
| 153 | + | .await | |
| 154 | + | } | |
| 155 | + | ||
| 101 | 156 | // ============================================================================ | |
| 102 | 157 | // Queries | |
| 103 | 158 | // ============================================================================ |
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- Internal API support: external reference for threads created by MNW. | |
| 2 | + | ALTER TABLE threads ADD COLUMN external_ref TEXT; | |
| 3 | + | CREATE UNIQUE INDEX idx_threads_external_ref ON threads (external_ref) WHERE external_ref IS NOT NULL; |
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | //! Application configuration read from environment variables. | |
| 2 | 2 | ||
| 3 | + | use std::sync::Arc; | |
| 3 | 4 | use uuid::Uuid; | |
| 4 | 5 | ||
| 5 | 6 | #[derive(Clone)] | |
| 6 | 7 | pub struct Config { | |
| 7 | - | pub mnw_base_url: String, | |
| 8 | + | pub mnw_base_url: Arc<str>, | |
| 8 | 9 | pub oauth_client_id: String, | |
| 9 | 10 | pub oauth_redirect_uri: String, | |
| 10 | 11 | pub platform_admin_id: Option<Uuid>, | |
| @@ -13,6 +14,8 @@ pub struct Config { | |||
| 13 | 14 | pub cookie_secure: bool, | |
| 14 | 15 | /// S3 storage configuration. None if S3 env vars are missing. | |
| 15 | 16 | pub s3: Option<S3Config>, | |
| 17 | + | /// Shared secret for HMAC-signed internal API requests from MNW. | |
| 18 | + | pub internal_shared_secret: Option<String>, | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | 21 | #[derive(Clone)] | |
| @@ -39,7 +42,8 @@ impl Config { | |||
| 39 | 42 | pub fn from_env() -> Self { | |
| 40 | 43 | Self { | |
| 41 | 44 | mnw_base_url: std::env::var("MNW_BASE_URL") | |
| 42 | - | .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()), | |
| 45 | + | .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()) | |
| 46 | + | .into(), | |
| 43 | 47 | oauth_client_id: std::env::var("OAUTH_CLIENT_ID") | |
| 44 | 48 | .expect("OAUTH_CLIENT_ID must be set"), | |
| 45 | 49 | oauth_redirect_uri: std::env::var("OAUTH_REDIRECT_URI") | |
| @@ -51,6 +55,7 @@ impl Config { | |||
| 51 | 55 | .map(|v| v != "false") | |
| 52 | 56 | .unwrap_or(true), | |
| 53 | 57 | s3: S3Config::from_env(), | |
| 58 | + | internal_shared_secret: std::env::var("INTERNAL_SHARED_SECRET").ok(), | |
| 54 | 59 | } | |
| 55 | 60 | } | |
| 56 | 61 | } |
| @@ -0,0 +1,130 @@ | |||
| 1 | + | //! HMAC-SHA256 authentication for internal API requests from MNW. | |
| 2 | + | //! | |
| 3 | + | //! MNW signs requests with `HMAC-SHA256(timestamp + "\n" + body, secret)`. | |
| 4 | + | //! The signature and timestamp are sent in `X-Internal-Signature` and | |
| 5 | + | //! `X-Internal-Timestamp` headers. Requests older than 60 seconds are rejected. | |
| 6 | + | ||
| 7 | + | use axum::{ | |
| 8 | + | body::Bytes, | |
| 9 | + | extract::{FromRequest, Request}, | |
| 10 | + | http::StatusCode, | |
| 11 | + | response::{IntoResponse, Response}, | |
| 12 | + | }; | |
| 13 | + | use hmac::{Hmac, Mac}; | |
| 14 | + | use sha2::Sha256; | |
| 15 | + | ||
| 16 | + | use crate::AppState; | |
| 17 | + | ||
| 18 | + | /// Maximum age (in seconds) for an internal request timestamp before it's rejected. | |
| 19 | + | const MAX_TIMESTAMP_AGE_SECS: i64 = 60; | |
| 20 | + | ||
| 21 | + | /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests. | |
| 22 | + | /// Extracts the raw request body as `Bytes` after successful verification. | |
| 23 | + | pub struct InternalAuth(pub Bytes); | |
| 24 | + | ||
| 25 | + | impl FromRequest<AppState> for InternalAuth { | |
| 26 | + | type Rejection = Response; | |
| 27 | + | ||
| 28 | + | async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> { | |
| 29 | + | let secret = state | |
| 30 | + | .config | |
| 31 | + | .internal_shared_secret | |
| 32 | + | .as_deref() | |
| 33 | + | .ok_or_else(|| { | |
| 34 | + | tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured"); | |
| 35 | + | StatusCode::SERVICE_UNAVAILABLE.into_response() | |
| 36 | + | })?; | |
| 37 | + | ||
| 38 | + | let timestamp_str = req | |
| 39 | + | .headers() | |
| 40 | + | .get("X-Internal-Timestamp") | |
| 41 | + | .and_then(|v| v.to_str().ok()) | |
| 42 | + | .ok_or_else(|| { | |
| 43 | + | (StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp").into_response() | |
| 44 | + | })? | |
| 45 | + | .to_string(); | |
| 46 | + | ||
| 47 | + | let signature = req | |
| 48 | + | .headers() | |
| 49 | + | .get("X-Internal-Signature") | |
| 50 | + | .and_then(|v| v.to_str().ok()) | |
| 51 | + | .ok_or_else(|| { | |
| 52 | + | (StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature").into_response() | |
| 53 | + | })? | |
| 54 | + | .to_string(); | |
| 55 | + | ||
| 56 | + | // Verify timestamp freshness | |
| 57 | + | let timestamp: i64 = timestamp_str.parse().map_err(|_| { | |
| 58 | + | (StatusCode::UNAUTHORIZED, "Invalid timestamp").into_response() | |
| 59 | + | })?; | |
| 60 | + | ||
| 61 | + | let now = chrono::Utc::now().timestamp(); | |
| 62 | + | if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS { | |
| 63 | + | return Err( | |
| 64 | + | (StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future") | |
| 65 | + | .into_response(), | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Read body | |
| 70 | + | let body = Bytes::from_request(req, state).await.map_err(|e| { | |
| 71 | + | tracing::error!(error = %e, "failed to read request body"); | |
| 72 | + | StatusCode::BAD_REQUEST.into_response() | |
| 73 | + | })?; | |
| 74 | + | ||
| 75 | + | // Verify HMAC | |
| 76 | + | let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(&body).unwrap_or("")); | |
| 77 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 78 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 79 | + | mac.update(message.as_bytes()); | |
| 80 | + | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 81 | + | ||
| 82 | + | if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { | |
| 83 | + | return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | Ok(InternalAuth(body)) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Constant-time byte comparison to prevent timing attacks. | |
| 91 | + | fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| 92 | + | if a.len() != b.len() { | |
| 93 | + | return false; | |
| 94 | + | } | |
| 95 | + | a.iter() | |
| 96 | + | .zip(b.iter()) | |
| 97 | + | .fold(0u8, |acc, (x, y)| acc | (x ^ y)) | |
| 98 | + | == 0 | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[cfg(test)] | |
| 102 | + | mod tests { | |
| 103 | + | use super::*; | |
| 104 | + | ||
| 105 | + | #[test] | |
| 106 | + | fn constant_time_eq_works() { | |
| 107 | + | assert!(constant_time_eq(b"hello", b"hello")); | |
| 108 | + | assert!(!constant_time_eq(b"hello", b"world")); | |
| 109 | + | assert!(!constant_time_eq(b"hello", b"hell")); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[test] | |
| 113 | + | fn hmac_signature_roundtrip() { | |
| 114 | + | let secret = "test-secret"; | |
| 115 | + | let timestamp = "1234567890"; | |
| 116 | + | let body = r#"{"name":"test"}"#; | |
| 117 | + | let message = format!("{}\n{}", timestamp, body); | |
| 118 | + | ||
| 119 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 120 | + | mac.update(message.as_bytes()); | |
| 121 | + | let sig = hex::encode(mac.finalize().into_bytes()); | |
| 122 | + | ||
| 123 | + | // Verify the same computation matches | |
| 124 | + | let mut mac2 = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); | |
| 125 | + | mac2.update(message.as_bytes()); | |
| 126 | + | let expected = hex::encode(mac2.finalize().into_bytes()); | |
| 127 | + | ||
| 128 | + | assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes())); | |
| 129 | + | } | |
| 130 | + | } |
| @@ -3,8 +3,8 @@ | |||
| 3 | 3 | pub mod auth; | |
| 4 | 4 | pub mod config; | |
| 5 | 5 | pub mod csrf; | |
| 6 | + | pub mod internal_auth; | |
| 6 | 7 | pub mod link_preview; | |
| 7 | - | pub mod markdown; | |
| 8 | 8 | pub mod routes; | |
| 9 | 9 | pub mod seed; | |
| 10 | 10 | pub mod storage; |
| @@ -86,7 +86,7 @@ async fn main() { | |||
| 86 | 86 | )) | |
| 87 | 87 | .with_secure(state.config.cookie_secure); | |
| 88 | 88 | ||
| 89 | - | let app = multithreaded::routes::forum_routes(state) | |
| 89 | + | let app = multithreaded::routes::forum_routes(state.clone()) | |
| 90 | 90 | .layer(axum::middleware::from_fn(csrf::csrf_middleware)) | |
| 91 | 91 | .layer(session_layer) | |
| 92 | 92 | .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( | |
| @@ -103,6 +103,8 @@ async fn main() { | |||
| 103 | 103 | axum::http::header::X_FRAME_OPTIONS, | |
| 104 | 104 | axum::http::HeaderValue::from_static("DENY"), | |
| 105 | 105 | )) | |
| 106 | + | // Internal API routes — HMAC auth only, no CSRF/session middleware | |
| 107 | + | .merge(multithreaded::routes::internal::internal_routes(state)) | |
| 106 | 108 | .nest_service("/static", ServeDir::new("static")); | |
| 107 | 109 | ||
| 108 | 110 | let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
| @@ -1,495 +0,0 @@ | |||
| 1 | - | //! Markdown rendering with HTML sanitization. | |
| 2 | - | ||
| 3 | - | use std::collections::HashSet; | |
| 4 | - | ||
| 5 | - | use pulldown_cmark::{CowStr, Event, Parser, Tag, TagEnd, html}; | |
| 6 | - | ||
| 7 | - | /// Returns true if the URL uses a scheme not in the safe allowlist. | |
| 8 | - | /// | |
| 9 | - | /// Safe schemes: `http`, `https`, `mailto`, `ftp`. Relative URLs (no scheme) are safe. | |
| 10 | - | fn has_dangerous_scheme(url: &str) -> bool { | |
| 11 | - | let trimmed = url.trim(); | |
| 12 | - | if let Some(colon_pos) = trimmed.find(':') { | |
| 13 | - | let before_colon = &trimmed[..colon_pos]; | |
| 14 | - | // If `/`, `#`, or `?` appears before the colon, it's a path/fragment/query, not a scheme | |
| 15 | - | if before_colon.contains('/') | |
| 16 | - | || before_colon.contains('#') | |
| 17 | - | || before_colon.contains('?') | |
| 18 | - | { | |
| 19 | - | return false; | |
| 20 | - | } | |
| 21 | - | let scheme = before_colon.to_ascii_lowercase(); | |
| 22 | - | !matches!(scheme.as_str(), "http" | "https" | "mailto" | "ftp") | |
| 23 | - | } else { | |
| 24 | - | false | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | /// Render markdown to HTML, stripping raw HTML events and dangerous URL schemes. | |
| 29 | - | pub fn render(input: &str) -> String { | |
| 30 | - | let parser = Parser::new(input).filter_map(|event| match event { | |
| 31 | - | Event::Html(_) | Event::InlineHtml(_) => None, | |
| 32 | - | Event::Start(Tag::Link { | |
| 33 | - | link_type, | |
| 34 | - | dest_url, | |
| 35 | - | title, | |
| 36 | - | id, | |
| 37 | - | }) if has_dangerous_scheme(&dest_url) => Some(Event::Start(Tag::Link { | |
| 38 | - | link_type, | |
| 39 | - | dest_url: CowStr::Borrowed("#"), | |
| 40 | - | title, | |
| 41 | - | id, | |
| 42 | - | })), | |
| 43 | - | // Strip images entirely — alt text passes through as plain text | |
| 44 | - | Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => None, | |
| 45 | - | other => Some(other), | |
| 46 | - | }); | |
| 47 | - | let mut output = String::new(); | |
| 48 | - | html::push_html(&mut output, parser); | |
| 49 | - | ammonia::Builder::default() | |
| 50 | - | .link_rel(Some("noopener noreferrer nofollow")) | |
| 51 | - | .clean(&output) | |
| 52 | - | .to_string() | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | /// Quote author info for attribution rendering. | |
| 56 | - | pub struct QuoteAuthor { | |
| 57 | - | pub username: String, | |
| 58 | - | pub display_name: String, | |
| 59 | - | pub is_removed: bool, | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | /// HTML-escape a string for safe interpolation into raw HTML. | |
| 63 | - | fn html_escape(s: &str) -> String { | |
| 64 | - | s.replace('&', "&") | |
| 65 | - | .replace('<', "<") | |
| 66 | - | .replace('>', ">") | |
| 67 | - | .replace('"', """) | |
| 68 | - | .replace('\'', "'") | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | /// Post-process rendered HTML to replace `[quote:POST_ID:HASH]` markers with attribution. | |
| 72 | - | pub fn post_process_quotes( | |
| 73 | - | html: &str, | |
| 74 | - | quote_authors: &std::collections::HashMap<uuid::Uuid, QuoteAuthor>, | |
| 75 | - | ) -> String { | |
| 76 | - | static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 77 | - | regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap() | |
| 78 | - | }); | |
| 79 | - | QUOTE_RE.replace_all(html, |caps: ®ex_lite::Captures| { | |
| 80 | - | let post_id_str = &caps[1]; | |
| 81 | - | if let Ok(post_id) = uuid::Uuid::parse_str(post_id_str) | |
| 82 | - | && let Some(author) = quote_authors.get(&post_id) | |
| 83 | - | { | |
| 84 | - | if author.is_removed { | |
| 85 | - | format!( | |
| 86 | - | "<cite class=\"quote-attribution\"><a href=\"#post-{}\">(original post removed)</a></cite>", | |
| 87 | - | post_id_str | |
| 88 | - | ) | |
| 89 | - | } else { | |
| 90 | - | format!( | |
| 91 | - | "<cite class=\"quote-attribution\"><a href=\"#post-{}\">— {} (@{})</a></cite>", | |
| 92 | - | post_id_str, | |
| 93 | - | html_escape(&author.display_name), | |
| 94 | - | html_escape(&author.username), | |
| 95 | - | ) | |
| 96 | - | } | |
| 97 | - | } else { | |
| 98 | - | caps[0].to_string() | |
| 99 | - | } | |
| 100 | - | }) | |
| 101 | - | .to_string() | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // ============================================================================ | |
| 105 | - | // @Mention extraction + resolution | |
| 106 | - | // ============================================================================ | |
| 107 | - | ||
| 108 | - | /// Extract unique `@username` mentions from raw markdown input. | |
| 109 | - | /// Skips mentions inside inline code (backtick-wrapped). | |
| 110 | - | pub fn extract_mention_usernames(input: &str) -> Vec<String> { | |
| 111 | - | static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> = | |
| 112 | - | std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); | |
| 113 | - | ||
| 114 | - | // Strip inline code spans and fenced code blocks before scanning | |
| 115 | - | let stripped = strip_code_spans(input); | |
| 116 | - | let mut seen = HashSet::new(); | |
| 117 | - | let mut result = Vec::new(); | |
| 118 | - | for caps in MENTION_RE.captures_iter(&stripped) { | |
| 119 | - | let username = caps[1].to_string(); | |
| 120 | - | if seen.insert(username.clone()) { | |
| 121 | - | result.push(username); | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | result | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | /// Replace `@username` with markdown profile links for valid community members. | |
| 128 | - | /// Unknown usernames are left as plain text. | |
| 129 | - | pub fn resolve_mentions( | |
| 130 | - | input: &str, | |
| 131 | - | community_slug: &str, | |
| 132 | - | valid_usernames: &HashSet<String>, | |
| 133 | - | ) -> String { | |
| 134 | - | static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> = | |
| 135 | - | std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); | |
| 136 | - | ||
| 137 | - | // We need to avoid replacing mentions inside backtick code spans. | |
| 138 | - | // Strategy: split on code spans, only replace in non-code segments. | |
| 139 | - | let mut result = String::with_capacity(input.len()); | |
| 140 | - | let mut pos = 0; | |
| 141 | - | ||
| 142 | - | for (code_start, code_end) in code_span_ranges(input) { | |
| 143 | - | // Process the text before this code span | |
| 144 | - | let before = &input[pos..code_start]; | |
| 145 | - | result.push_str(&replace_mentions(before, community_slug, valid_usernames, &MENTION_RE)); | |
| 146 | - | // Copy the code span verbatim | |
| 147 | - | result.push_str(&input[code_start..code_end]); | |
| 148 | - | pos = code_end; | |
| 149 | - | } | |
| 150 | - | // Process remaining text after the last code span | |
| 151 | - | let tail = &input[pos..]; | |
| 152 | - | result.push_str(&replace_mentions(tail, community_slug, valid_usernames, &MENTION_RE)); | |
| 153 | - | ||
| 154 | - | result | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | fn replace_mentions( | |
| 158 | - | text: &str, | |
| 159 | - | community_slug: &str, | |
| 160 | - | valid_usernames: &HashSet<String>, | |
| 161 | - | re: ®ex_lite::Regex, | |
| 162 | - | ) -> String { | |
| 163 | - | re.replace_all(text, |caps: ®ex_lite::Captures| { | |
| 164 | - | let username = &caps[1]; | |
| 165 | - | if valid_usernames.contains(username) { | |
| 166 | - | format!("[@{username}](/p/{community_slug}/u/{username})") | |
| 167 | - | } else { | |
| 168 | - | caps[0].to_string() | |
| 169 | - | } | |
| 170 | - | }) | |
| 171 | - | .to_string() | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | /// Strip inline code (backtick) and fenced code blocks, replacing with spaces. | |
| 175 | - | fn strip_code_spans(input: &str) -> String { | |
| 176 | - | let mut out = String::with_capacity(input.len()); | |
| 177 | - | let mut chars = input.chars().peekable(); | |
| 178 | - | ||
| 179 | - | while let Some(ch) = chars.next() { | |
| 180 | - | if ch == '`' { | |
| 181 | - | // Count consecutive backticks | |
| 182 | - | let mut tick_count = 1; | |
| 183 | - | while chars.peek() == Some(&'`') { | |
| 184 | - | tick_count += 1; | |
| 185 | - | chars.next(); | |
| 186 | - | } | |
| 187 | - | // Find the matching closing backticks | |
| 188 | - | let mut skipped = 0; | |
| 189 | - | while let Some(c) = chars.next() { | |
| 190 | - | skipped += 1; | |
| 191 | - | if c == '`' { | |
| 192 | - | let mut close_count = 1; | |
| 193 | - | while chars.peek() == Some(&'`') { | |
| 194 | - | close_count += 1; | |
| 195 | - | chars.next(); | |
| 196 | - | } | |
| 197 | - | if close_count == tick_count { | |
| 198 | - | break; | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | // Replace the code span content (+ delimiters) with spaces | |
| 203 | - | let total = tick_count * 2 + skipped; | |
| 204 | - | for _ in 0..total { | |
| 205 | - | out.push(' '); | |
| 206 | - | } | |
| 207 | - | } else { | |
| 208 | - | out.push(ch); | |
| 209 | - | } | |
| 210 | - | } | |
| 211 | - | out | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | /// Return byte ranges of inline code spans and fenced code blocks. | |
| 215 | - | fn code_span_ranges(input: &str) -> Vec<(usize, usize)> { | |
| 216 | - | let mut ranges = Vec::new(); | |
| 217 | - | let bytes = input.as_bytes(); | |
| 218 | - | let len = bytes.len(); | |
| 219 | - | let mut i = 0; | |
| 220 | - | ||
| 221 | - | while i < len { | |
| 222 | - | if bytes[i] == b'`' { | |
| 223 | - | let start = i; | |
| 224 | - | let mut tick_count = 0; | |
| 225 | - | while i < len && bytes[i] == b'`' { | |
| 226 | - | tick_count += 1; | |
| 227 | - | i += 1; | |
| 228 | - | } | |
| 229 | - | // Search for matching closing backticks | |
| 230 | - | let mut found = false; | |
| 231 | - | while i < len { | |
| 232 | - | if bytes[i] == b'`' { | |
| 233 | - | let mut close_count = 0; | |
| 234 | - | while i < len && bytes[i] == b'`' { | |
| 235 | - | close_count += 1; | |
| 236 | - | i += 1; | |
| 237 | - | } | |
| 238 | - | if close_count == tick_count { | |
| 239 | - | ranges.push((start, i)); | |
| 240 | - | found = true; | |
| 241 | - | break; | |
| 242 | - | } | |
| 243 | - | } else { | |
| 244 | - | i += 1; | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | if !found { | |
| 248 | - | // Unclosed — treat from start to end as code | |
| 249 | - | ranges.push((start, len)); | |
| 250 | - | } | |
| 251 | - | } else { | |
| 252 | - | i += 1; | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | ranges | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | #[cfg(test)] | |
| 259 | - | mod tests { | |
| 260 | - | use super::*; | |
| 261 | - | ||
| 262 | - | #[test] | |
| 263 | - | fn empty_input() { | |
| 264 | - | assert_eq!(render(""), ""); | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | #[test] | |
| 268 | - | fn plain_text_wraps_in_paragraph() { | |
| 269 | - | assert_eq!(render("hello world"), "<p>hello world</p>\n"); | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | #[test] | |
| 273 | - | fn bold_and_italic() { | |
| 274 | - | let result = render("**bold** and *italic*"); | |
| 275 | - | assert!(result.contains("<strong>bold</strong>")); | |
| 276 | - | assert!(result.contains("<em>italic</em>")); | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | #[test] | |
| 280 | - | fn inline_code() { | |
| 281 | - | let result = render("use `foo()` here"); | |
| 282 | - | assert!(result.contains("<code>foo()</code>")); | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | #[test] | |
| 286 | - | fn code_block() { | |
| 287 | - | let result = render("```\nlet x = 1;\n```"); | |
| 288 | - | assert!(result.contains("<pre><code>")); | |
| 289 | - | assert!(result.contains("let x = 1;")); | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | #[test] | |
| 293 | - | fn link_renders() { | |
| 294 | - | let result = render("[example](https://example.com)"); | |
| 295 | - | assert!(result.contains(r#"href="https://example.com""#)); | |
| 296 | - | assert!(result.contains("example</a>")); | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | #[test] | |
| 300 | - | fn strips_raw_html_tags() { | |
| 301 | - | let result = render("<script>alert('xss')</script>"); | |
| 302 | - | assert!(!result.contains("<script>")); | |
| 303 | - | assert!(!result.contains("</script>")); | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | #[test] | |
| 307 | - | fn strips_inline_html() { | |
| 308 | - | let result = render("hello <b>bold</b> world"); | |
| 309 | - | assert!(!result.contains("<b>")); | |
| 310 | - | assert!(!result.contains("</b>")); | |
| 311 | - | assert!(result.contains("hello")); | |
| 312 | - | assert!(result.contains("world")); | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | #[test] | |
| 316 | - | fn strips_img_tag() { | |
| 317 | - | let result = render(r#"<img src="x" onerror="alert(1)">"#); | |
| 318 | - | assert!(!result.contains("<img")); | |
| 319 | - | assert!(!result.contains("onerror")); | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | #[test] | |
| 323 | - | fn strips_iframe() { | |
| 324 | - | let result = render(r#"<iframe src="https://evil.com"></iframe>"#); | |
| 325 | - | assert!(!result.contains("<iframe")); | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | #[test] | |
| 329 | - | fn strips_event_handler_in_html() { | |
| 330 | - | let result = render(r#"<div onmouseover="alert(1)">hover me</div>"#); | |
| 331 | - | assert!(!result.contains("onmouseover")); | |
| 332 | - | assert!(!result.contains("<div")); | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | #[test] | |
| 336 | - | fn blockquote_renders() { | |
| 337 | - | let result = render("> quoted text"); | |
| 338 | - | assert!(result.contains("<blockquote>")); | |
| 339 | - | assert!(result.contains("quoted text")); | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | #[test] | |
| 343 | - | fn unordered_list() { | |
| 344 | - | let result = render("- item one\n- item two"); | |
| 345 | - | assert!(result.contains("<ul>")); | |
| 346 | - | assert!(result.contains("<li>item one</li>")); | |
| 347 | - | assert!(result.contains("<li>item two</li>")); | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | #[test] | |
| 351 | - | fn heading_renders() { | |
| 352 | - | let result = render("## Section Title"); | |
| 353 | - | assert!(result.contains("<h2>Section Title</h2>")); | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | #[test] | |
| 357 | - | fn mixed_markdown_and_html_strips_only_html() { | |
| 358 | - | let result = render("**bold** <script>alert(1)</script> *italic*"); | |
| 359 | - | assert!(result.contains("<strong>bold</strong>")); | |
| 360 | - | assert!(result.contains("<em>italic</em>")); | |
| 361 | - | assert!(!result.contains("<script>")); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | #[test] | |
| 365 | - | fn links_have_nofollow() { | |
| 366 | - | let result = render("[example](https://example.com)"); | |
| 367 | - | assert!(result.contains("nofollow"), "links should have rel=nofollow"); | |
| 368 | - | assert!(result.contains("noopener"), "links should have rel=noopener"); | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | #[test] | |
| 372 | - | fn javascript_url_sanitized() { | |
| 373 | - | let result = render("[click me](javascript:alert(1))"); | |
| 374 | - | assert!(result.contains("click me")); | |
| 375 | - | assert!(!result.contains("javascript:")); | |
| 376 | - | assert!(result.contains(r##"href="#""##)); | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | #[test] | |
| 380 | - | fn javascript_url_case_insensitive() { | |
| 381 | - | let result = render("[xss](JaVaScRiPt:alert(1))"); | |
| 382 | - | assert!(!result.contains("javascript:")); | |
| 383 | - | assert!(!result.contains("JaVaScRiPt:")); | |
| 384 | - | assert!(result.contains(r##"href="#""##)); | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | #[test] | |
| 388 | - | fn data_url_sanitized() { | |
| 389 | - | let result = render("[xss](data:text/html,<script>alert(1)</script>)"); | |
| 390 | - | assert!(!result.contains("data:text")); | |
| 391 | - | assert!(result.contains(r##"href="#""##)); | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | #[test] | |
| 395 | - | fn vbscript_url_sanitized() { | |
| 396 | - | let result = render("[xss](vbscript:msgbox)"); | |
| 397 | - | assert!(!result.contains("vbscript:")); | |
| 398 | - | assert!(result.contains(r##"href="#""##)); | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | #[test] | |
| 402 | - | fn safe_urls_preserved() { | |
| 403 | - | let result = render("[link](https://example.com)"); | |
| 404 | - | assert!(result.contains(r#"href="https://example.com""#)); | |
| 405 | - | ||
| 406 | - | let result = render("[mail](mailto:user@example.com)"); | |
| 407 | - | assert!(result.contains(r#"href="mailto:user@example.com""#)); | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | #[test] | |
| 411 | - | fn relative_urls_preserved() { | |
| 412 | - | let result = render("[page](/about)"); | |
| 413 | - | assert!(result.contains(r#"href="/about""#)); | |
| 414 | - | ||
| 415 | - | let result = render("[section](#heading)"); | |
| 416 | - | assert!(result.contains(r##"href="#heading""##)); | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | #[test] | |
| 420 | - | fn images_stripped_alt_text_preserved() { | |
| 421 | - | let result = render(""); | |
| 422 | - | assert!(!result.contains("<img")); | |
| 423 | - | assert!(!result.contains("example.com")); | |
| 424 | - | assert!(result.contains("alt text")); | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | #[test] | |
| 428 | - | fn javascript_url_in_image_stripped() { | |
| 429 | - | let result = render(")"); | |
| 430 | - | assert!(!result.contains("javascript:")); | |
| 431 | - | assert!(!result.contains("<img")); | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | // ======================================================================== | |
| 435 | - | // @Mention tests | |
| 436 | - | // ======================================================================== | |
| 437 | - | ||
| 438 | - | #[test] | |
| 439 | - | fn extract_mentions_basic() { | |
| 440 | - | let usernames = extract_mention_usernames("Hello @alice and @bob!"); | |
| 441 | - | assert_eq!(usernames, vec!["alice", "bob"]); | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | #[test] | |
| 445 | - | fn extract_mentions_deduplicates() { | |
| 446 | - | let usernames = extract_mention_usernames("@alice said @alice agrees"); | |
| 447 | - | assert_eq!(usernames, vec!["alice"]); | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | #[test] | |
| 451 | - | fn extract_mentions_skips_code_spans() { | |
| 452 | - | let usernames = extract_mention_usernames("Hello `@notreal` and @real"); | |
| 453 | - | assert_eq!(usernames, vec!["real"]); | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | #[test] | |
| 457 | - | fn extract_mentions_skips_fenced_code() { | |
| 458 | - | let usernames = extract_mention_usernames("text\n```\n@inside\n```\n@outside"); | |
| 459 | - | assert_eq!(usernames, vec!["outside"]); | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | #[test] | |
| 463 | - | fn extract_mentions_empty() { | |
| 464 | - | let usernames = extract_mention_usernames("no mentions here"); | |
| 465 | - | assert!(usernames.is_empty()); | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | #[test] | |
| 469 | - | fn resolve_mentions_valid_replaced() { | |
| 470 | - | let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); | |
| 471 | - | let result = resolve_mentions("Hello @alice!", "test-community", &valid); | |
| 472 | - | assert_eq!(result, "Hello [@alice](/p/test-community/u/alice)!"); | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | #[test] | |
| 476 | - | fn resolve_mentions_unknown_left_alone() { | |
| 477 | - | let valid: HashSet<String> = HashSet::new(); | |
| 478 | - | let result = resolve_mentions("Hello @unknown!", "test", &valid); | |
| 479 | - | assert_eq!(result, "Hello @unknown!"); | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | #[test] | |
| 483 | - | fn resolve_mentions_in_code_not_replaced() { | |
| 484 | - | let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); | |
| 485 | - | let result = resolve_mentions("Use `@alice` in code", "test", &valid); | |
| 486 | - | assert_eq!(result, "Use `@alice` in code"); | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | #[test] | |
| 490 | - | fn resolve_mentions_mixed_valid_invalid() { | |
| 491 | - | let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); | |
| 492 | - | let result = resolve_mentions("@alice and @unknown", "slug", &valid); | |
| 493 | - | assert_eq!(result, "[@alice](/p/slug/u/alice) and @unknown"); | |
| 494 | - | } | |
| 495 | - | } |
| @@ -13,6 +13,8 @@ use crate::csrf; | |||
| 13 | 13 | use crate::templates::*; | |
| 14 | 14 | use crate::AppState; | |
| 15 | 15 | ||
| 16 | + | use mt_core::types::ModAction; | |
| 17 | + | ||
| 16 | 18 | use super::{log_mod_action, parse_uuid, AdminSearchQuery, SuspendForm}; | |
| 17 | 19 | ||
| 18 | 20 | #[tracing::instrument(skip_all)] | |
| @@ -93,7 +95,7 @@ pub(super) async fn suspend_community_handler( | |||
| 93 | 95 | ||
| 94 | 96 | log_mod_action( | |
| 95 | 97 | &state.db, None, admin.user_id, | |
| 96 | - | "suspend_community", None, Some(community_id), reason, | |
| 98 | + | ModAction::SuspendCommunity, None, Some(community_id), reason, | |
| 97 | 99 | ).await; | |
| 98 | 100 | ||
| 99 | 101 | Ok(Redirect::to("/_admin?toast=Community+suspended")) | |
| @@ -116,7 +118,7 @@ pub(super) async fn unsuspend_community_handler( | |||
| 116 | 118 | ||
| 117 | 119 | log_mod_action( | |
| 118 | 120 | &state.db, None, admin.user_id, | |
| 119 | - | "unsuspend_community", None, Some(community_id), None, | |
| 121 | + | ModAction::UnsuspendCommunity, None, Some(community_id), None, | |
| 120 | 122 | ).await; | |
| 121 | 123 | ||
| 122 | 124 | Ok(Redirect::to("/_admin?toast=Community+unsuspended")) | |
| @@ -141,7 +143,7 @@ pub(super) async fn suspend_user_handler( | |||
| 141 | 143 | ||
| 142 | 144 | log_mod_action( | |
| 143 | 145 | &state.db, None, admin.user_id, | |
| 144 | - | "suspend_user", Some(user_id), None, reason, | |
| 146 | + | ModAction::SuspendUser, Some(user_id), None, reason, | |
| 145 | 147 | ).await; | |
| 146 | 148 | ||
| 147 | 149 | Ok(Redirect::to("/_admin?toast=User+suspended")) | |
| @@ -164,7 +166,7 @@ pub(super) async fn unsuspend_user_handler( | |||
| 164 | 166 | ||
| 165 | 167 | log_mod_action( | |
| 166 | 168 | &state.db, None, admin.user_id, | |
| 167 | - | "unsuspend_user", Some(user_id), None, None, | |
| 169 | + | ModAction::UnsuspendUser, Some(user_id), None, None, | |
| 168 | 170 | ).await; | |
| 169 | 171 | ||
| 170 | 172 | Ok(Redirect::to("/_admin?toast=User+unsuspended")) |
| @@ -11,6 +11,8 @@ use serde::Deserialize; | |||
| 11 | 11 | use crate::auth::MaybeUser; | |
| 12 | 12 | use crate::AppState; | |
| 13 | 13 | ||
| 14 | + | use mt_core::types::ModAction; | |
| 15 | + | ||
| 14 | 16 | use super::{ | |
| 15 | 17 | check_community_access, get_community, log_mod_action, parse_uuid, require_mod_or_owner, | |
| 16 | 18 | }; | |
| @@ -76,7 +78,7 @@ pub(super) async fn flag_post_handler( | |||
| 76 | 78 | Ok(true) => { | |
| 77 | 79 | log_mod_action( | |
| 78 | 80 | &state.db, Some(community.id), user.user_id, | |
| 79 | - | "auto_hide_post", Some(post_data.author_id), Some(post_id), None, | |
| 81 | + | ModAction::AutoHidePost, Some(post_data.author_id), Some(post_id), None, | |
| 80 | 82 | ).await; | |
| 81 | 83 | } | |
| 82 | 84 | Ok(false) => {} // threshold not met or already removed | |
| @@ -161,7 +163,7 @@ pub(super) async fn remove_flagged_post_handler( | |||
| 161 | 163 | ||
| 162 | 164 | log_mod_action( | |
| 163 | 165 | &state.db, Some(community.id), user.user_id, | |
| 164 | - | "remove_post_via_flag", Some(author_id), Some(post_id), None, | |
| 166 | + | ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None, | |
| 165 | 167 | ).await; | |
| 166 | 168 | ||
| 167 | 169 | Ok(Redirect::to(&format!( |
| @@ -16,6 +16,8 @@ use crate::csrf; | |||
| 16 | 16 | use crate::templates::*; | |
| 17 | 17 | use crate::AppState; | |
| 18 | 18 | ||
| 19 | + | use mt_core::types::ModAction; | |
| 20 | + | ||
| 19 | 21 | use super::super::{ | |
| 20 | 22 | check_community_access, check_user_post_rate, check_write_access, get_community, get_role, | |
| 21 | 23 | get_thread, is_mod_or_owner, log_mod_action, parse_uuid, render_markdown, | |
| @@ -127,7 +129,7 @@ async fn resolve_and_render_mentions( | |||
| 127 | 129 | community_slug: &str, | |
| 128 | 130 | author_id: Uuid, | |
| 129 | 131 | ) -> Result<(String, Vec<Uuid>), Response> { | |
| 130 | - | let usernames = crate::markdown::extract_mention_usernames(body); | |
| 132 | + | let usernames = docengine::extract_mentions(body); | |
| 131 | 133 | if usernames.is_empty() { | |
| 132 | 134 | return Ok((render_markdown(body), Vec::new())); | |
| 133 | 135 | } | |
| @@ -589,7 +591,7 @@ pub(in crate::routes) async fn delete_thread_handler( | |||
| 589 | 591 | ||
| 590 | 592 | log_mod_action( | |
| 591 | 593 | &state.db, Some(thread_data.community_id), user.user_id, | |
| 592 | - | "delete_thread", Some(thread_data.author_id), Some(thread_id), None, | |
| 594 | + | ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None, | |
| 593 | 595 | ).await; | |
| 594 | 596 | ||
| 595 | 597 | Ok(Redirect::to(&format!( |
| @@ -15,6 +15,8 @@ use crate::AppState; | |||
| 15 | 15 | ||
| 16 | 16 | use std::collections::HashMap; | |
| 17 | 17 | ||
| 18 | + | use mt_core::types::{SortColumn, SortOrder}; | |
| 19 | + | ||
| 18 | 20 | use super::super::{ | |
| 19 | 21 | check_community_access, get_community, get_role, get_thread, is_mod_or_owner, is_owner, | |
| 20 | 22 | parse_uuid, template_user, CategoryQuery, PageQuery, | |
| @@ -179,14 +181,8 @@ pub(in crate::routes) async fn category( | |||
| 179 | 181 | let per_page: i64 = 25; | |
| 180 | 182 | ||
| 181 | 183 | // Parse sort column — only allow known values, default to "activity" | |
| 182 | - | let sort = match query.sort.as_deref() { | |
| 183 | - | Some("replies") => "replies", | |
| 184 | - | _ => "activity", | |
| 185 | - | }; | |
| 186 | - | let order = match query.order.as_deref() { | |
| 187 | - | Some("asc") => "asc", | |
| 188 | - | _ => "desc", | |
| 189 | - | }; | |
| 184 | + | let sort = SortColumn::from_query(query.sort.as_deref()); | |
| 185 | + | let order = SortOrder::from_query(query.order.as_deref()); | |
| 190 | 186 | ||
| 191 | 187 | let tag_filter = query.tag.as_deref().filter(|t| !t.is_empty()); | |
| 192 | 188 | ||
| @@ -205,7 +201,7 @@ pub(in crate::routes) async fn category( | |||
| 205 | 201 | let offset = (page as i64 - 1) * per_page; | |
| 206 | 202 | ||
| 207 | 203 | let db_threads = mt_db::queries::list_threads_in_category_sorted_filtered( | |
| 208 | - | &state.db, &slug, &category_slug, sort, order, per_page, offset, tag_filter, | |
| 204 | + | &state.db, &slug, &category_slug, sort.as_str(), order.as_str(), per_page, offset, tag_filter, | |
| 209 | 205 | ) | |
| 210 | 206 | .await | |
| 211 | 207 | .map_err(|e| { | |
| @@ -287,8 +283,8 @@ pub(in crate::routes) async fn category( | |||
| 287 | 283 | category_slug, | |
| 288 | 284 | threads, | |
| 289 | 285 | pagination: Pagination::new(page, total, per_page), | |
| 290 | - | sort_column: sort.to_string(), | |
| 291 | - | sort_order: order.to_string(), | |
| 286 | + | sort_column: sort.as_str().to_string(), | |
| 287 | + | sort_order: order.as_str().to_string(), | |
| 292 | 288 | available_tags, | |
| 293 | 289 | active_tag: tag_filter.map(|t| t.to_string()), | |
| 294 | 290 | }) | |
| @@ -411,9 +407,9 @@ pub(in crate::routes) async fn thread( | |||
| 411 | 407 | } | |
| 412 | 408 | ||
| 413 | 409 | // Build quote author map for attribution rendering | |
| 414 | - | let mut quote_authors: HashMap<uuid::Uuid, crate::markdown::QuoteAuthor> = HashMap::new(); | |
| 410 | + | let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new(); | |
| 415 | 411 | for p in &db_posts { | |
| 416 | - | quote_authors.insert(p.id, crate::markdown::QuoteAuthor { | |
| 412 | + | quote_authors.insert(p.id, docengine::QuoteAuthor { | |
| 417 | 413 | username: p.author_username.clone(), | |
| 418 | 414 | display_name: p.author_name.clone(), | |
| 419 | 415 | is_removed: p.removed_at.is_some(), | |
| @@ -432,7 +428,7 @@ pub(in crate::routes) async fn thread( | |||
| 432 | 428 | let body_html = if is_removed { | |
| 433 | 429 | String::from("<p><em>[removed by moderator]</em></p>") | |
| 434 | 430 | } else { | |
| 435 | - | crate::markdown::post_process_quotes(&p.body_html, "e_authors) | |
| 431 | + | docengine::post_process_quotes(&p.body_html, "e_authors) | |
| 436 | 432 | }; | |
| 437 | 433 | ||
| 438 | 434 | let post_id_str = p.id.to_string(); |
| @@ -0,0 +1,273 @@ | |||
| 1 | + | //! Internal API routes — called by MNW with HMAC-SHA256 authentication. | |
| 2 | + | //! Registered outside the CSRF/session middleware stack. | |
| 3 | + | ||
| 4 | + | use axum::{ | |
| 5 | + | extract::{Path, State}, | |
| 6 | + | http::StatusCode, | |
| 7 | + | response::{IntoResponse, Response}, | |
| 8 | + | routing::{get, post}, | |
| 9 | + | Json, Router, | |
| 10 | + | }; | |
| 11 | + | use serde::{Deserialize, Serialize}; | |
| 12 | + | use uuid::Uuid; | |
| 13 | + | ||
| 14 | + | use crate::internal_auth::InternalAuth; | |
| 15 | + | use crate::AppState; | |
| 16 | + | ||
| 17 | + | // ============================================================================ | |
| 18 | + | // Request/response types | |
| 19 | + | // ============================================================================ | |
| 20 | + | ||
| 21 | + | #[derive(Deserialize)] | |
| 22 | + | pub struct CreateCommunityRequest { | |
| 23 | + | pub name: String, | |
| 24 | + | pub slug: String, | |
| 25 | + | pub description: Option<String>, | |
| 26 | + | pub owner_mnw_id: Uuid, | |
| 27 | + | pub owner_username: String, | |
| 28 | + | pub owner_display_name: Option<String>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Serialize)] | |
| 32 | + | pub struct CreateCommunityResponse { | |
| 33 | + | pub community_id: Uuid, | |
| 34 | + | pub created: bool, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[derive(Deserialize)] | |
| 38 | + | pub struct CreateThreadRequest { | |
| 39 | + | pub community_slug: String, | |
| 40 | + | pub category_slug: String, | |
| 41 | + | pub title: String, | |
| 42 | + | pub body_markdown: String, | |
| 43 | + | pub author_mnw_id: Uuid, | |
| 44 | + | pub author_username: String, | |
| 45 | + | pub author_display_name: Option<String>, | |
| 46 | + | pub external_ref: String, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | #[derive(Serialize)] | |
| 50 | + | pub struct CreateThreadResponse { | |
| 51 | + | pub thread_id: Uuid, | |
| 52 | + | pub post_id: Uuid, | |
| 53 | + | pub created: bool, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[derive(Serialize)] | |
| 57 | + | pub struct ThreadStatsResponse { | |
| 58 | + | pub post_count: i64, | |
| 59 | + | pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ============================================================================ | |
| 63 | + | // Handlers | |
| 64 | + | // ============================================================================ | |
| 65 | + | ||
| 66 | + | /// `POST /internal/communities` — Create or return an existing community. | |
| 67 | + | #[tracing::instrument(skip_all, name = "internal::create_community")] | |
| 68 | + | async fn create_community( | |
| 69 | + | State(state): State<AppState>, | |
| 70 | + | InternalAuth(body): InternalAuth, | |
| 71 | + | ) -> Result<Json<CreateCommunityResponse>, Response> { | |
| 72 | + | let req: CreateCommunityRequest = serde_json::from_slice(&body).map_err(|e| { | |
| 73 | + | tracing::warn!(error = %e, "invalid create_community request body"); | |
| 74 | + | (StatusCode::BAD_REQUEST, "Invalid request body").into_response() | |
| 75 | + | })?; | |
| 76 | + | ||
| 77 | + | // Check if community already exists (idempotent) | |
| 78 | + | if let Some(existing) = mt_db::queries::get_community_by_slug(&state.db, &req.slug) | |
| 79 | + | .await | |
| 80 | + | .map_err(db_error)? | |
| 81 | + | { | |
| 82 | + | return Ok(Json(CreateCommunityResponse { | |
| 83 | + | community_id: existing.id, | |
| 84 | + | created: false, | |
| 85 | + | })); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Upsert the owner user (may not have logged into MT yet) | |
| 89 | + | mt_db::mutations::upsert_user( | |
| 90 | + | &state.db, | |
| 91 | + | req.owner_mnw_id, | |
| 92 | + | &req.owner_username, | |
| 93 | + | req.owner_display_name.as_deref(), | |
| 94 | + | ) | |
| 95 | + | .await | |
| 96 | + | .map_err(db_error)?; | |
| 97 | + | ||
| 98 | + | // Create the community | |
| 99 | + | let community_id = | |
| 100 | + | mt_db::mutations::create_community(&state.db, &req.name, &req.slug, req.description.as_deref()) | |
| 101 | + | .await | |
| 102 | + | .map_err(db_error)?; | |
| 103 | + | ||
| 104 | + | // Create default categories | |
| 105 | + | let categories = [ | |
| 106 | + | ("Items", "items", 0), | |
| 107 | + | ("Blog", "blog", 1), | |
| 108 | + | ("Devlog", "devlog", 2), | |
| 109 | + | ("Discussion", "discussion", 3), | |
| 110 | + | ]; | |
| 111 | + | for (name, slug, order) in categories { | |
| 112 | + | mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order) | |
| 113 | + | .await | |
| 114 | + | .map_err(db_error)?; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Create owner membership | |
| 118 | + | mt_db::mutations::ensure_membership_with_role( | |
| 119 | + | &state.db, | |
| 120 | + | req.owner_mnw_id, | |
| 121 | + | community_id, | |
| 122 | + | "owner", | |
| 123 | + | ) | |
| 124 | + | .await | |
| 125 | + | .map_err(db_error)?; | |
| 126 | + | ||
| 127 | + | tracing::info!(community_id = %community_id, slug = %req.slug, "internal: community created"); | |
| 128 | + | ||
| 129 | + | Ok(Json(CreateCommunityResponse { | |
| 130 | + | community_id, | |
| 131 | + | created: true, | |
| 132 | + | })) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// `POST /internal/threads` — Create a thread with external reference (idempotent). | |
| 136 | + | #[tracing::instrument(skip_all, name = "internal::create_thread")] | |
| 137 | + | async fn create_thread( | |
| 138 | + | State(state): State<AppState>, | |
| 139 | + | InternalAuth(body): InternalAuth, | |
| 140 | + | ) -> Result<Json<CreateThreadResponse>, Response> { | |
| 141 | + | let req: CreateThreadRequest = serde_json::from_slice(&body).map_err(|e| { | |
| 142 | + | tracing::warn!(error = %e, "invalid create_thread request body"); | |
| 143 | + | (StatusCode::BAD_REQUEST, "Invalid request body").into_response() | |
| 144 | + | })?; | |
| 145 | + | ||
| 146 | + | // Idempotent: return existing thread if external_ref matches | |
| 147 | + | if let Some((thread_id,)) = mt_db::queries::get_thread_by_external_ref(&state.db, &req.external_ref) | |
| 148 | + | .await | |
| 149 | + | .map_err(db_error)? | |
| 150 | + | { | |
| 151 | + | // Get the opening post ID | |
| 152 | + | let posts = mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_id, 1, 0) | |
| 153 | + | .await | |
| 154 | + | .map_err(db_error)?; | |
| 155 | + | let post_id = posts.first().map(|p| p.id).unwrap_or(thread_id); | |
| 156 | + | return Ok(Json(CreateThreadResponse { | |
| 157 | + | thread_id, | |
| 158 | + | post_id, | |
| 159 | + | created: false, | |
| 160 | + | })); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Look up community | |
| 164 | + | let community = mt_db::queries::get_community_by_slug(&state.db, &req.community_slug) | |
| 165 | + | .await | |
| 166 | + | .map_err(db_error)? | |
| 167 | + | .ok_or_else(|| { | |
| 168 | + | (StatusCode::NOT_FOUND, "Community not found").into_response() | |
| 169 | + | })?; | |
| 170 | + | ||
| 171 | + | // Look up category | |
| 172 | + | let category = mt_db::queries::get_category_by_community_and_slug( | |
| 173 | + | &state.db, | |
| 174 | + | community.id, | |
| 175 | + | &req.category_slug, | |
| 176 | + | ) | |
| 177 | + | .await | |
| 178 | + | .map_err(db_error)? | |
| 179 | + | .ok_or_else(|| { | |
| 180 | + | (StatusCode::NOT_FOUND, "Category not found").into_response() | |
| 181 | + | })?; | |
| 182 | + | ||
| 183 | + | // Upsert author | |
| 184 | + | mt_db::mutations::upsert_user( | |
| 185 | + | &state.db, | |
| 186 | + | req.author_mnw_id, | |
| 187 | + | &req.author_username, | |
| 188 | + | req.author_display_name.as_deref(), | |
| 189 | + | ) | |
| 190 | + | .await | |
| 191 | + | .map_err(db_error)?; | |
| 192 | + | ||
| 193 | + | // Ensure membership | |
| 194 | + | mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id) | |
| 195 | + | .await | |
| 196 | + | .map_err(db_error)?; | |
| 197 | + | ||
| 198 | + | // Create thread with external_ref | |
| 199 | + | let thread_id = mt_db::mutations::create_thread_with_external_ref( | |
| 200 | + | &state.db, | |
| 201 | + | category.id, | |
| 202 | + | req.author_mnw_id, | |
| 203 | + | &req.title, | |
| 204 | + | &req.external_ref, | |
| 205 | + | ) | |
| 206 | + | .await | |
| 207 | + | .map_err(db_error)?; | |
| 208 | + | ||
| 209 | + | // Create opening post | |
| 210 | + | let body_html = super::render_markdown(&req.body_markdown); | |
| 211 | + | let post_id = mt_db::mutations::create_post( | |
| 212 | + | &state.db, | |
| 213 | + | thread_id, | |
| 214 | + | req.author_mnw_id, | |
| 215 | + | &req.body_markdown, | |
| 216 | + | &body_html, | |
| 217 | + | ) | |
| 218 | + | .await | |
| 219 | + | .map_err(db_error)?; | |
| 220 | + | ||
| 221 | + | tracing::info!( | |
| 222 | + | thread_id = %thread_id, | |
| 223 | + | external_ref = %req.external_ref, | |
| 224 | + | "internal: thread created" | |
| 225 | + | ); | |
| 226 | + | ||
| 227 | + | Ok(Json(CreateThreadResponse { | |
| 228 | + | thread_id, | |
| 229 | + | post_id, | |
| 230 | + | created: true, | |
| 231 | + | })) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// `GET /internal/threads/:id/stats` — Thread post count and last activity. | |
| 235 | + | #[tracing::instrument(skip_all, name = "internal::thread_stats")] | |
| 236 | + | async fn thread_stats( | |
| 237 | + | State(state): State<AppState>, | |
| 238 | + | Path(id): Path<String>, | |
| 239 | + | ) -> Result<Json<ThreadStatsResponse>, Response> { | |
| 240 | + | // Note: stats endpoint doesn't require HMAC auth (read-only, no sensitive data). | |
| 241 | + | // But it's only accessible via the internal route prefix which is not public. | |
| 242 | + | let thread_id = Uuid::parse_str(&id).map_err(|_| { | |
| 243 | + | StatusCode::NOT_FOUND.into_response() | |
| 244 | + | })?; | |
| 245 | + | ||
| 246 | + | let (post_count, last_activity_at) = mt_db::queries::get_thread_stats(&state.db, thread_id) | |
| 247 | + | .await | |
| 248 | + | .map_err(db_error)? | |
| 249 | + | .unwrap_or((0, None)); | |
| 250 | + | ||
| 251 | + | Ok(Json(ThreadStatsResponse { | |
| 252 | + | post_count, | |
| 253 | + | last_activity_at, | |
| 254 | + | })) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Build the internal API router. Registered outside CSRF/session middleware. | |
| 258 | + | pub fn internal_routes(state: AppState) -> Router { | |
| 259 | + | Router::new() | |
| 260 | + | .route("/internal/communities", post(create_community)) | |
| 261 | + | .route("/internal/threads", post(create_thread)) | |
| 262 | + | .route("/internal/threads/{id}/stats", get(thread_stats)) | |
| 263 | + | .with_state(state) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // ============================================================================ | |
| 267 | + | // Helpers | |
| 268 | + | // ============================================================================ | |
| 269 | + | ||
| 270 | + | fn db_error(e: sqlx::Error) -> Response { | |
| 271 | + | tracing::error!(error = %e, "internal API database error"); | |
| 272 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 273 | + | } |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | mod admin; | |
| 4 | 4 | mod flagging; | |
| 5 | 5 | mod forum; | |
| 6 | + | pub mod internal; | |
| 6 | 7 | mod moderation; | |
| 7 | 8 | mod search; | |
| 8 | 9 | mod settings; | |
| @@ -21,6 +22,8 @@ use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extract | |||
| 21 | 22 | use tower_sessions::Session; | |
| 22 | 23 | use uuid::Uuid; | |
| 23 | 24 | ||
| 25 | + | use mt_core::types::{CommunityRole, ModAction}; | |
| 26 | + | ||
| 24 | 27 | use crate::auth::{self, MaybeUser}; | |
| 25 | 28 | use crate::csrf; | |
| 26 | 29 | use crate::templates::*; | |
| @@ -252,7 +255,7 @@ pub(super) struct DeleteTagForm { | |||
| 252 | 255 | ||
| 253 | 256 | /// Render markdown to HTML, stripping raw HTML events to prevent XSS. | |
| 254 | 257 | pub(super) fn render_markdown(input: &str) -> String { | |
| 255 | - | crate::markdown::render(input) | |
| 258 | + | docengine::render_strict(input) | |
| 256 | 259 | } | |
| 257 | 260 | ||
| 258 | 261 | /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. | |
| @@ -261,8 +264,9 @@ pub(super) fn render_markdown_with_mentions( | |||
| 261 | 264 | community_slug: &str, | |
| 262 | 265 | valid_usernames: &std::collections::HashSet<String>, | |
| 263 | 266 | ) -> String { | |
| 264 | - | let resolved = crate::markdown::resolve_mentions(input, community_slug, valid_usernames); | |
| 265 | - | crate::markdown::render(&resolved) | |
| 267 | + | let template = format!("/p/{community_slug}/u/{{username}}"); | |
| 268 | + | let resolved = docengine::resolve_mentions(input, valid_usernames, &template); | |
| 269 | + | docengine::render_strict(&resolved) | |
| 266 | 270 | } | |
| 267 | 271 | ||
| 268 | 272 | // ============================================================================ | |
| @@ -312,13 +316,14 @@ pub(super) async fn get_role( | |||
| 312 | 316 | db: &sqlx::PgPool, | |
| 313 | 317 | user_id: Uuid, | |
| 314 | 318 | community_id: Uuid, | |
| 315 | - | ) -> Result<Option<String>, Response> { | |
| 316 | - | mt_db::queries::get_user_role(db, user_id, community_id) | |
| 319 | + | ) -> Result<Option<CommunityRole>, Response> { | |
| 320 | + | let role_str = mt_db::queries::get_user_role(db, user_id, community_id) | |
| 317 | 321 | .await | |
| 318 | 322 | .map_err(|e| { | |
| 319 | 323 | tracing::error!(error = ?e, "db error fetching role"); | |
| 320 | 324 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 321 | - | }) | |
| 325 | + | })?; | |
| 326 | + | Ok(role_str.and_then(|s| CommunityRole::from_db(&s))) | |
| 322 | 327 | } | |
| 323 | 328 | ||
| 324 | 329 | /// Look up a user by username, returning 422 if not found. | |
| @@ -341,7 +346,7 @@ pub(super) async fn log_mod_action( | |||
| 341 | 346 | db: &sqlx::PgPool, | |
| 342 | 347 | community_id: Option<Uuid>, | |
| 343 | 348 | actor_id: Uuid, | |
| 344 | - | action: &str, | |
| 349 | + | action: ModAction, | |
| 345 | 350 | target_user: Option<Uuid>, | |
| 346 | 351 | target_id: Option<Uuid>, | |
| 347 | 352 | reason: Option<&str>, | |
| @@ -399,13 +404,13 @@ pub(super) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Resul | |||
| 399 | 404 | // ============================================================================ | |
| 400 | 405 | ||
| 401 | 406 | /// Is this user a moderator or owner in the community? | |
| 402 | - | pub(super) fn is_mod_or_owner(role: &Option<String>) -> bool { | |
| 403 | - | matches!(role.as_deref(), Some("moderator" | "owner")) | |
| 407 | + | pub(super) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool { | |
| 408 | + | role.is_some_and(|r| r.is_mod_or_owner()) | |
| 404 | 409 | } | |
| 405 | 410 | ||
| 406 | 411 | /// Is this user an owner of the community? | |
| 407 | - | pub(super) fn is_owner(role: &Option<String>) -> bool { | |
| 408 | - | matches!(role.as_deref(), Some("owner")) | |
| 412 | + | pub(super) fn is_owner(role: &Option<CommunityRole>) -> bool { | |
| 413 | + | role.is_some_and(|r| r.is_owner()) | |
| 409 | 414 | } | |
| 410 | 415 | ||
| 411 | 416 | // ============================================================================ | |
| @@ -528,7 +533,7 @@ pub(super) async fn require_mod_or_owner( | |||
| 528 | 533 | state: &AppState, | |
| 529 | 534 | slug: &str, | |
| 530 | 535 | user: &auth::SessionUser, | |
| 531 | - | ) -> Result<(mt_db::queries::CommunityRow, Option<String>), Response> { | |
| 536 | + | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 532 | 537 | let community = get_community(&state.db, slug).await?; | |
| 533 | 538 | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 534 | 539 | if !is_mod_or_owner(&role) { |
| @@ -13,6 +13,8 @@ use crate::csrf; | |||
| 13 | 13 | use crate::templates::*; | |
| 14 | 14 | use crate::AppState; | |
| 15 | 15 | ||
| 16 | + | use mt_core::types::{BanType, ModAction}; | |
| 17 | + | ||
| 16 | 18 | use super::{ | |
| 17 | 19 | get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner, log_mod_action, | |
| 18 | 20 | parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm, PageQuery, UnbanForm, | |
| @@ -42,7 +44,7 @@ pub(super) async fn pin_thread_handler( | |||
| 42 | 44 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 43 | 45 | })?; | |
| 44 | 46 | ||
| 45 | - | let action = if new_pinned { "pin_thread" } else { "unpin_thread" }; | |
| 47 | + | let action = if new_pinned { ModAction::PinThread } else { ModAction::UnpinThread }; | |
| 46 | 48 | log_mod_action( | |
| 47 | 49 | &state.db, Some(thread_data.community_id), user.user_id, | |
| 48 | 50 | action, None, Some(thread_data.id), None, | |
| @@ -78,7 +80,7 @@ pub(super) async fn lock_thread_handler( | |||
| 78 | 80 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 79 | 81 | })?; | |
| 80 | 82 | ||
| 81 | - | let action = if new_locked { "lock_thread" } else { "unlock_thread" }; | |
| 83 | + | let action = if new_locked { ModAction::LockThread } else { ModAction::UnlockThread }; | |
| 82 | 84 | log_mod_action( | |
| 83 | 85 | &state.db, Some(thread_data.community_id), user.user_id, | |
| 84 | 86 | action, None, Some(thread_data.id), None, | |
| @@ -128,7 +130,7 @@ pub(super) async fn mod_remove_post_handler( | |||
| 128 | 130 | ||
| 129 | 131 | log_mod_action( | |
| 130 | 132 | &state.db, Some(post_data.community_id), user.user_id, | |
| 131 | - | "remove_post", Some(post_data.author_id), Some(post_id), None, | |
| 133 | + | ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None, | |
| 132 | 134 | ).await; | |
| 133 | 135 | ||
| 134 | 136 | Ok(Redirect::to(&format!( | |
| @@ -247,7 +249,7 @@ pub(super) async fn ban_user_handler( | |||
| 247 | 249 | ||
| 248 | 250 | mt_db::mutations::create_community_ban( | |
| 249 | 251 | &state.db, community.id, target_id, user.user_id, | |
| 250 | - | "ban", reason, expires_at, | |
| 252 | + | BanType::Ban, reason, expires_at, | |
| 251 | 253 | ) | |
| 252 | 254 | .await | |
| 253 | 255 | .map_err(|e| { | |
| @@ -257,7 +259,7 @@ pub(super) async fn ban_user_handler( | |||
| 257 | 259 | ||
| 258 | 260 | log_mod_action( | |
| 259 | 261 | &state.db, Some(community.id), user.user_id, | |
| 260 | - | "ban", Some(target_id), None, reason, | |
| 262 | + | ModAction::Ban, Some(target_id), None, reason, | |
| 261 | 263 | ).await; | |
| 262 | 264 | ||
| 263 | 265 | Ok(Redirect::to(&format!( | |
| @@ -279,7 +281,7 @@ pub(super) async fn unban_user_handler( | |||
| 279 | 281 | ||
| 280 | 282 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| 281 | 283 | ||
| 282 | - | mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, "ban") | |
| 284 | + | mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Ban) | |
| 283 | 285 | .await | |
| 284 | 286 | .map_err(|e| { | |
| 285 | 287 | tracing::error!(error = ?e, "db error removing ban"); | |
| @@ -288,7 +290,7 @@ pub(super) async fn unban_user_handler( | |||
| 288 | 290 | ||
| 289 | 291 | log_mod_action( | |
| 290 | 292 | &state.db, Some(community.id), user.user_id, | |
| 291 | - | "unban", Some(target_id), None, None, | |
| 293 | + | ModAction::Unban, Some(target_id), None, None, | |
| 292 | 294 | ).await; | |
| 293 | 295 | ||
| 294 | 296 | Ok(Redirect::to(&format!( | |
| @@ -326,7 +328,7 @@ pub(super) async fn mute_user_handler( | |||
| 326 | 328 | ||
| 327 | 329 | mt_db::mutations::create_community_ban( | |
| 328 | 330 | &state.db, community.id, target_id, user.user_id, | |
| 329 | - | "mute", reason, expires_at, | |
| 331 | + | BanType::Mute, reason, expires_at, | |
| 330 | 332 | ) | |
| 331 | 333 | .await | |
| 332 | 334 | .map_err(|e| { | |
| @@ -336,7 +338,7 @@ pub(super) async fn mute_user_handler( | |||
| 336 | 338 | ||
| 337 | 339 | log_mod_action( | |
| 338 | 340 | &state.db, Some(community.id), user.user_id, | |
| 339 | - | "mute", Some(target_id), None, reason, | |
| 341 | + | ModAction::Mute, Some(target_id), None, reason, | |
| 340 | 342 | ).await; | |
| 341 | 343 | ||
| 342 | 344 | Ok(Redirect::to(&format!( | |
| @@ -358,7 +360,7 @@ pub(super) async fn unmute_user_handler( | |||
| 358 | 360 | ||
| 359 | 361 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| 360 | 362 | ||
| 361 | - | mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, "mute") | |
| 363 | + | mt_db::mutations::remove_community_ban(&state.db, community.id, target_id, BanType::Mute) | |
| 362 | 364 | .await | |
| 363 | 365 | .map_err(|e| { | |
| 364 | 366 | tracing::error!(error = ?e, "db error removing mute"); | |
| @@ -367,7 +369,7 @@ pub(super) async fn unmute_user_handler( | |||
| 367 | 369 | ||
| 368 | 370 | log_mod_action( | |
| 369 | 371 | &state.db, Some(community.id), user.user_id, | |
| 370 | - | "unmute", Some(target_id), None, None, | |
| 372 | + | ModAction::Unmute, Some(target_id), None, None, | |
| 371 | 373 | ).await; | |
| 372 | 374 | ||
| 373 | 375 | Ok(Redirect::to(&format!( |