Skip to main content

max / multithreaded

Internal API, typed enums, DocEngine migration, transaction fix - Internal API: HMAC-SHA256 authenticated endpoints for MNW integration - POST /internal/communities (create with default categories) - POST /internal/threads (create with external_ref, idempotent) - GET /internal/threads/:id/stats (post count + last activity) - Migration 021: threads.external_ref column + unique index - 10 integration tests + 2 unit tests for auth - Typed enums: CommunityRole, BanType, ModAction, SortColumn, SortOrder - create_post wrapped in transaction (atomicity fix) - markdown.rs removed, replaced with docengine crate - Config: mnw_base_url as Arc<str>, internal_shared_secret field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:30 UTC
Commit: 738be80d870163847583ba7ee36bbc29435a00b3
Parent: 504f3ea
27 files changed, +1421 insertions, -591 deletions
M Cargo.lock +19 -1
@@ -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"
M Cargo.toml +5 -2
@@ -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;
M src/config.rs +7 -2
@@ -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 + }
M src/lib.rs +1 -1
@@ -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;
M src/main.rs +3 -1
@@ -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('&', "&amp;")
65 - .replace('<', "&lt;")
66 - .replace('>', "&gt;")
67 - .replace('"', "&quot;")
68 - .replace('\'', "&#x27;")
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: &regex_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: &regex_lite::Regex,
162 - ) -> String {
163 - re.replace_all(text, |caps: &regex_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("![alt text](https://example.com/img.png)");
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("![alt](javascript:alert(1))");
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, &quote_authors)
431 + docengine::post_process_quotes(&p.body_html, &quote_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 + }
M src/routes/mod.rs +17 -12
@@ -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!(
M todo.md +61 -5