max / makenotwork
23 files changed,
+1455 insertions,
-782 deletions
A
multithreaded/.sqlx/query-0d9bb9d5a6919ac3280ad1c0586b6114559dc610c93629d3f9f99c2cf51de866.json
+22
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT suspension_reason FROM communities WHERE id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "suspension_reason", | |
| 9 | + | "type_info": "Text" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | true | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "0d9bb9d5a6919ac3280ad1c0586b6114559dc610c93629d3f9f99c2cf51de866" | |
| 22 | + | } |
A
multithreaded/.sqlx/query-59953c28712213fdcd1d8f2a53219fe106e75f288669565848345200a7ae2286.json
+22
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT COUNT(*) AS \"count!\" FROM threads t\n JOIN categories c ON c.id = t.category_id\n WHERE c.community_id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "count!", | |
| 9 | + | "type_info": "Int8" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "59953c28712213fdcd1d8f2a53219fe106e75f288669565848345200a7ae2286" | |
| 22 | + | } |
A
multithreaded/.sqlx/query-5ed94c9e85f15c46c8dd1e701dde63071ba44711af8d7c340850e56df6388cac.json
+35
| @@ -0,0 +1,35 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT pf.post_id AS \"post_id!\", p.author_id AS \"author_id!\", t.id AS \"thread_id!\"\n FROM post_flags pf\n JOIN posts p ON p.id = pf.post_id\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE pf.id = $1 AND c.community_id = $2", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "post_id!", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "author_id!", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "thread_id!", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | } | |
| 21 | + | ], | |
| 22 | + | "parameters": { | |
| 23 | + | "Left": [ | |
| 24 | + | "Uuid", | |
| 25 | + | "Uuid" | |
| 26 | + | ] | |
| 27 | + | }, | |
| 28 | + | "nullable": [ | |
| 29 | + | false, | |
| 30 | + | false, | |
| 31 | + | false | |
| 32 | + | ] | |
| 33 | + | }, | |
| 34 | + | "hash": "5ed94c9e85f15c46c8dd1e701dde63071ba44711af8d7c340850e56df6388cac" | |
| 35 | + | } |
A
multithreaded/.sqlx/query-790719db1a55f098443b09b1b9a31d161ee4aed1277323ee43f147ef327d8cd3.json
+22
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT (removed_at IS NOT NULL) AS \"removed!\" FROM posts WHERE id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "removed!", | |
| 9 | + | "type_info": "Bool" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "790719db1a55f098443b09b1b9a31d161ee4aed1277323ee43f147ef327d8cd3" | |
| 22 | + | } |
A
multithreaded/.sqlx/query-7d9c0e757bb77446e6cace3d83b718edc18a3a020e3913b2ae0ced477ed7b24e.json
+28
| @@ -0,0 +1,28 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "signature_markdown", | |
| 9 | + | "type_info": "Text" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "signature_html", | |
| 14 | + | "type_info": "Text" | |
| 15 | + | } | |
| 16 | + | ], | |
| 17 | + | "parameters": { | |
| 18 | + | "Left": [ | |
| 19 | + | "Uuid" | |
| 20 | + | ] | |
| 21 | + | }, | |
| 22 | + | "nullable": [ | |
| 23 | + | true, | |
| 24 | + | true | |
| 25 | + | ] | |
| 26 | + | }, | |
| 27 | + | "hash": "7d9c0e757bb77446e6cace3d83b718edc18a3a020e3913b2ae0ced477ed7b24e" | |
| 28 | + | } |
A
multithreaded/.sqlx/query-96b258d531a69452f8ffb93123c05e112e0c2eae2b5c352d59c47979848305c3.json
+16
| @@ -0,0 +1,16 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "UPDATE users SET signature_markdown = $2, signature_html = $3 WHERE mnw_account_id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [], | |
| 6 | + | "parameters": { | |
| 7 | + | "Left": [ | |
| 8 | + | "Uuid", | |
| 9 | + | "Text", | |
| 10 | + | "Text" | |
| 11 | + | ] | |
| 12 | + | }, | |
| 13 | + | "nullable": [] | |
| 14 | + | }, | |
| 15 | + | "hash": "96b258d531a69452f8ffb93123c05e112e0c2eae2b5c352d59c47979848305c3" | |
| 16 | + | } |
A
multithreaded/.sqlx/query-abfd2381ad3c10a2581671173e22ae35a5936d073388c6c24dfa35451a7fb74d.json
+14
| @@ -0,0 +1,14 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "UPDATE users SET signature_markdown = NULL, signature_html = NULL WHERE mnw_account_id = $1", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [], | |
| 6 | + | "parameters": { | |
| 7 | + | "Left": [ | |
| 8 | + | "Uuid" | |
| 9 | + | ] | |
| 10 | + | }, | |
| 11 | + | "nullable": [] | |
| 12 | + | }, | |
| 13 | + | "hash": "abfd2381ad3c10a2581671173e22ae35a5936d073388c6c24dfa35451a7fb74d" | |
| 14 | + | } |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Database write mutations — inserts, updates, deletes. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | - | use mt_core::types::{BanType, CommunityState, ModAction, ModActor}; | |
| 4 | + | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction, ModActor}; | |
| 5 | 5 | use sqlx::PgPool; | |
| 6 | 6 | use uuid::Uuid; | |
| 7 | 7 | ||
| @@ -95,7 +95,7 @@ pub async fn ensure_membership_with_role( | |||
| 95 | 95 | pool: &PgPool, | |
| 96 | 96 | user_id: Uuid, | |
| 97 | 97 | community_id: Uuid, | |
| 98 | - | role: &str, | |
| 98 | + | role: CommunityRole, | |
| 99 | 99 | ) -> Result<(), sqlx::Error> { | |
| 100 | 100 | sqlx::query!( | |
| 101 | 101 | "INSERT INTO memberships (user_id, community_id, role) | |
| @@ -103,7 +103,7 @@ pub async fn ensure_membership_with_role( | |||
| 103 | 103 | ON CONFLICT (user_id, community_id) DO NOTHING", | |
| 104 | 104 | user_id, | |
| 105 | 105 | community_id, | |
| 106 | - | role, | |
| 106 | + | role.as_str(), | |
| 107 | 107 | ) | |
| 108 | 108 | .execute(pool) | |
| 109 | 109 | .await?; | |
| @@ -1339,3 +1339,36 @@ pub async fn mark_images_s3_purged(pool: &PgPool, image_ids: &[Uuid]) -> Result< | |||
| 1339 | 1339 | .await?; | |
| 1340 | 1340 | Ok(()) | |
| 1341 | 1341 | } | |
| 1342 | + | ||
| 1343 | + | /// Clear a user's saved signature (markdown + rendered html). | |
| 1344 | + | #[tracing::instrument(skip_all)] | |
| 1345 | + | pub async fn clear_user_signature(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { | |
| 1346 | + | sqlx::query!( | |
| 1347 | + | "UPDATE users SET signature_markdown = NULL, signature_html = NULL \ | |
| 1348 | + | WHERE mnw_account_id = $1", | |
| 1349 | + | user_id, | |
| 1350 | + | ) | |
| 1351 | + | .execute(pool) | |
| 1352 | + | .await?; | |
| 1353 | + | Ok(()) | |
| 1354 | + | } | |
| 1355 | + | ||
| 1356 | + | /// Save a user's signature markdown and its pre-rendered html. | |
| 1357 | + | #[tracing::instrument(skip_all)] | |
| 1358 | + | pub async fn set_user_signature( | |
| 1359 | + | pool: &PgPool, | |
| 1360 | + | user_id: Uuid, | |
| 1361 | + | markdown: &str, | |
| 1362 | + | html: &str, | |
| 1363 | + | ) -> Result<(), sqlx::Error> { | |
| 1364 | + | sqlx::query!( | |
| 1365 | + | "UPDATE users SET signature_markdown = $2, signature_html = $3 \ | |
| 1366 | + | WHERE mnw_account_id = $1", | |
| 1367 | + | user_id, | |
| 1368 | + | markdown, | |
| 1369 | + | html, | |
| 1370 | + | ) | |
| 1371 | + | .execute(pool) | |
| 1372 | + | .await?; | |
| 1373 | + | Ok(()) | |
| 1374 | + | } |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Database read queries — projection structs and SQL. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | - | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction}; | |
| 4 | + | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction, SortColumn, SortOrder}; | |
| 5 | 5 | use sqlx::PgPool; | |
| 6 | 6 | use uuid::Uuid; | |
| 7 | 7 | ||
| @@ -372,23 +372,49 @@ pub async fn list_threads_in_category_paginated( | |||
| 372 | 372 | .await | |
| 373 | 373 | } | |
| 374 | 374 | ||
| 375 | - | /// List threads with sorting. `sort` must be "replies" or "activity". | |
| 376 | - | /// `order` must be "asc" or "desc". Pinned threads always sort first. | |
| 375 | + | /// List threads in a category, sorted, optionally filtered to one tag. | |
| 376 | + | /// | |
| 377 | + | /// Pinned threads always sort first. `sort`/`order` are typed enums, so the | |
| 378 | + | /// `ORDER BY` clause is chosen from an exhaustive match of fixed literals (no | |
| 379 | + | /// user string reaches the SQL); when `tag_slug` is `Some`, an extra join | |
| 380 | + | /// restricts the results to threads carrying that tag in the same community. | |
| 377 | 381 | #[tracing::instrument(skip_all)] | |
| 382 | + | #[allow(clippy::too_many_arguments)] | |
| 378 | 383 | pub async fn list_threads_in_category_sorted( | |
| 379 | 384 | pool: &PgPool, | |
| 380 | 385 | community_slug: &str, | |
| 381 | 386 | category_slug: &str, | |
| 382 | - | sort: &str, | |
| 383 | - | order: &str, | |
| 387 | + | sort: SortColumn, | |
| 388 | + | order: SortOrder, | |
| 384 | 389 | limit: i64, | |
| 385 | 390 | offset: i64, | |
| 391 | + | tag_slug: Option<&str>, | |
| 386 | 392 | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 387 | 393 | let order_clause = match (sort, order) { | |
| 388 | - | ("replies", "asc") => "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC", | |
| 389 | - | ("replies", _) => "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC", | |
| 390 | - | (_, "asc") => "ORDER BY t.pinned DESC, t.last_activity_at ASC", | |
| 391 | - | _ => "ORDER BY t.pinned DESC, t.last_activity_at DESC", | |
| 394 | + | (SortColumn::Replies, SortOrder::Asc) => { | |
| 395 | + | "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC" | |
| 396 | + | } | |
| 397 | + | (SortColumn::Replies, SortOrder::Desc) => { | |
| 398 | + | "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC" | |
| 399 | + | } | |
| 400 | + | (SortColumn::Activity, SortOrder::Asc) => { | |
| 401 | + | "ORDER BY t.pinned DESC, t.last_activity_at ASC" | |
| 402 | + | } | |
| 403 | + | (SortColumn::Activity, SortOrder::Desc) => { | |
| 404 | + | "ORDER BY t.pinned DESC, t.last_activity_at DESC" | |
| 405 | + | } | |
| 406 | + | }; | |
| 407 | + | ||
| 408 | + | // When a tag filter is present it binds as $3, pushing limit/offset to $4/$5. | |
| 409 | + | let (tag_join, limit_ph, offset_ph) = if tag_slug.is_some() { | |
| 410 | + | ( | |
| 411 | + | "JOIN thread_tags tt ON tt.thread_id = t.id \ | |
| 412 | + | JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id", | |
| 413 | + | "$4", | |
| 414 | + | "$5", | |
| 415 | + | ) | |
| 416 | + | } else { | |
| 417 | + | ("", "$3", "$4") | |
| 392 | 418 | }; | |
| 393 | 419 | ||
| 394 | 420 | let query = format!( | |
| @@ -402,19 +428,20 @@ pub async fn list_threads_in_category_sorted( | |||
| 402 | 428 | JOIN categories c ON c.id = t.category_id | |
| 403 | 429 | JOIN communities co ON co.id = c.community_id | |
| 404 | 430 | JOIN users u ON u.mnw_account_id = t.author_id | |
| 431 | + | {tag_join} | |
| 405 | 432 | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 406 | 433 | {order_clause} | |
| 407 | - | LIMIT $3 OFFSET $4" | |
| 434 | + | LIMIT {limit_ph} OFFSET {offset_ph}" | |
| 408 | 435 | ); | |
| 409 | 436 | ||
| 410 | 437 | // runtime-checked: dynamic SQL (cannot use compile-time macro) | |
| 411 | - | sqlx::query_as::<_, ThreadWithMeta>(&query) | |
| 438 | + | let mut q = sqlx::query_as::<_, ThreadWithMeta>(&query) | |
| 412 | 439 | .bind(community_slug) | |
| 413 | - | .bind(category_slug) | |
| 414 | - | .bind(limit) | |
| 415 | - | .bind(offset) | |
| 416 | - | .fetch_all(pool) | |
| 417 | - | .await | |
| 440 | + | .bind(category_slug); | |
| 441 | + | if let Some(tag) = tag_slug { | |
| 442 | + | q = q.bind(tag); | |
| 443 | + | } | |
| 444 | + | q.bind(limit).bind(offset).fetch_all(pool).await | |
| 418 | 445 | } | |
| 419 | 446 | ||
| 420 | 447 | #[tracing::instrument(skip_all)] | |
| @@ -1393,59 +1420,6 @@ pub async fn count_threads_in_category_filtered( | |||
| 1393 | 1420 | } | |
| 1394 | 1421 | } | |
| 1395 | 1422 | ||
| 1396 | - | /// List threads with sorting, optionally filtered by tag slug. | |
| 1397 | - | #[tracing::instrument(skip_all)] | |
| 1398 | - | #[allow(clippy::too_many_arguments)] | |
| 1399 | - | pub async fn list_threads_in_category_sorted_filtered( | |
| 1400 | - | pool: &PgPool, | |
| 1401 | - | community_slug: &str, | |
| 1402 | - | category_slug: &str, | |
| 1403 | - | sort: &str, | |
| 1404 | - | order: &str, | |
| 1405 | - | limit: i64, | |
| 1406 | - | offset: i64, | |
| 1407 | - | tag_slug: Option<&str>, | |
| 1408 | - | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 1409 | - | if tag_slug.is_none() { | |
| 1410 | - | return list_threads_in_category_sorted(pool, community_slug, category_slug, sort, order, limit, offset).await; | |
| 1411 | - | } | |
| 1412 | - | ||
| 1413 | - | let tag = tag_slug.unwrap(); | |
| 1414 | - | let order_clause = match (sort, order) { | |
| 1415 | - | ("replies", "asc") => "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC", | |
| 1416 | - | ("replies", _) => "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC", | |
| 1417 | - | (_, "asc") => "ORDER BY t.pinned DESC, t.last_activity_at ASC", | |
| 1418 | - | _ => "ORDER BY t.pinned DESC, t.last_activity_at DESC", | |
| 1419 | - | }; | |
| 1420 | - | ||
| 1421 | - | let query = format!( | |
| 1422 | - | "SELECT t.id, t.title, | |
| 1423 | - | COALESCE(u.display_name, u.username) AS author_name, | |
| 1424 | - | u.username AS author_username, | |
| 1425 | - | GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count, | |
| 1426 | - | t.last_activity_at, | |
| 1427 | - | t.pinned, t.locked | |
| 1428 | - | FROM threads t | |
| 1429 | - | JOIN categories c ON c.id = t.category_id | |
| 1430 | - | JOIN communities co ON co.id = c.community_id | |
| 1431 | - | JOIN users u ON u.mnw_account_id = t.author_id | |
| 1432 | - | JOIN thread_tags tt ON tt.thread_id = t.id | |
| 1433 | - | JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id | |
| 1434 | - | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 1435 | - | {order_clause} | |
| 1436 | - | LIMIT $4 OFFSET $5" | |
| 1437 | - | ); | |
| 1438 | - | ||
| 1439 | - | // runtime-checked: dynamic SQL (cannot use compile-time macro) | |
| 1440 | - | sqlx::query_as::<_, ThreadWithMeta>(&query) | |
| 1441 | - | .bind(community_slug) | |
| 1442 | - | .bind(category_slug) | |
| 1443 | - | .bind(tag) | |
| 1444 | - | .bind(limit) | |
| 1445 | - | .bind(offset) | |
| 1446 | - | .fetch_all(pool) | |
| 1447 | - | .await | |
| 1448 | - | } | |
| 1449 | 1423 | ||
| 1450 | 1424 | // ============================================================================ | |
| 1451 | 1425 | // Flag queries | |
| @@ -1779,3 +1753,87 @@ pub async fn count_recent_uploads_by_user( | |||
| 1779 | 1753 | .fetch_one(pool) | |
| 1780 | 1754 | .await | |
| 1781 | 1755 | } | |
| 1756 | + | ||
| 1757 | + | /// Post-removal target for a flag, scoped to a community: `(post_id, author_id, | |
| 1758 | + | /// thread_id)`. Returns `None` if the flag doesn't exist or belongs to another | |
| 1759 | + | /// community — the community scoping is enforced here rather than in the handler. | |
| 1760 | + | #[tracing::instrument(skip_all)] | |
| 1761 | + | pub async fn get_flag_removal_target( | |
| 1762 | + | pool: &PgPool, | |
| 1763 | + | flag_id: Uuid, | |
| 1764 | + | community_id: Uuid, | |
| 1765 | + | ) -> Result<Option<(Uuid, Uuid, Uuid)>, sqlx::Error> { | |
| 1766 | + | let row = sqlx::query!( | |
| 1767 | + | r#"SELECT pf.post_id AS "post_id!", p.author_id AS "author_id!", t.id AS "thread_id!" | |
| 1768 | + | FROM post_flags pf | |
| 1769 | + | JOIN posts p ON p.id = pf.post_id | |
| 1770 | + | JOIN threads t ON t.id = p.thread_id | |
| 1771 | + | JOIN categories c ON c.id = t.category_id | |
| 1772 | + | WHERE pf.id = $1 AND c.community_id = $2"#, | |
| 1773 | + | flag_id, | |
| 1774 | + | community_id, | |
| 1775 | + | ) | |
| 1776 | + | .fetch_optional(pool) | |
| 1777 | + | .await?; | |
| 1778 | + | Ok(row.map(|r| (r.post_id, r.author_id, r.thread_id))) | |
| 1779 | + | } | |
| 1780 | + | ||
| 1781 | + | /// Whether a post is mod-removed (`removed_at IS NOT NULL`). | |
| 1782 | + | #[tracing::instrument(skip_all)] | |
| 1783 | + | pub async fn is_post_removed(pool: &PgPool, post_id: Uuid) -> Result<bool, sqlx::Error> { | |
| 1784 | + | sqlx::query_scalar!( | |
| 1785 | + | r#"SELECT (removed_at IS NOT NULL) AS "removed!" FROM posts WHERE id = $1"#, | |
| 1786 | + | post_id, | |
| 1787 | + | ) | |
| 1788 | + | .fetch_one(pool) | |
| 1789 | + | .await | |
| 1790 | + | } | |
| 1791 | + | ||
| 1792 | + | /// Count all threads in a community (across every category, including | |
| 1793 | + | /// soft-deleted — matches the admin dashboard's historical total). | |
| 1794 | + | #[tracing::instrument(skip_all)] | |
| 1795 | + | pub async fn count_threads_in_community( | |
| 1796 | + | pool: &PgPool, | |
| 1797 | + | community_id: Uuid, | |
| 1798 | + | ) -> Result<i64, sqlx::Error> { | |
| 1799 | + | sqlx::query_scalar!( | |
| 1800 | + | r#"SELECT COUNT(*) AS "count!" FROM threads t | |
| 1801 | + | JOIN categories c ON c.id = t.category_id | |
| 1802 | + | WHERE c.community_id = $1"#, | |
| 1803 | + | community_id, | |
| 1804 | + | ) | |
| 1805 | + | .fetch_one(pool) | |
| 1806 | + | .await | |
| 1807 | + | } | |
| 1808 | + | ||
| 1809 | + | /// The stored suspension reason for a community, if any. | |
| 1810 | + | #[tracing::instrument(skip_all)] | |
| 1811 | + | pub async fn get_community_suspension_reason( | |
| 1812 | + | pool: &PgPool, | |
| 1813 | + | community_id: Uuid, | |
| 1814 | + | ) -> Result<Option<String>, sqlx::Error> { | |
| 1815 | + | sqlx::query_scalar!( | |
| 1816 | + | "SELECT suspension_reason FROM communities WHERE id = $1", | |
| 1817 | + | community_id, | |
| 1818 | + | ) | |
| 1819 | + | .fetch_one(pool) | |
| 1820 | + | .await | |
| 1821 | + | } | |
| 1822 | + | ||
| 1823 | + | /// A user's saved signature: `(markdown, html)`. Either field may be `NULL`. | |
| 1824 | + | pub type UserSignature = (Option<String>, Option<String>); | |
| 1825 | + | ||
| 1826 | + | /// Fetch a user's saved signature, or `None` if the user row is absent. | |
| 1827 | + | #[tracing::instrument(skip_all)] | |
| 1828 | + | pub async fn get_user_signature( | |
| 1829 | + | pool: &PgPool, | |
| 1830 | + | user_id: Uuid, | |
| 1831 | + | ) -> Result<Option<UserSignature>, sqlx::Error> { | |
| 1832 | + | let row = sqlx::query!( | |
| 1833 | + | "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", | |
| 1834 | + | user_id, | |
| 1835 | + | ) | |
| 1836 | + | .fetch_optional(pool) | |
| 1837 | + | .await?; | |
| 1838 | + | Ok(row.map(|r| (r.signature_markdown, r.signature_html))) | |
| 1839 | + | } |
| @@ -465,6 +465,147 @@ pub async fn reverify( | |||
| 465 | 465 | Redirect::to(&url) | |
| 466 | 466 | } | |
| 467 | 467 | ||
| 468 | + | /// Retry backoffs for the OAuth token/userinfo round trips (two retries). | |
| 469 | + | const OAUTH_BACKOFFS: [std::time::Duration; 2] = [ | |
| 470 | + | std::time::Duration::from_millis(500), | |
| 471 | + | std::time::Duration::from_millis(1000), | |
| 472 | + | ]; | |
| 473 | + | ||
| 474 | + | /// Exchange the authorization code for a token, retrying on transport/5xx. | |
| 475 | + | /// | |
| 476 | + | /// Returns the parsed token on success, or the `?error=` slug to redirect with. | |
| 477 | + | /// Parsing happens here so the caller never holds an un-parsed response — there | |
| 478 | + | /// is no post-loop `unwrap()` to trip if the retry logic ever changes. | |
| 479 | + | async fn exchange_code_for_token( | |
| 480 | + | http: &reqwest::Client, | |
| 481 | + | config: &crate::config::Config, | |
| 482 | + | code: &str, | |
| 483 | + | verifier: &str, | |
| 484 | + | ) -> Result<TokenResponse, &'static str> { | |
| 485 | + | let token_url = format!("{}/oauth/token", config.mnw_base_url); | |
| 486 | + | tracing::info!(%token_url, "exchanging code for token"); | |
| 487 | + | // `attempt` is the retry counter (also logged), and the loop runs one past | |
| 488 | + | // the backoff array — an iterator-with-enumerate doesn't fit the N+1 shape. | |
| 489 | + | #[allow(clippy::needless_range_loop)] | |
| 490 | + | for attempt in 0..=OAUTH_BACKOFFS.len() { | |
| 491 | + | let res = http | |
| 492 | + | .post(&token_url) | |
| 493 | + | .json(&serde_json::json!({ | |
| 494 | + | "grant_type": "authorization_code", | |
| 495 | + | "code": code, | |
| 496 | + | "redirect_uri": config.oauth_redirect_uri, | |
| 497 | + | "code_verifier": verifier, | |
| 498 | + | "client_id": config.oauth_client_id, | |
| 499 | + | })) | |
| 500 | + | .send() | |
| 501 | + | .await; | |
| 502 | + | ||
| 503 | + | match res { | |
| 504 | + | Ok(r) if r.status().is_server_error() => { | |
| 505 | + | let status = r.status(); | |
| 506 | + | if attempt < OAUTH_BACKOFFS.len() { | |
| 507 | + | tracing::warn!(%status, attempt, "token exchange got 5xx, retrying"); | |
| 508 | + | sleep(OAUTH_BACKOFFS[attempt]).await; | |
| 509 | + | continue; | |
| 510 | + | } | |
| 511 | + | let body = r.text().await.unwrap_or_default(); | |
| 512 | + | tracing::error!(%status, %body, "token exchange failed after retries"); | |
| 513 | + | return Err("token_exchange_failed"); | |
| 514 | + | } | |
| 515 | + | Ok(r) if !r.status().is_success() => { | |
| 516 | + | let status = r.status(); | |
| 517 | + | let body = r.text().await.unwrap_or_default(); | |
| 518 | + | tracing::error!(%status, %body, "token exchange failed"); | |
| 519 | + | return Err("token_exchange_failed"); | |
| 520 | + | } | |
| 521 | + | Ok(r) => { | |
| 522 | + | return r.json().await.map_err(|e| { | |
| 523 | + | tracing::error!(error = %e, "token parse failed"); | |
| 524 | + | "token_parse_failed" | |
| 525 | + | }); | |
| 526 | + | } | |
| 527 | + | Err(e) => { | |
| 528 | + | if attempt < OAUTH_BACKOFFS.len() { | |
| 529 | + | tracing::warn!(error = %e, attempt, "token request failed, retrying"); | |
| 530 | + | sleep(OAUTH_BACKOFFS[attempt]).await; | |
| 531 | + | continue; | |
| 532 | + | } | |
| 533 | + | tracing::error!(error = %e, "token request failed after retries"); | |
| 534 | + | return Err("token_request_failed"); | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | // Unreachable: the loop returns on every terminal branch. Kept total so a | |
| 539 | + | // future edit to the retry logic can't reintroduce a panic path. | |
| 540 | + | Err("token_request_failed") | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | /// Fetch userinfo, retrying on transport/5xx. Returns userinfo or the `?error=` | |
| 544 | + | /// slug to redirect with. No post-loop `expect()` — the loop returns on success. | |
| 545 | + | async fn fetch_userinfo_with_retry( | |
| 546 | + | http: &reqwest::Client, | |
| 547 | + | base_url: &str, | |
| 548 | + | access_token: &str, | |
| 549 | + | ) -> Result<UserinfoResponse, &'static str> { | |
| 550 | + | #[allow(clippy::needless_range_loop)] | |
| 551 | + | for attempt in 0..=OAUTH_BACKOFFS.len() { | |
| 552 | + | match fetch_userinfo(http, base_url, access_token).await { | |
| 553 | + | Ok(i) => return Ok(i), | |
| 554 | + | Err(UserinfoError::Transport) if attempt < OAUTH_BACKOFFS.len() => { | |
| 555 | + | tracing::warn!(attempt, "userinfo transport error, retrying"); | |
| 556 | + | sleep(OAUTH_BACKOFFS[attempt]).await; | |
| 557 | + | continue; | |
| 558 | + | } | |
| 559 | + | Err(UserinfoError::Transport) => { | |
| 560 | + | tracing::error!("userinfo transport failed after retries"); | |
| 561 | + | return Err("userinfo_fetch_failed"); | |
| 562 | + | } | |
| 563 | + | Err(UserinfoError::Unauthorized) => { | |
| 564 | + | tracing::error!("userinfo unauthorized — token rejected"); | |
| 565 | + | return Err("userinfo_fetch_failed"); | |
| 566 | + | } | |
| 567 | + | Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => { | |
| 568 | + | // RefreshUnavailable is unreachable from fetch_userinfo (it's a | |
| 569 | + | // refresh-grant outcome), but the match must be exhaustive. | |
| 570 | + | tracing::error!("userinfo bad response"); | |
| 571 | + | return Err("userinfo_parse_failed"); | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | } | |
| 575 | + | Err("userinfo_fetch_failed") | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | /// Upsert the local user row from userinfo on login. `is_fan_plus`/`is_creator` | |
| 579 | + | /// are denormalised here so post rendering can JOIN the author's perks (migration | |
| 580 | + | /// 026). The stale-username vacate and the upsert run on one transaction so the | |
| 581 | + | /// freed name is visible to the insert (S2). | |
| 582 | + | async fn upsert_login_user( | |
| 583 | + | db: &sqlx::PgPool, | |
| 584 | + | info: &UserinfoResponse, | |
| 585 | + | ) -> Result<(), sqlx::Error> { | |
| 586 | + | let mut tx = db.begin().await?; | |
| 587 | + | mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?; | |
| 588 | + | sqlx::query( | |
| 589 | + | r#" | |
| 590 | + | INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) | |
| 591 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 592 | + | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 593 | + | SET username = $2, display_name = $3, avatar_url = $4, | |
| 594 | + | is_fan_plus = $5, is_creator = $6, updated_at = now() | |
| 595 | + | "#, | |
| 596 | + | ) | |
| 597 | + | .bind(info.user_id) | |
| 598 | + | .bind(&info.username) | |
| 599 | + | .bind(&info.display_name) | |
| 600 | + | .bind(&info.avatar_url) | |
| 601 | + | .bind(info.perks.fan_plus) | |
| 602 | + | .bind(info.perks.is_creator) | |
| 603 | + | .execute(&mut *tx) | |
| 604 | + | .await?; | |
| 605 | + | tx.commit().await?; | |
| 606 | + | Ok(()) | |
| 607 | + | } | |
| 608 | + | ||
| 468 | 609 | /// `GET /auth/callback` — exchange code for token, fetch userinfo, create session. | |
| 469 | 610 | #[tracing::instrument(skip_all)] | |
| 470 | 611 | pub async fn callback( | |
| @@ -521,141 +662,28 @@ pub async fn callback( | |||
| 521 | 662 | } | |
| 522 | 663 | }; | |
| 523 | 664 | ||
| 524 | - | // Exchange code for token (retry up to 2 attempts on network/5xx errors) | |
| 525 | - | let token_url = format!("{}/oauth/token", state.config.mnw_base_url); | |
| 526 | - | tracing::info!(%token_url, "exchanging code for token"); | |
| 527 | - | let backoffs = [ | |
| 528 | - | std::time::Duration::from_millis(500), | |
| 529 | - | std::time::Duration::from_millis(1000), | |
| 530 | - | ]; | |
| 531 | - | let mut token_res = None; | |
| 532 | - | for attempt in 0..=backoffs.len() { | |
| 533 | - | let res = state | |
| 534 | - | .http | |
| 535 | - | .post(&token_url) | |
| 536 | - | .json(&serde_json::json!({ | |
| 537 | - | "grant_type": "authorization_code", | |
| 538 | - | "code": code, | |
| 539 | - | "redirect_uri": state.config.oauth_redirect_uri, | |
| 540 | - | "code_verifier": verifier, | |
| 541 | - | "client_id": state.config.oauth_client_id, | |
| 542 | - | })) | |
| 543 | - | .send() | |
| 544 | - | .await; | |
| 545 | - | ||
| 546 | - | match res { | |
| 547 | - | Ok(r) if r.status().is_server_error() => { | |
| 548 | - | let status = r.status(); | |
| 549 | - | if attempt < backoffs.len() { | |
| 550 | - | tracing::warn!(%status, attempt, "token exchange got 5xx, retrying"); | |
| 551 | - | sleep(backoffs[attempt]).await; | |
| 552 | - | continue; | |
| 553 | - | } | |
| 554 | - | let body = r.text().await.unwrap_or_default(); | |
| 555 | - | tracing::error!(%status, %body, "token exchange failed after retries"); | |
| 556 | - | return Redirect::to("/?error=token_exchange_failed"); | |
| 557 | - | } | |
| 558 | - | Ok(r) if !r.status().is_success() => { | |
| 559 | - | let status = r.status(); | |
| 560 | - | let body = r.text().await.unwrap_or_default(); | |
| 561 | - | tracing::error!(%status, %body, "token exchange failed"); | |
| 562 | - | return Redirect::to("/?error=token_exchange_failed"); | |
| 563 | - | } | |
| 564 | - | Ok(r) => { | |
| 565 | - | token_res = Some(r); | |
| 566 | - | break; | |
| 567 | - | } | |
| 568 | - | Err(e) => { | |
| 569 | - | if attempt < backoffs.len() { | |
| 570 | - | tracing::warn!(error = %e, attempt, "token request failed, retrying"); | |
| 571 | - | sleep(backoffs[attempt]).await; | |
| 572 | - | continue; | |
| 573 | - | } | |
| 574 | - | tracing::error!(error = %e, "token request failed after retries"); | |
| 575 | - | return Redirect::to("/?error=token_request_failed"); | |
| 576 | - | } | |
| 577 | - | } | |
| 578 | - | } | |
| 579 | - | // Safety: loop always either sets token_res or returns early | |
| 580 | - | let token_res = token_res.unwrap(); | |
| 581 | - | ||
| 582 | - | let token: TokenResponse = match token_res.json().await { | |
| 665 | + | // Exchange code for token, then fetch userinfo — each retries on transport/5xx | |
| 666 | + | // and returns the `?error=` slug to redirect with on failure. | |
| 667 | + | let token = match exchange_code_for_token(&state.http, &state.config, &code, &verifier).await { | |
| 583 | 668 | Ok(t) => t, | |
| 584 | - | Err(e) => { | |
| 585 | - | tracing::error!(error = %e, "token parse failed"); | |
| 586 | - | return Redirect::to("/?error=token_parse_failed"); | |
| 587 | - | } | |
| 669 | + | Err(slug) => return Redirect::to(&format!("/?error={slug}")), | |
| 588 | 670 | }; | |
| 589 | 671 | ||
| 590 | - | // Fetch userinfo (retry up to 2 attempts on transport / 5xx errors). | |
| 591 | 672 | tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo"); | |
| 592 | - | let mut info: Option<UserinfoResponse> = None; | |
| 593 | - | for attempt in 0..=backoffs.len() { | |
| 594 | - | match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await { | |
| 595 | - | Ok(i) => { | |
| 596 | - | info = Some(i); | |
| 597 | - | break; | |
| 598 | - | } | |
| 599 | - | Err(UserinfoError::Transport) if attempt < backoffs.len() => { | |
| 600 | - | tracing::warn!(attempt, "userinfo transport error, retrying"); | |
| 601 | - | sleep(backoffs[attempt]).await; | |
| 602 | - | continue; | |
| 603 | - | } | |
| 604 | - | Err(UserinfoError::Transport) => { | |
| 605 | - | tracing::error!("userinfo transport failed after retries"); | |
| 606 | - | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 607 | - | } | |
| 608 | - | Err(UserinfoError::Unauthorized) => { | |
| 609 | - | tracing::error!("userinfo unauthorized — token rejected"); | |
| 610 | - | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 611 | - | } | |
| 612 | - | Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => { | |
| 613 | - | // RefreshUnavailable is unreachable from fetch_userinfo (it's a | |
| 614 | - | // refresh-grant outcome), but the match must be exhaustive. | |
| 615 | - | tracing::error!("userinfo bad response"); | |
| 616 | - | return Redirect::to("/?error=userinfo_parse_failed"); | |
| 617 | - | } | |
| 618 | - | } | |
| 619 | - | } | |
| 620 | - | let info = info.expect("userinfo loop always sets value or returns"); | |
| 673 | + | let info = match fetch_userinfo_with_retry( | |
| 674 | + | &state.http, | |
| 675 | + | &state.config.mnw_base_url, | |
| 676 | + | &token.access_token, | |
| 677 | + | ) | |
| 678 | + | .await | |
| 679 | + | { | |
| 680 | + | Ok(i) => i, | |
| 681 | + | Err(slug) => return Redirect::to(&format!("/?error={slug}")), | |
| 682 | + | }; | |
| 621 | 683 | ||
| 622 | 684 | tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful"); | |
| 623 | 685 | ||
| 624 | - | // Upsert local user. `is_fan_plus`/`is_creator` are denormalised here so | |
| 625 | - | // post rendering can look up the post author's perks via JOIN — see | |
| 626 | - | // migration 026. | |
| 627 | - | // | |
| 628 | - | // First vacate the username from any stale mirror row that still holds it: | |
| 629 | - | // MNW usernames are mutable/reusable, so a login that renames into a name an | |
| 630 | - | // old row carries would otherwise trip the `users_username_key` unique index | |
| 631 | - | // and lock the user out (S2). The vacate and the upsert run on one | |
| 632 | - | // transaction so the freed name is visible to the insert. | |
| 633 | - | let upsert_result = async { | |
| 634 | - | let mut tx = state.db.begin().await?; | |
| 635 | - | mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?; | |
| 636 | - | sqlx::query( | |
| 637 | - | r#" | |
| 638 | - | INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) | |
| 639 | - | VALUES ($1, $2, $3, $4, $5, $6) | |
| 640 | - | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 641 | - | SET username = $2, display_name = $3, avatar_url = $4, | |
| 642 | - | is_fan_plus = $5, is_creator = $6, updated_at = now() | |
| 643 | - | "#, | |
| 644 | - | ) | |
| 645 | - | .bind(info.user_id) | |
| 646 | - | .bind(&info.username) | |
| 647 | - | .bind(&info.display_name) | |
| 648 | - | .bind(&info.avatar_url) | |
| 649 | - | .bind(info.perks.fan_plus) | |
| 650 | - | .bind(info.perks.is_creator) | |
| 651 | - | .execute(&mut *tx) | |
| 652 | - | .await?; | |
| 653 | - | tx.commit().await?; | |
| 654 | - | Ok::<(), sqlx::Error>(()) | |
| 655 | - | } | |
| 656 | - | .await; | |
| 657 | - | ||
| 658 | - | if let Err(e) = upsert_result { | |
| 686 | + | if let Err(e) = upsert_login_user(&state.db, &info).await { | |
| 659 | 687 | tracing::error!(error = %e, "user upsert failed"); | |
| 660 | 688 | return Redirect::to("/?error=user_upsert_failed"); | |
| 661 | 689 | } |
| @@ -33,17 +33,14 @@ pub(super) async fn account_settings( | |||
| 33 | 33 | let user = session_user | |
| 34 | 34 | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 35 | 35 | ||
| 36 | - | let row: Option<(Option<String>, Option<String>)> = sqlx::query_as( | |
| 37 | - | "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", | |
| 38 | - | ) | |
| 39 | - | .bind(user.user_id) | |
| 40 | - | .fetch_optional(&state.db) | |
| 41 | - | .await | |
| 42 | - | .map_err(|e| { | |
| 43 | - | tracing::error!(error = ?e, "db error loading signature"); | |
| 44 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 45 | - | })?; | |
| 46 | - | let (signature_markdown, signature_html) = row.unwrap_or((None, None)); | |
| 36 | + | let (signature_markdown, signature_html) = | |
| 37 | + | mt_db::queries::get_user_signature(&state.db, user.user_id) | |
| 38 | + | .await | |
| 39 | + | .map_err(|e| { | |
| 40 | + | tracing::error!(error = ?e, "db error loading signature"); | |
| 41 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 42 | + | })? | |
| 43 | + | .unwrap_or((None, None)); | |
| 47 | 44 | ||
| 48 | 45 | Ok(AccountSettingsTemplate { | |
| 49 | 46 | csrf_token, | |
| @@ -76,17 +73,12 @@ pub(super) async fn update_signature_handler( | |||
| 76 | 73 | // "Clear signature" button submits with `clear=1`; takes precedence over | |
| 77 | 74 | // the textarea content. | |
| 78 | 75 | if form.clear.as_deref() == Some("1") { | |
| 79 | - | sqlx::query( | |
| 80 | - | "UPDATE users SET signature_markdown = NULL, signature_html = NULL \ | |
| 81 | - | WHERE mnw_account_id = $1", | |
| 82 | - | ) | |
| 83 | - | .bind(user.user_id) | |
| 84 | - | .execute(&state.db) | |
| 85 | - | .await | |
| 86 | - | .map_err(|e| { | |
| 87 | - | tracing::error!(error = ?e, "db error clearing signature"); | |
| 88 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 89 | - | })?; | |
| 76 | + | mt_db::mutations::clear_user_signature(&state.db, user.user_id) | |
| 77 | + | .await | |
| 78 | + | .map_err(|e| { | |
| 79 | + | tracing::error!(error = ?e, "db error clearing signature"); | |
| 80 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 81 | + | })?; | |
| 90 | 82 | return Ok(Redirect::to("/account?toast=Signature+cleared")); | |
| 91 | 83 | } | |
| 92 | 84 | ||
| @@ -114,19 +106,12 @@ pub(super) async fn update_signature_handler( | |||
| 114 | 106 | render_markdown(trimmed) | |
| 115 | 107 | }; | |
| 116 | 108 | ||
| 117 | - | sqlx::query( | |
| 118 | - | "UPDATE users SET signature_markdown = $2, signature_html = $3 \ | |
| 119 | - | WHERE mnw_account_id = $1", | |
| 120 | - | ) | |
| 121 | - | .bind(user.user_id) | |
| 122 | - | .bind(trimmed) | |
| 123 | - | .bind(&signature_html) | |
| 124 | - | .execute(&state.db) | |
| 125 | - | .await | |
| 126 | - | .map_err(|e| { | |
| 127 | - | tracing::error!(error = ?e, "db error saving signature"); | |
| 128 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 129 | - | })?; | |
| 109 | + | mt_db::mutations::set_user_signature(&state.db, user.user_id, trimmed, &signature_html) | |
| 110 | + | .await | |
| 111 | + | .map_err(|e| { | |
| 112 | + | tracing::error!(error = ?e, "db error saving signature"); | |
| 113 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 114 | + | })?; | |
| 130 | 115 | ||
| 131 | 116 | Ok(Redirect::to("/account?toast=Signature+saved")) | |
| 132 | 117 | } |
| @@ -201,18 +201,12 @@ pub(super) async fn admin_community_detail( | |||
| 201 | 201 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 202 | 202 | let community = get_community(&state.db, &slug).await?; | |
| 203 | 203 | ||
| 204 | - | let thread_count: i64 = sqlx::query_scalar( | |
| 205 | - | "SELECT COUNT(*) FROM threads t | |
| 206 | - | JOIN categories c ON c.id = t.category_id | |
| 207 | - | WHERE c.community_id = $1", | |
| 208 | - | ) | |
| 209 | - | .bind(community.id) | |
| 210 | - | .fetch_one(&state.db) | |
| 211 | - | .await | |
| 212 | - | .map_err(|e| { | |
| 213 | - | tracing::error!(error = ?e, "db error counting threads"); | |
| 214 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 215 | - | })?; | |
| 204 | + | let thread_count = mt_db::queries::count_threads_in_community(&state.db, community.id) | |
| 205 | + | .await | |
| 206 | + | .map_err(|e| { | |
| 207 | + | tracing::error!(error = ?e, "db error counting threads"); | |
| 208 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 209 | + | })?; | |
| 216 | 210 | ||
| 217 | 211 | let member_count = mt_db::queries::count_community_members(&state.db, community.id) | |
| 218 | 212 | .await | |
| @@ -222,9 +216,7 @@ pub(super) async fn admin_community_detail( | |||
| 222 | 216 | })?; | |
| 223 | 217 | ||
| 224 | 218 | let suspension_reason: Option<String> = if community.suspended_at.is_some() { | |
| 225 | - | sqlx::query_scalar("SELECT suspension_reason FROM communities WHERE id = $1") | |
| 226 | - | .bind(community.id) | |
| 227 | - | .fetch_one(&state.db) | |
| 219 | + | mt_db::queries::get_community_suspension_reason(&state.db, community.id) | |
| 228 | 220 | .await | |
| 229 | 221 | .ok() | |
| 230 | 222 | .flatten() |
| @@ -155,26 +155,16 @@ pub(super) async fn remove_flagged_post_handler( | |||
| 155 | 155 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 156 | 156 | let flag_id = parse_uuid(&flag_id_str)?; | |
| 157 | 157 | ||
| 158 | - | // Get the flag to find the post_id and its thread — scoped to this community | |
| 159 | - | let flag_row: Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)> = sqlx::query_as( | |
| 160 | - | "SELECT pf.post_id, p.author_id, t.id | |
| 161 | - | FROM post_flags pf | |
| 162 | - | JOIN posts p ON p.id = pf.post_id | |
| 163 | - | JOIN threads t ON t.id = p.thread_id | |
| 164 | - | JOIN categories c ON c.id = t.category_id | |
| 165 | - | WHERE pf.id = $1 AND c.community_id = $2", | |
| 166 | - | ) | |
| 167 | - | .bind(flag_id) | |
| 168 | - | .bind(community.id) | |
| 169 | - | .fetch_optional(&state.db) | |
| 170 | - | .await | |
| 171 | - | .map_err(|e| { | |
| 172 | - | tracing::error!(error = ?e, "db error fetching flag"); | |
| 173 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 174 | - | })?; | |
| 175 | - | ||
| 176 | - | let (post_id, author_id, thread_id) = flag_row | |
| 177 | - | .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?; | |
| 158 | + | // Find the flag's post + thread, scoped to this community (scoping enforced | |
| 159 | + | // in the query layer). | |
| 160 | + | let (post_id, author_id, thread_id) = | |
| 161 | + | mt_db::queries::get_flag_removal_target(&state.db, flag_id, community.id) | |
| 162 | + | .await | |
| 163 | + | .map_err(|e| { | |
| 164 | + | tracing::error!(error = ?e, "db error fetching flag"); | |
| 165 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 166 | + | })? | |
| 167 | + | .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?; | |
| 178 | 168 | ||
| 179 | 169 | // Mod-remove the post, resolve its flags, and write the audit row(s) on one | |
| 180 | 170 | // transaction: the removal, the flag resolution, and the log all commit |
| @@ -90,9 +90,7 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 90 | 90 | let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?; | |
| 91 | 91 | let post_id = scope.resource.id; | |
| 92 | 92 | ||
| 93 | - | let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1") | |
| 94 | - | .bind(post_id) | |
| 95 | - | .fetch_one(&state.db) | |
| 93 | + | let removed = mt_db::queries::is_post_removed(&state.db, post_id) | |
| 96 | 94 | .await | |
| 97 | 95 | .map_err(|e| { | |
| 98 | 96 | tracing::error!(error = ?e, "db error checking removal status"); | |
| @@ -170,9 +168,7 @@ pub(in crate::routes) async fn toggle_endorsement_handler( | |||
| 170 | 168 | let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?; | |
| 171 | 169 | let post_id = scope.resource.id; | |
| 172 | 170 | ||
| 173 | - | let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1") | |
| 174 | - | .bind(post_id) | |
| 175 | - | .fetch_one(&state.db) | |
| 171 | + | let removed = mt_db::queries::is_post_removed(&state.db, post_id) | |
| 176 | 172 | .await | |
| 177 | 173 | .map_err(|e| { | |
| 178 | 174 | tracing::error!(error = ?e, "db error checking removal status"); |
| @@ -234,8 +234,8 @@ pub(in crate::routes) async fn category( | |||
| 234 | 234 | let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page); | |
| 235 | 235 | let offset = pagination.offset(per_page); | |
| 236 | 236 | ||
| 237 | - | let db_threads = mt_db::queries::list_threads_in_category_sorted_filtered( | |
| 238 | - | &state.db, &slug, &category_slug, sort.as_str(), order.as_str(), per_page, offset, tag_filter, | |
| 237 | + | let db_threads = mt_db::queries::list_threads_in_category_sorted( | |
| 238 | + | &state.db, &slug, &category_slug, sort, order, per_page, offset, tag_filter, | |
| 239 | 239 | ) | |
| 240 | 240 | .await | |
| 241 | 241 | .map_err(|e| { |
| @@ -1,886 +0,0 @@ | |||
| 1 | - | //! Shared route helpers — validation, permission checks, markdown rendering, enforcement. | |
| 2 | - | ||
| 3 | - | use axum::{ | |
| 4 | - | http::StatusCode, | |
| 5 | - | response::{IntoResponse, Response}, | |
| 6 | - | }; | |
| 7 | - | use chrono::{DateTime, Duration, Utc}; | |
| 8 | - | use uuid::Uuid; | |
| 9 | - | ||
| 10 | - | use mt_core::types::{CommunityRole, CommunityState, ModAction, ModActor}; | |
| 11 | - | ||
| 12 | - | use crate::auth; | |
| 13 | - | use crate::templates::*; | |
| 14 | - | use crate::AppState; | |
| 15 | - | ||
| 16 | - | // ============================================================================ | |
| 17 | - | // Rate limiting constants | |
| 18 | - | // ============================================================================ | |
| 19 | - | ||
| 20 | - | /// Per-user rate limit: max posts per window (complements per-IP tower-governor). | |
| 21 | - | pub(crate) const USER_POST_RATE_LIMIT: i64 = 15; | |
| 22 | - | pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60; | |
| 23 | - | ||
| 24 | - | // ============================================================================ | |
| 25 | - | // Markdown rendering | |
| 26 | - | // ============================================================================ | |
| 27 | - | ||
| 28 | - | /// Render markdown to HTML, stripping raw HTML events to prevent XSS. | |
| 29 | - | /// | |
| 30 | - | /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this | |
| 31 | - | /// for content from non-Fan+ users. Fan+ subscribers get image embeds via | |
| 32 | - | /// [`render_markdown_plus`]. | |
| 33 | - | pub(crate) fn render_markdown(input: &str) -> String { | |
| 34 | - | docengine::render_strict(input) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | /// Render markdown to HTML with image embeds permitted. Otherwise identical to | |
| 38 | - | /// the strict preset — raw HTML is still stripped, dangerous schemes filtered, | |
| 39 | - | /// links get `nofollow`. Use for Fan+ subscriber content. | |
| 40 | - | pub(crate) fn render_markdown_plus(input: &str) -> String { | |
| 41 | - | let html = docengine::Renderer::strict() | |
| 42 | - | .with_strip_images(false) | |
| 43 | - | .render(input); | |
| 44 | - | proxy_external_images(&html) | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | /// Rewrite external `<img src="http(s)://…">` in rendered Fan+ HTML to load | |
| 48 | - | /// through the same-origin image proxy (`/img-proxy?u=…`), so the page CSP can | |
| 49 | - | /// stay `img-src 'self'` while still showing embedded external images. Uploaded | |
| 50 | - | /// images are served from same-origin `/uploads/…` (no scheme), so they don't | |
| 51 | - | /// match and aren't proxied. docengine has already attribute-escaped the URL, so | |
| 52 | - | /// `&` in a query string is unescaped back to `&` before percent-encoding. | |
| 53 | - | fn proxy_external_images(html: &str) -> String { | |
| 54 | - | static IMG_SRC_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 55 | - | regex_lite::Regex::new(r#"(<img\b[^>]*?\bsrc=")(https?://[^"]+)(")"#).unwrap() | |
| 56 | - | }); | |
| 57 | - | IMG_SRC_RE | |
| 58 | - | .replace_all(html, |caps: ®ex_lite::Captures| { | |
| 59 | - | let url = caps[2].replace("&", "&"); | |
| 60 | - | format!("{}/img-proxy?u={}{}", &caps[1], percent_encode_query(&url), &caps[3]) | |
| 61 | - | }) | |
| 62 | - | .into_owned() | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | /// RFC 3986 percent-encode a string for use as a URL query value. Keeps the | |
| 66 | - | /// unreserved set (`A-Za-z0-9-_.~`) and escapes everything else, so the proxied | |
| 67 | - | /// URL round-trips intact through axum's query decoder. | |
| 68 | - | fn percent_encode_query(s: &str) -> String { | |
| 69 | - | let mut out = String::with_capacity(s.len()); | |
| 70 | - | for &b in s.as_bytes() { | |
| 71 | - | match b { | |
| 72 | - | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 73 | - | out.push(b as char) | |
| 74 | - | } | |
| 75 | - | _ => out.push_str(&format!("%{b:02X}")), | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | out | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. | |
| 82 | - | pub(crate) fn render_markdown_with_mentions( | |
| 83 | - | input: &str, | |
| 84 | - | community_slug: &str, | |
| 85 | - | valid_usernames: &std::collections::HashSet<String>, | |
| 86 | - | allow_images: bool, | |
| 87 | - | ) -> String { | |
| 88 | - | let template = format!("/p/{community_slug}/u/{{username}}"); | |
| 89 | - | let resolved = docengine::resolve_mentions(input, valid_usernames, &template); | |
| 90 | - | if allow_images { | |
| 91 | - | render_markdown_plus(&resolved) | |
| 92 | - | } else { | |
| 93 | - | docengine::render_strict(&resolved) | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /// Reject submissions that contain image embeds. Used to give non-Fan+ users | |
| 98 | - | /// a clear error rather than silently stripping their image at render time. | |
| 99 | - | /// | |
| 100 | - | /// Only matches the markdown image syntax ``. Raw HTML `<img>` / | |
| 101 | - | /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is | |
| 102 | - | /// true in both `render_strict` and `render_markdown_plus`), so we don't need | |
| 103 | - | /// to reject them here — and rejecting them would surprise users pasting code | |
| 104 | - | /// blocks containing HTML. | |
| 105 | - | #[allow(clippy::result_large_err)] | |
| 106 | - | pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> { | |
| 107 | - | static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 108 | - | regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap() | |
| 109 | - | }); | |
| 110 | - | if EMBED_RE.is_match(body) { | |
| 111 | - | return Err(( | |
| 112 | - | StatusCode::UNPROCESSABLE_ENTITY, | |
| 113 | - | "Image embeds are a Fan+ feature.", | |
| 114 | - | ) | |
| 115 | - | .into_response()); | |
| 116 | - | } | |
| 117 | - | Ok(()) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // ============================================================================ | |
| 121 | - | // Common helpers — reduce boilerplate in handlers | |
| 122 | - | // ============================================================================ | |
| 123 | - | ||
| 124 | - | /// Fetch community by slug, returning 404/500 on failure. | |
| 125 | - | #[tracing::instrument(skip_all)] | |
| 126 | - | pub(crate) async fn get_community( | |
| 127 | - | db: &sqlx::PgPool, | |
| 128 | - | slug: &str, | |
| 129 | - | ) -> Result<mt_db::queries::CommunityRow, Response> { | |
| 130 | - | mt_db::queries::get_community_by_slug(db, slug) | |
| 131 | - | .await | |
| 132 | - | .map_err(|e| { | |
| 133 | - | tracing::error!(error = ?e, "db error fetching community"); | |
| 134 | - | crate::error_page::internal_error() | |
| 135 | - | })? | |
| 136 | - | .ok_or_else(crate::error_page::not_found) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | /// Parse a UUID from a string, returning 404 on failure. | |
| 140 | - | #[allow(clippy::result_large_err)] | |
| 141 | - | pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> { | |
| 142 | - | Uuid::parse_str(id_str).map_err(|_| crate::error_page::not_found()) | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | /// Fetch a user's role in a community, returning 500 on DB error. | |
| 146 | - | #[tracing::instrument(skip_all)] | |
| 147 | - | pub(crate) async fn get_role( | |
| 148 | - | db: &sqlx::PgPool, | |
| 149 | - | user_id: Uuid, | |
| 150 | - | community_id: Uuid, | |
| 151 | - | ) -> Result<Option<CommunityRole>, Response> { | |
| 152 | - | mt_db::queries::get_user_role(db, user_id, community_id) | |
| 153 | - | .await | |
| 154 | - | .map_err(|e| { | |
| 155 | - | tracing::error!(error = ?e, "db error fetching role"); | |
| 156 | - | crate::error_page::internal_error() | |
| 157 | - | }) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | /// Look up a user by username, returning 422 if not found. | |
| 161 | - | #[tracing::instrument(skip_all)] | |
| 162 | - | pub(crate) async fn get_user_by_username( | |
| 163 | - | db: &sqlx::PgPool, | |
| 164 | - | username: &str, | |
| 165 | - | ) -> Result<Uuid, Response> { | |
| 166 | - | mt_db::queries::get_user_by_username(db, username) | |
| 167 | - | .await | |
| 168 | - | .map_err(|e| { | |
| 169 | - | tracing::error!(error = ?e, "db error looking up user"); | |
| 170 | - | crate::error_page::internal_error() | |
| 171 | - | })? | |
| 172 | - | // 422 stays a plain form-validation response (consumed inline by mt.js), | |
| 173 | - | // not a full branded page. | |
| 174 | - | .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response()) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | /// Begin a transaction, mapping a DB error to a branded 500. | |
| 178 | - | #[allow(clippy::result_large_err)] | |
| 179 | - | pub(crate) async fn begin_tx( | |
| 180 | - | db: &sqlx::PgPool, | |
| 181 | - | ) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, Response> { | |
| 182 | - | db.begin().await.map_err(|e| { | |
| 183 | - | tracing::error!(error = ?e, "db error opening transaction"); | |
| 184 | - | crate::error_page::internal_error() | |
| 185 | - | }) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | /// Commit a transaction, mapping a DB error to a branded 500. | |
| 189 | - | #[allow(clippy::result_large_err)] | |
| 190 | - | pub(crate) async fn commit_tx( | |
| 191 | - | tx: sqlx::Transaction<'_, sqlx::Postgres>, | |
| 192 | - | ) -> Result<(), Response> { | |
| 193 | - | tx.commit().await.map_err(|e| { | |
| 194 | - | tracing::error!(error = ?e, "db error committing transaction"); | |
| 195 | - | crate::error_page::internal_error() | |
| 196 | - | }) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | /// Write a mod-log entry on the caller's transaction. | |
| 200 | - | /// | |
| 201 | - | /// The audit row is written on the *same* `tx` as the mutation it records, so | |
| 202 | - | /// the moderation action and its log either both commit or both roll back — an | |
| 203 | - | /// auditable action can never land without its correctly-attributed audit row | |
| 204 | - | /// (closes the fire-and-forget gap). A `System` actor (e.g. flag-threshold | |
| 205 | - | /// auto-hide) is recorded as a NULL `actor_id`, never the user who tripped it. | |
| 206 | - | #[allow(clippy::result_large_err)] | |
| 207 | - | pub(crate) async fn audit( | |
| 208 | - | tx: &mut sqlx::PgConnection, | |
| 209 | - | community_id: Option<Uuid>, | |
| 210 | - | actor: ModActor, | |
| 211 | - | action: ModAction, | |
| 212 | - | target_user: Option<Uuid>, | |
| 213 | - | target_id: Option<Uuid>, | |
| 214 | - | reason: Option<&str>, | |
| 215 | - | ) -> Result<(), Response> { | |
| 216 | - | mt_db::mutations::insert_mod_log( | |
| 217 | - | &mut *tx, community_id, actor, action, target_user, target_id, reason, | |
| 218 | - | ) | |
| 219 | - | .await | |
| 220 | - | .map_err(|e| { | |
| 221 | - | tracing::error!(error = %e, "failed to insert mod log"); | |
| 222 | - | crate::error_page::internal_error() | |
| 223 | - | }) | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | /// Convert a session user to a template session user. | |
| 227 | - | pub(crate) fn template_user( | |
| 228 | - | user: &auth::SessionUser, | |
| 229 | - | platform_admin_id: Option<Uuid>, | |
| 230 | - | ) -> TemplateSessionUser { | |
| 231 | - | TemplateSessionUser { | |
| 232 | - | is_platform_admin: platform_admin_id == Some(user.user_id), | |
| 233 | - | username: user.username.clone(), | |
| 234 | - | } | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | /// Build a 422 validation response tagged with the offending form field. | |
| 238 | - | /// | |
| 239 | - | /// `field` matches the input's `name` attribute. The `X-Form-Field` header lets | |
| 240 | - | /// `mt.js` render the message as a persistent inline error next to that input | |
| 241 | - | /// (and focus it) instead of a transient toast — the A-grade form-failure UX. | |
| 242 | - | /// Submissions never navigate away on 422, so the user's input is preserved in | |
| 243 | - | /// the live DOM; only the error needs to come back. | |
| 244 | - | #[allow(clippy::result_large_err)] | |
| 245 | - | pub(crate) fn field_error(field: &'static str, message: impl Into<String>) -> Response { | |
| 246 | - | ( | |
| 247 | - | StatusCode::UNPROCESSABLE_ENTITY, | |
| 248 | - | [("X-Form-Field", field)], | |
| 249 | - | message.into(), | |
| 250 | - | ) | |
| 251 | - | .into_response() | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | /// Validate a title field (1-256 chars). | |
| 255 | - | #[allow(clippy::result_large_err)] | |
| 256 | - | pub(crate) fn validate_title(text: &str) -> Result<&str, Response> { | |
| 257 | - | let t = text.trim(); | |
| 258 | - | // Count characters, not bytes, to match the client `maxlength` (UTF-16 | |
| 259 | - | // units) — else a multibyte title under the visible cap fails server-side. | |
| 260 | - | if t.is_empty() || t.chars().count() > 256 { | |
| 261 | - | return Err(field_error( | |
| 262 | - | "title", | |
| 263 | - | "Title must be between 1 and 256 characters.", | |
| 264 | - | )); | |
| 265 | - | } | |
| 266 | - | Ok(t) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | /// Validate a body/content field (1 to max chars). `label` names the field in | |
| 270 | - | /// the message ("Body", "Footnote"); the inline error attaches to the `body` | |
| 271 | - | /// input, which every content textarea in the app uses as its `name`. | |
| 272 | - | #[allow(clippy::result_large_err)] | |
| 273 | - | pub(crate) fn validate_body<'a>(text: &'a str, max: usize, label: &str) -> Result<&'a str, Response> { | |
| 274 | - | let t = text.trim(); | |
| 275 | - | // Character count (not byte length) to match the client-side cap. | |
| 276 | - | if t.is_empty() || t.chars().count() > max { | |
| 277 | - | return Err(field_error( | |
| 278 | - | "body", | |
| 279 | - | format!("{label} must be between 1 and {max} characters."), | |
| 280 | - | )); | |
| 281 | - | } | |
| 282 | - | Ok(t) | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | // ============================================================================ | |
| 286 | - | // Permission helpers | |
| 287 | - | // ============================================================================ | |
| 288 | - | ||
| 289 | - | /// Is this user a moderator or owner in the community? | |
| 290 | - | pub(crate) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool { | |
| 291 | - | role.is_some_and(|r| r.is_mod_or_owner()) | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | /// Is this user an owner of the community? | |
| 295 | - | pub(crate) fn is_owner(role: &Option<CommunityRole>) -> bool { | |
| 296 | - | role.is_some_and(|r| r.is_owner()) | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | // ============================================================================ | |
| 300 | - | // Enforcement helpers | |
| 301 | - | // ============================================================================ | |
| 302 | - | ||
| 303 | - | /// Check community suspension + user ban. For read handlers. | |
| 304 | - | #[tracing::instrument(skip_all)] | |
| 305 | - | pub(crate) async fn check_community_access( | |
| 306 | - | db: &sqlx::PgPool, | |
| 307 | - | community: &mt_db::queries::CommunityRow, | |
| 308 | - | user_id: Option<Uuid>, | |
| 309 | - | ) -> Result<(), Response> { | |
| 310 | - | if community.suspended_at.is_some() { | |
| 311 | - | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 312 | - | } | |
| 313 | - | if let Some(uid) = user_id { | |
| 314 | - | let banned = mt_db::queries::is_user_banned(db, community.id, uid) | |
| 315 | - | .await | |
| 316 | - | .map_err(|e| { | |
| 317 | - | tracing::error!(error = ?e, "db error checking ban status"); | |
| 318 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 319 | - | })?; | |
| 320 | - | if banned { | |
| 321 | - | return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); | |
| 322 | - | } | |
| 323 | - | } | |
| 324 | - | Ok(()) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | /// Check community suspension + platform suspension + user ban + user mute. For write handlers. | |
| 328 | - | #[tracing::instrument(skip_all)] | |
| 329 | - | pub(crate) async fn check_write_access( | |
| 330 | - | db: &sqlx::PgPool, | |
| 331 | - | community_id: Uuid, | |
| 332 | - | user_id: Uuid, | |
| 333 | - | community_suspended: bool, | |
| 334 | - | ) -> Result<(), Response> { | |
| 335 | - | if community_suspended { | |
| 336 | - | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 337 | - | } | |
| 338 | - | let suspended = mt_db::queries::is_user_suspended(db, user_id) | |
| 339 | - | .await | |
| 340 | - | .map_err(|e| { | |
| 341 | - | tracing::error!(error = ?e, "db error checking user suspension"); | |
| 342 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 343 | - | })?; | |
| 344 | - | if suspended { | |
| 345 | - | return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()); | |
| 346 | - | } | |
| 347 | - | let banned = mt_db::queries::is_user_banned(db, community_id, user_id) | |
| 348 | - | .await | |
| 349 | - | .map_err(|e| { | |
| 350 | - | tracing::error!(error = ?e, "db error checking ban status"); | |
| 351 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 352 | - | })?; | |
| 353 | - | if banned { | |
| 354 | - | return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); | |
| 355 | - | } | |
| 356 | - | let muted = mt_db::queries::is_user_muted(db, community_id, user_id) | |
| 357 | - | .await | |
| 358 | - | .map_err(|e| { | |
| 359 | - | tracing::error!(error = ?e, "db error checking mute status"); | |
| 360 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 361 | - | })?; | |
| 362 | - | if muted { | |
| 363 | - | return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response()); | |
| 364 | - | } | |
| 365 | - | Ok(()) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit. | |
| 369 | - | #[tracing::instrument(skip_all)] | |
| 370 | - | pub(crate) async fn check_user_post_rate( | |
| 371 | - | db: &sqlx::PgPool, | |
| 372 | - | user_id: Uuid, | |
| 373 | - | ) -> Result<(), Response> { | |
| 374 | - | let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS) | |
| 375 | - | .await | |
| 376 | - | .map_err(|e| { | |
| 377 | - | tracing::error!(error = ?e, "db error checking user post rate"); | |
| 378 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 379 | - | })?; | |
| 380 | - | if count >= USER_POST_RATE_LIMIT { | |
| 381 | - | return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response()); | |
| 382 | - | } | |
| 383 | - | Ok(()) | |
| 384 | - | } | |
| 385 | - | ||
| 386 | - | /// Parse a ban duration string into an optional expiration datetime. | |
| 387 | - | /// Returns `Err` for unrecognized durations to prevent accidental permanent bans. | |
| 388 | - | #[allow(clippy::result_large_err)] | |
| 389 | - | pub(crate) fn parse_duration(duration: &str) -> Result<Option<DateTime<Utc>>, Response> { | |
| 390 | - | match duration { | |
| 391 | - | "permanent" => Ok(None), | |
| 392 | - | "1h" => Ok(Some(Utc::now() + Duration::hours(1))), | |
| 393 | - | "1d" => Ok(Some(Utc::now() + Duration::days(1))), | |
| 394 | - | "7d" => Ok(Some(Utc::now() + Duration::days(7))), | |
| 395 | - | "30d" => Ok(Some(Utc::now() + Duration::days(30))), | |
| 396 | - | _ => Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid duration.").into_response()), | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | /// Helper: fetch community + verify owner role, returning 403 if not owner. | |
| 401 | - | #[tracing::instrument(skip_all)] | |
| 402 | - | pub(crate) async fn require_owner( | |
| 403 | - | state: &AppState, | |
| 404 | - | slug: &str, | |
| 405 | - | user: &auth::SessionUser, | |
| 406 | - | ) -> Result<mt_db::queries::CommunityRow, Response> { | |
| 407 | - | let community = get_community(&state.db, slug).await?; | |
| 408 | - | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 409 | - | if !is_owner(&role) { | |
| 410 | - | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 411 | - | } | |
| 412 | - | Ok(community) | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | /// Helper: fetch community + verify mod_or_owner role, returning 403 if not. | |
| 416 | - | #[tracing::instrument(skip_all)] | |
| 417 | - | pub(crate) async fn require_mod_or_owner( | |
| 418 | - | state: &AppState, | |
| 419 | - | slug: &str, | |
| 420 | - | user: &auth::SessionUser, | |
| 421 | - | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 422 | - | let community = get_community(&state.db, slug).await?; | |
| 423 | - | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 424 | - | if !is_mod_or_owner(&role) { | |
| 425 | - | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 426 | - | } | |
| 427 | - | Ok((community, role)) | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | // ============================================================================ | |
| 431 | - | // Superadmin authorization | |
| 432 | - | // ============================================================================ | |
| 433 | - | ||
| 434 | - | /// Whether `user` is the configured platform admin. | |
| 435 | - | /// | |
| 436 | - | /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real | |
| 437 | - | /// permissions system is deferred. See `docs/todo.md` § Community Moderation | |
| 438 | - | /// Enforcement. | |
| 439 | - | pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool { | |
| 440 | - | state | |
| 441 | - | .config | |
| 442 | - | .platform_admin_id | |
| 443 | - | .is_some_and(|id| id == user.user_id) | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | /// True if the user can perform mod actions in this community: either a | |
| 447 | - | /// community Owner/Moderator, or the platform admin (who can act on any | |
| 448 | - | /// community). Used by [`check_community_state`] and by the state-change route. | |
| 449 | - | pub(crate) fn is_mod_or_superadmin( | |
| 450 | - | state: &AppState, | |
| 451 | - | user: &auth::SessionUser, | |
| 452 | - | role: &Option<CommunityRole>, | |
| 453 | - | ) -> bool { | |
| 454 | - | is_mod_or_owner(role) || is_platform_admin(state, user) | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | /// Fetch community + verify the user is a mod, owner, or platform admin. | |
| 458 | - | /// | |
| 459 | - | /// Returns `(community, role)` — `role` is `None` when the user is the platform | |
| 460 | - | /// admin but holds no role in this specific community. | |
| 461 | - | #[tracing::instrument(skip_all)] | |
| 462 | - | pub(crate) async fn require_mod_or_superadmin( | |
| 463 | - | state: &AppState, | |
| 464 | - | slug: &str, | |
| 465 | - | user: &auth::SessionUser, | |
| 466 | - | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 467 | - | let community = get_community(&state.db, slug).await?; | |
| 468 | - | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 469 | - | if !is_mod_or_superadmin(state, user, &role) { | |
| 470 | - | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 471 | - | } | |
| 472 | - | Ok((community, role)) | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | // ============================================================================ | |
| 476 | - | // Community state enforcement | |
| 477 | - | // ============================================================================ | |
| 478 | - | ||
| 479 | - | /// Whether a write attempt is starting a new thread or extending an existing | |
| 480 | - | /// one. Restricted communities block `NewThread` for non-mods but still accept | |
| 481 | - | /// `ContinueExisting` writes. | |
| 482 | - | #[derive(Debug, Clone, Copy)] | |
| 483 | - | pub(crate) enum WriteScope { | |
| 484 | - | NewThread, | |
| 485 | - | ContinueExisting, | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | /// Convenience: combine role lookup with [`check_community_state`]. Use this | |
| 489 | - | /// in write handlers that don't already need the role for other purposes. | |
| 490 | - | #[tracing::instrument(skip_all)] | |
| 491 | - | pub(crate) async fn check_write_state( | |
| 492 | - | state: &AppState, | |
| 493 | - | community: &mt_db::queries::CommunityRow, | |
| 494 | - | user: &auth::SessionUser, | |
| 495 | - | scope: WriteScope, | |
| 496 | - | ) -> Result<(), Response> { | |
| 497 | - | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 498 | - | let is_mod_or_super = is_mod_or_superadmin(state, user, &role); | |
| 499 | - | check_community_state(community.state, scope, is_mod_or_super) | |
| 500 | - | } |
Lines truncated
| @@ -0,0 +1,400 @@ | |||
| 1 | + | //! Authorization + enforcement: roles, ban/mute/suspension gates, community | |
| 2 | + | //! state machine, and the platform-admin (superadmin) checks. | |
| 3 | + | ||
| 4 | + | use axum::{ | |
| 5 | + | http::StatusCode, | |
| 6 | + | response::{IntoResponse, Response}, | |
| 7 | + | }; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | use mt_core::types::{CommunityRole, CommunityState}; | |
| 11 | + | ||
| 12 | + | use super::get_community; | |
| 13 | + | use crate::auth; | |
| 14 | + | use crate::AppState; | |
| 15 | + | ||
| 16 | + | /// Fetch a user's role in a community, returning 500 on DB error. | |
| 17 | + | #[tracing::instrument(skip_all)] | |
| 18 | + | pub(crate) async fn get_role( | |
| 19 | + | db: &sqlx::PgPool, | |
| 20 | + | user_id: Uuid, | |
| 21 | + | community_id: Uuid, | |
| 22 | + | ) -> Result<Option<CommunityRole>, Response> { | |
| 23 | + | mt_db::queries::get_user_role(db, user_id, community_id) | |
| 24 | + | .await | |
| 25 | + | .map_err(|e| { | |
| 26 | + | tracing::error!(error = ?e, "db error fetching role"); | |
| 27 | + | crate::error_page::internal_error() | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ============================================================================ | |
| 32 | + | // Permission helpers | |
| 33 | + | // ============================================================================ | |
| 34 | + | ||
| 35 | + | /// Is this user a moderator or owner in the community? | |
| 36 | + | pub(crate) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool { | |
| 37 | + | role.is_some_and(|r| r.is_mod_or_owner()) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Is this user an owner of the community? | |
| 41 | + | pub(crate) fn is_owner(role: &Option<CommunityRole>) -> bool { | |
| 42 | + | role.is_some_and(|r| r.is_owner()) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ============================================================================ | |
| 46 | + | // Enforcement helpers | |
| 47 | + | // ============================================================================ | |
| 48 | + | ||
| 49 | + | /// Check community suspension + user ban. For read handlers. | |
| 50 | + | #[tracing::instrument(skip_all)] | |
| 51 | + | pub(crate) async fn check_community_access( | |
| 52 | + | db: &sqlx::PgPool, | |
| 53 | + | community: &mt_db::queries::CommunityRow, | |
| 54 | + | user_id: Option<Uuid>, | |
| 55 | + | ) -> Result<(), Response> { | |
| 56 | + | if community.suspended_at.is_some() { | |
| 57 | + | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 58 | + | } | |
| 59 | + | if let Some(uid) = user_id { | |
| 60 | + | let banned = mt_db::queries::is_user_banned(db, community.id, uid) | |
| 61 | + | .await | |
| 62 | + | .map_err(|e| { | |
| 63 | + | tracing::error!(error = ?e, "db error checking ban status"); | |
| 64 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 65 | + | })?; | |
| 66 | + | if banned { | |
| 67 | + | return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | Ok(()) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Check community suspension + platform suspension + user ban + user mute. For write handlers. | |
| 74 | + | #[tracing::instrument(skip_all)] | |
| 75 | + | pub(crate) async fn check_write_access( | |
| 76 | + | db: &sqlx::PgPool, | |
| 77 | + | community_id: Uuid, | |
| 78 | + | user_id: Uuid, | |
| 79 | + | community_suspended: bool, | |
| 80 | + | ) -> Result<(), Response> { | |
| 81 | + | if community_suspended { | |
| 82 | + | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 83 | + | } | |
| 84 | + | let suspended = mt_db::queries::is_user_suspended(db, user_id) | |
| 85 | + | .await | |
| 86 | + | .map_err(|e| { | |
| 87 | + | tracing::error!(error = ?e, "db error checking user suspension"); | |
| 88 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 89 | + | })?; | |
| 90 | + | if suspended { | |
| 91 | + | return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()); | |
| 92 | + | } | |
| 93 | + | let banned = mt_db::queries::is_user_banned(db, community_id, user_id) | |
| 94 | + | .await | |
| 95 | + | .map_err(|e| { | |
| 96 | + | tracing::error!(error = ?e, "db error checking ban status"); | |
| 97 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 98 | + | })?; | |
| 99 | + | if banned { | |
| 100 | + | return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response()); | |
| 101 | + | } | |
| 102 | + | let muted = mt_db::queries::is_user_muted(db, community_id, user_id) | |
| 103 | + | .await | |
| 104 | + | .map_err(|e| { | |
| 105 | + | tracing::error!(error = ?e, "db error checking mute status"); | |
| 106 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 107 | + | })?; | |
| 108 | + | if muted { | |
| 109 | + | return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response()); | |
| 110 | + | } | |
| 111 | + | Ok(()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Helper: fetch community + verify owner role, returning 403 if not owner. | |
| 115 | + | #[tracing::instrument(skip_all)] | |
| 116 | + | pub(crate) async fn require_owner( | |
| 117 | + | state: &AppState, | |
| 118 | + | slug: &str, | |
| 119 | + | user: &auth::SessionUser, | |
| 120 | + | ) -> Result<mt_db::queries::CommunityRow, Response> { | |
| 121 | + | let community = get_community(&state.db, slug).await?; | |
| 122 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 123 | + | if !is_owner(&role) { | |
| 124 | + | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 125 | + | } | |
| 126 | + | Ok(community) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Helper: fetch community + verify mod_or_owner role, returning 403 if not. | |
| 130 | + | #[tracing::instrument(skip_all)] | |
| 131 | + | pub(crate) async fn require_mod_or_owner( | |
| 132 | + | state: &AppState, | |
| 133 | + | slug: &str, | |
| 134 | + | user: &auth::SessionUser, | |
| 135 | + | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 136 | + | let community = get_community(&state.db, slug).await?; | |
| 137 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 138 | + | if !is_mod_or_owner(&role) { | |
| 139 | + | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 140 | + | } | |
| 141 | + | Ok((community, role)) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // ============================================================================ | |
| 145 | + | // Superadmin authorization | |
| 146 | + | // ============================================================================ | |
| 147 | + | ||
| 148 | + | /// Whether `user` is the configured platform admin. | |
| 149 | + | /// | |
| 150 | + | /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real | |
| 151 | + | /// permissions system is deferred. See `docs/todo.md` § Community Moderation | |
| 152 | + | /// Enforcement. | |
| 153 | + | pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool { | |
| 154 | + | state | |
| 155 | + | .config | |
| 156 | + | .platform_admin_id | |
| 157 | + | .is_some_and(|id| id == user.user_id) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// True if the user can perform mod actions in this community: either a | |
| 161 | + | /// community Owner/Moderator, or the platform admin (who can act on any | |
| 162 | + | /// community). Used by [`check_community_state`] and by the state-change route. | |
| 163 | + | pub(crate) fn is_mod_or_superadmin( | |
| 164 | + | state: &AppState, | |
| 165 | + | user: &auth::SessionUser, | |
| 166 | + | role: &Option<CommunityRole>, | |
| 167 | + | ) -> bool { | |
| 168 | + | is_mod_or_owner(role) || is_platform_admin(state, user) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Fetch community + verify the user is a mod, owner, or platform admin. | |
| 172 | + | /// | |
| 173 | + | /// Returns `(community, role)` — `role` is `None` when the user is the platform | |
| 174 | + | /// admin but holds no role in this specific community. | |
| 175 | + | #[tracing::instrument(skip_all)] | |
| 176 | + | pub(crate) async fn require_mod_or_superadmin( | |
| 177 | + | state: &AppState, | |
| 178 | + | slug: &str, | |
| 179 | + | user: &auth::SessionUser, | |
| 180 | + | ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> { | |
| 181 | + | let community = get_community(&state.db, slug).await?; | |
| 182 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 183 | + | if !is_mod_or_superadmin(state, user, &role) { | |
| 184 | + | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 185 | + | } | |
| 186 | + | Ok((community, role)) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // ============================================================================ | |
| 190 | + | // Community state enforcement | |
| 191 | + | // ============================================================================ | |
| 192 | + | ||
| 193 | + | /// Whether a write attempt is starting a new thread or extending an existing | |
| 194 | + | /// one. Restricted communities block `NewThread` for non-mods but still accept | |
| 195 | + | /// `ContinueExisting` writes. | |
| 196 | + | #[derive(Debug, Clone, Copy)] | |
| 197 | + | pub(crate) enum WriteScope { | |
| 198 | + | NewThread, | |
| 199 | + | ContinueExisting, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Convenience: combine role lookup with [`check_community_state`]. Use this | |
| 203 | + | /// in write handlers that don't already need the role for other purposes. | |
| 204 | + | #[tracing::instrument(skip_all)] | |
| 205 | + | pub(crate) async fn check_write_state( | |
| 206 | + | state: &AppState, | |
| 207 | + | community: &mt_db::queries::CommunityRow, | |
| 208 | + | user: &auth::SessionUser, | |
| 209 | + | scope: WriteScope, | |
| 210 | + | ) -> Result<(), Response> { | |
| 211 | + | let role = get_role(&state.db, user.user_id, community.id).await?; | |
| 212 | + | let is_mod_or_super = is_mod_or_superadmin(state, user, &role); | |
| 213 | + | check_community_state(community.state, scope, is_mod_or_super) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Pure decision for [`check_community_state`]: returns `None` when the write | |
| 217 | + | /// is allowed, `Some(message)` when denied (message is what the user sees). | |
| 218 | + | /// | |
| 219 | + | /// Mods/owners and the platform admin bypass restrictions. Members go through | |
| 220 | + | /// the state's `allows_*` predicates. | |
| 221 | + | pub(crate) fn community_state_denial_message( | |
| 222 | + | community_state: CommunityState, | |
| 223 | + | scope: WriteScope, | |
| 224 | + | is_mod_or_super: bool, | |
| 225 | + | ) -> Option<&'static str> { | |
| 226 | + | if is_mod_or_super { | |
| 227 | + | return None; | |
| 228 | + | } | |
| 229 | + | let allowed = match scope { | |
| 230 | + | WriteScope::NewThread => community_state.allows_new_threads_for_members(), | |
| 231 | + | WriteScope::ContinueExisting => community_state.allows_writes_for_members(), | |
| 232 | + | }; | |
| 233 | + | if allowed { | |
| 234 | + | return None; | |
| 235 | + | } | |
| 236 | + | Some(match (community_state, scope) { | |
| 237 | + | (CommunityState::Restricted, WriteScope::NewThread) => { | |
| 238 | + | "New threads are restricted in this community." | |
| 239 | + | } | |
| 240 | + | (CommunityState::Frozen, _) => "This community is frozen.", | |
| 241 | + | (CommunityState::Archived, _) => "This community is archived.", | |
| 242 | + | _ => "Action not allowed in the community's current state.", | |
| 243 | + | }) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Gate a write against the community's [`CommunityState`]. | |
| 247 | + | /// | |
| 248 | + | /// Mods/owners and the platform admin bypass all state restrictions. Members | |
| 249 | + | /// follow the state's `allows_*` predicates. Returns 403 with a state-specific | |
| 250 | + | /// message on denial. Independent of [`check_write_access`] (suspension/ban/mute); | |
| 251 | + | /// call both in write handlers. | |
| 252 | + | #[allow(clippy::result_large_err)] | |
| 253 | + | pub(crate) fn check_community_state( | |
| 254 | + | community_state: CommunityState, | |
| 255 | + | scope: WriteScope, | |
| 256 | + | is_mod_or_super: bool, | |
| 257 | + | ) -> Result<(), Response> { | |
| 258 | + | match community_state_denial_message(community_state, scope, is_mod_or_super) { | |
| 259 | + | None => Ok(()), | |
| 260 | + | Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()), | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[cfg(test)] | |
| 265 | + | mod authz_tests { | |
| 266 | + | use super::*; | |
| 267 | + | ||
| 268 | + | // ── is_mod_or_owner / is_owner Option wrappers ── | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn is_mod_or_owner_none_role_is_false() { | |
| 272 | + | assert!(!is_mod_or_owner(&None)); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn is_mod_or_owner_some_roles() { | |
| 277 | + | assert!(is_mod_or_owner(&Some(CommunityRole::Owner))); | |
| 278 | + | assert!(is_mod_or_owner(&Some(CommunityRole::Moderator))); | |
| 279 | + | assert!(!is_mod_or_owner(&Some(CommunityRole::Member))); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn is_owner_none_role_is_false() { | |
| 284 | + | assert!(!is_owner(&None)); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[test] | |
| 288 | + | fn is_owner_some_roles() { | |
| 289 | + | assert!(is_owner(&Some(CommunityRole::Owner))); | |
| 290 | + | assert!(!is_owner(&Some(CommunityRole::Moderator))); | |
| 291 | + | assert!(!is_owner(&Some(CommunityRole::Member))); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // ── community_state_denial_message ── | |
| 295 | + | ||
| 296 | + | #[test] | |
| 297 | + | fn state_denial_mod_bypasses_everything() { | |
| 298 | + | // Pins the `if is_mod_or_super { return None; }` early return — a mod | |
| 299 | + | // can write to Archived/Frozen/Restricted communities for any scope. | |
| 300 | + | for state in [ | |
| 301 | + | CommunityState::Active, | |
| 302 | + | CommunityState::Restricted, | |
| 303 | + | CommunityState::Frozen, | |
| 304 | + | CommunityState::Archived, | |
| 305 | + | ] { | |
| 306 | + | for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] { | |
| 307 | + | assert_eq!( | |
| 308 | + | community_state_denial_message(state, scope, true), | |
| 309 | + | None, | |
| 310 | + | "mod must bypass: state={state:?} scope={scope:?}" | |
| 311 | + | ); | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn state_denial_active_allows_members_both_scopes() { | |
| 318 | + | assert_eq!( | |
| 319 | + | community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false), | |
| 320 | + | None | |
| 321 | + | ); | |
| 322 | + | assert_eq!( | |
| 323 | + | community_state_denial_message(CommunityState::Active, WriteScope::ContinueExisting, false), | |
| 324 | + | None | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | #[test] | |
| 329 | + | fn state_denial_restricted_blocks_new_thread_only() { | |
| 330 | + | // Restricted: members can reply but not start threads. | |
| 331 | + | assert_eq!( | |
| 332 | + | community_state_denial_message(CommunityState::Restricted, WriteScope::NewThread, false), | |
| 333 | + | Some("New threads are restricted in this community.") | |
| 334 | + | ); | |
| 335 | + | assert_eq!( | |
| 336 | + | community_state_denial_message( | |
| 337 | + | CommunityState::Restricted, | |
| 338 | + | WriteScope::ContinueExisting, | |
| 339 | + | false | |
| 340 | + | ), | |
| 341 | + | None, | |
| 342 | + | "Restricted must allow replies" | |
| 343 | + | ); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn state_denial_frozen_blocks_all_member_writes() { | |
| 348 | + | assert_eq!( | |
| 349 | + | community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false), | |
| 350 | + | Some("This community is frozen.") | |
| 351 | + | ); | |
| 352 | + | assert_eq!( | |
| 353 | + | community_state_denial_message(CommunityState::Frozen, WriteScope::ContinueExisting, false), | |
| 354 | + | Some("This community is frozen.") | |
| 355 | + | ); | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | #[test] | |
| 359 | + | fn state_denial_archived_blocks_all_member_writes() { | |
| 360 | + | assert_eq!( | |
| 361 | + | community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false), | |
| 362 | + | Some("This community is archived.") | |
| 363 | + | ); | |
| 364 | + | assert_eq!( | |
| 365 | + | community_state_denial_message( | |
| 366 | + | CommunityState::Archived, | |
| 367 | + | WriteScope::ContinueExisting, | |
| 368 | + | false | |
| 369 | + | ), | |
| 370 | + | Some("This community is archived.") | |
| 371 | + | ); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[test] | |
| 375 | + | fn state_denial_message_distinct_per_state() { | |
| 376 | + | // Distinct error text per state — mutations that swap arms (e.g. | |
| 377 | + | // Frozen → Archived) would surface here. | |
| 378 | + | let frozen = community_state_denial_message( | |
| 379 | + | CommunityState::Frozen, | |
| 380 | + | WriteScope::NewThread, | |
| 381 | + | false, | |
| 382 | + | ) | |
| 383 | + | .unwrap(); | |
| 384 | + | let archived = community_state_denial_message( | |
| 385 | + | CommunityState::Archived, | |
| 386 | + | WriteScope::NewThread, | |
| 387 | + | false, | |
| 388 | + | ) | |
| 389 | + | .unwrap(); | |
| 390 | + | let restricted = community_state_denial_message( | |
| 391 | + | CommunityState::Restricted, | |
| 392 | + | WriteScope::NewThread, | |
| 393 | + | false, | |
| 394 | + | ) | |
| 395 | + | .unwrap(); | |
| 396 | + | assert_ne!(frozen, archived); | |
| 397 | + | assert_ne!(frozen, restricted); | |
| 398 | + | assert_ne!(archived, restricted); | |
| 399 | + | } | |
| 400 | + | } |
| @@ -0,0 +1,135 @@ | |||
| 1 | + | //! Markdown rendering + Fan+ image handling for route handlers. | |
| 2 | + | ||
| 3 | + | use axum::{ | |
| 4 | + | http::StatusCode, | |
| 5 | + | response::{IntoResponse, Response}, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | /// Render markdown to HTML, stripping raw HTML events to prevent XSS. | |
| 9 | + | /// | |
| 10 | + | /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this | |
| 11 | + | /// for content from non-Fan+ users. Fan+ subscribers get image embeds via | |
| 12 | + | /// [`render_markdown_plus`]. | |
| 13 | + | pub(crate) fn render_markdown(input: &str) -> String { | |
| 14 | + | docengine::render_strict(input) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Render markdown to HTML with image embeds permitted. Otherwise identical to | |
| 18 | + | /// the strict preset — raw HTML is still stripped, dangerous schemes filtered, | |
| 19 | + | /// links get `nofollow`. Use for Fan+ subscriber content. | |
| 20 | + | pub(crate) fn render_markdown_plus(input: &str) -> String { | |
| 21 | + | let html = docengine::Renderer::strict() | |
| 22 | + | .with_strip_images(false) | |
| 23 | + | .render(input); | |
| 24 | + | proxy_external_images(&html) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Rewrite external `<img src="http(s)://…">` in rendered Fan+ HTML to load | |
| 28 | + | /// through the same-origin image proxy (`/img-proxy?u=…`), so the page CSP can | |
| 29 | + | /// stay `img-src 'self'` while still showing embedded external images. Uploaded | |
| 30 | + | /// images are served from same-origin `/uploads/…` (no scheme), so they don't | |
| 31 | + | /// match and aren't proxied. docengine has already attribute-escaped the URL, so | |
| 32 | + | /// `&` in a query string is unescaped back to `&` before percent-encoding. | |
| 33 | + | fn proxy_external_images(html: &str) -> String { | |
| 34 | + | static IMG_SRC_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 35 | + | regex_lite::Regex::new(r#"(<img\b[^>]*?\bsrc=")(https?://[^"]+)(")"#).unwrap() | |
| 36 | + | }); | |
| 37 | + | IMG_SRC_RE | |
| 38 | + | .replace_all(html, |caps: ®ex_lite::Captures| { | |
| 39 | + | let url = caps[2].replace("&", "&"); | |
| 40 | + | format!("{}/img-proxy?u={}{}", &caps[1], percent_encode_query(&url), &caps[3]) | |
| 41 | + | }) | |
| 42 | + | .into_owned() | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// RFC 3986 percent-encode a string for use as a URL query value. Keeps the | |
| 46 | + | /// unreserved set (`A-Za-z0-9-_.~`) and escapes everything else, so the proxied | |
| 47 | + | /// URL round-trips intact through axum's query decoder. | |
| 48 | + | fn percent_encode_query(s: &str) -> String { | |
| 49 | + | let mut out = String::with_capacity(s.len()); | |
| 50 | + | for &b in s.as_bytes() { | |
| 51 | + | match b { | |
| 52 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 53 | + | out.push(b as char) | |
| 54 | + | } | |
| 55 | + | _ => out.push_str(&format!("%{b:02X}")), | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | out | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. | |
| 62 | + | pub(crate) fn render_markdown_with_mentions( | |
| 63 | + | input: &str, | |
| 64 | + | community_slug: &str, | |
| 65 | + | valid_usernames: &std::collections::HashSet<String>, | |
| 66 | + | allow_images: bool, | |
| 67 | + | ) -> String { | |
| 68 | + | let template = format!("/p/{community_slug}/u/{{username}}"); | |
| 69 | + | let resolved = docengine::resolve_mentions(input, valid_usernames, &template); | |
| 70 | + | if allow_images { | |
| 71 | + | render_markdown_plus(&resolved) | |
| 72 | + | } else { | |
| 73 | + | docengine::render_strict(&resolved) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Reject submissions that contain image embeds. Used to give non-Fan+ users | |
| 78 | + | /// a clear error rather than silently stripping their image at render time. | |
| 79 | + | /// | |
| 80 | + | /// Only matches the markdown image syntax ``. Raw HTML `<img>` / | |
| 81 | + | /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is | |
| 82 | + | /// true in both `render_strict` and `render_markdown_plus`), so we don't need | |
| 83 | + | /// to reject them here — and rejecting them would surprise users pasting code | |
| 84 | + | /// blocks containing HTML. | |
| 85 | + | #[allow(clippy::result_large_err)] | |
| 86 | + | pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> { | |
| 87 | + | static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 88 | + | regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap() | |
| 89 | + | }); | |
| 90 | + | if EMBED_RE.is_match(body) { | |
| 91 | + | return Err(( | |
| 92 | + | StatusCode::UNPROCESSABLE_ENTITY, | |
| 93 | + | "Image embeds are a Fan+ feature.", | |
| 94 | + | ) | |
| 95 | + | .into_response()); | |
| 96 | + | } | |
| 97 | + | Ok(()) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[cfg(test)] | |
| 101 | + | mod proxy_image_tests { | |
| 102 | + | use super::{percent_encode_query, proxy_external_images}; | |
| 103 | + | ||
| 104 | + | #[test] | |
| 105 | + | fn external_image_is_routed_through_proxy() { | |
| 106 | + | let html = r#"<p><img src="https://example.com/a.png" alt="x"></p>"#; | |
| 107 | + | let out = proxy_external_images(html); | |
| 108 | + | assert!( | |
| 109 | + | out.contains(r#"src="/img-proxy?u=https%3A%2F%2Fexample.com%2Fa.png""#), | |
| 110 | + | "got: {out}" | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[test] | |
| 115 | + | fn same_origin_upload_is_not_proxied() { | |
| 116 | + | let html = r#"<img src="/uploads/abc-123" alt="x">"#; | |
| 117 | + | assert_eq!(proxy_external_images(html), html, "relative uploads must be left alone"); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn query_string_ampersands_round_trip() { | |
| 122 | + | // docengine attribute-escapes `&` to `&`; the proxied value must carry | |
| 123 | + | // the real `&` (percent-encoded), not the entity. | |
| 124 | + | let html = r#"<img src="https://x.test/i?w=1&h=2">"#; | |
| 125 | + | let out = proxy_external_images(html); | |
| 126 | + | assert!(out.contains("u=https%3A%2F%2Fx.test%2Fi%3Fw%3D1%26h%3D2"), "got: {out}"); | |
| 127 | + | assert!(!out.contains("&h=2"), "the entity must not leak into the query"); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn percent_encode_keeps_unreserved() { | |
| 132 | + | assert_eq!(percent_encode_query("aZ0-_.~"), "aZ0-_.~"); | |
| 133 | + | assert_eq!(percent_encode_query("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e"); | |
| 134 | + | } | |
| 135 | + | } |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | //! Shared route helpers. | |
| 2 | + | //! | |
| 3 | + | //! Split by concern: [`markdown`] (rendering + Fan+ image proxying), | |
| 4 | + | //! [`validation`] (input parsing → HTTP responses), and [`authz`] (roles, | |
| 5 | + | //! ban/mute/suspension gates, community-state machine, superadmin). This module | |
| 6 | + | //! keeps the small DB/transaction/audit context helpers and re-exports the | |
| 7 | + | //! submodules so callers still reach everything via `crate::routes::*`. | |
| 8 | + | ||
| 9 | + | use axum::{ | |
| 10 | + | http::StatusCode, | |
| 11 | + | response::{IntoResponse, Response}, | |
| 12 | + | }; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | use mt_core::types::{ModAction, ModActor}; | |
| 16 | + | ||
| 17 | + | use crate::auth; | |
| 18 | + | use crate::templates::*; | |
| 19 | + | ||
| 20 | + | mod authz; | |
| 21 | + | mod markdown; | |
| 22 | + | mod validation; | |
| 23 | + | ||
| 24 | + | pub(crate) use authz::*; | |
| 25 | + | pub(crate) use markdown::*; | |
| 26 | + | pub(crate) use validation::*; | |
| 27 | + | ||
| 28 | + | // ============================================================================ | |
| 29 | + | // Rate limiting constants | |
| 30 | + | // ============================================================================ | |
| 31 | + | ||
| 32 | + | /// Per-user rate limit: max posts per window (complements per-IP tower-governor). | |
| 33 | + | pub(crate) const USER_POST_RATE_LIMIT: i64 = 15; | |
| 34 | + | pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60; | |
| 35 | + | ||
| 36 | + | // ============================================================================ | |
| 37 | + | // Common DB / transaction / context helpers | |
| 38 | + | // ============================================================================ | |
| 39 | + | ||
| 40 | + | /// Fetch community by slug, returning 404/500 on failure. | |
| 41 | + | #[tracing::instrument(skip_all)] | |
| 42 | + | pub(crate) async fn get_community( | |
| 43 | + | db: &sqlx::PgPool, | |
| 44 | + | slug: &str, | |
| 45 | + | ) -> Result<mt_db::queries::CommunityRow, Response> { | |
| 46 | + | mt_db::queries::get_community_by_slug(db, slug) | |
| 47 | + | .await | |
| 48 | + | .map_err(|e| { | |
| 49 | + | tracing::error!(error = ?e, "db error fetching community"); | |
| 50 | + | crate::error_page::internal_error() | |
| 51 | + | })? | |
| 52 | + | .ok_or_else(crate::error_page::not_found) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Look up a user by username, returning 422 if not found. | |
| 56 | + | #[tracing::instrument(skip_all)] | |
| 57 | + | pub(crate) async fn get_user_by_username( | |
| 58 | + | db: &sqlx::PgPool, | |
| 59 | + | username: &str, | |
| 60 | + | ) -> Result<Uuid, Response> { | |
| 61 | + | mt_db::queries::get_user_by_username(db, username) | |
| 62 | + | .await | |
| 63 | + | .map_err(|e| { | |
| 64 | + | tracing::error!(error = ?e, "db error looking up user"); | |
| 65 | + | crate::error_page::internal_error() | |
| 66 | + | })? | |
| 67 | + | // 422 stays a plain form-validation response (consumed inline by mt.js), | |
| 68 | + | // not a full branded page. | |
| 69 | + | .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response()) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Begin a transaction, mapping a DB error to a branded 500. | |
| 73 | + | #[allow(clippy::result_large_err)] | |
| 74 | + | pub(crate) async fn begin_tx( | |
| 75 | + | db: &sqlx::PgPool, | |
| 76 | + | ) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, Response> { | |
| 77 | + | db.begin().await.map_err(|e| { | |
| 78 | + | tracing::error!(error = ?e, "db error opening transaction"); | |
| 79 | + | crate::error_page::internal_error() | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Commit a transaction, mapping a DB error to a branded 500. | |
| 84 | + | #[allow(clippy::result_large_err)] | |
| 85 | + | pub(crate) async fn commit_tx( | |
| 86 | + | tx: sqlx::Transaction<'_, sqlx::Postgres>, | |
| 87 | + | ) -> Result<(), Response> { | |
| 88 | + | tx.commit().await.map_err(|e| { | |
| 89 | + | tracing::error!(error = ?e, "db error committing transaction"); | |
| 90 | + | crate::error_page::internal_error() | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// Write a mod-log entry on the caller's transaction. | |
| 95 | + | /// | |
| 96 | + | /// The audit row is written on the *same* `tx` as the mutation it records, so | |
| 97 | + | /// the moderation action and its log either both commit or both roll back — an | |
| 98 | + | /// auditable action can never land without its correctly-attributed audit row | |
| 99 | + | /// (closes the fire-and-forget gap). A `System` actor (e.g. flag-threshold | |
| 100 | + | /// auto-hide) is recorded as a NULL `actor_id`, never the user who tripped it. | |
| 101 | + | #[allow(clippy::result_large_err)] | |
| 102 | + | pub(crate) async fn audit( | |
| 103 | + | tx: &mut sqlx::PgConnection, | |
| 104 | + | community_id: Option<Uuid>, | |
| 105 | + | actor: ModActor, | |
| 106 | + | action: ModAction, | |
| 107 | + | target_user: Option<Uuid>, | |
| 108 | + | target_id: Option<Uuid>, | |
| 109 | + | reason: Option<&str>, | |
| 110 | + | ) -> Result<(), Response> { | |
| 111 | + | mt_db::mutations::insert_mod_log( | |
| 112 | + | &mut *tx, community_id, actor, action, target_user, target_id, reason, | |
| 113 | + | ) | |
| 114 | + | .await | |
| 115 | + | .map_err(|e| { | |
| 116 | + | tracing::error!(error = %e, "failed to insert mod log"); | |
| 117 | + | crate::error_page::internal_error() | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Convert a session user to a template session user. | |
| 122 | + | pub(crate) fn template_user( | |
| 123 | + | user: &auth::SessionUser, | |
| 124 | + | platform_admin_id: Option<Uuid>, | |
| 125 | + | ) -> TemplateSessionUser { | |
| 126 | + | TemplateSessionUser { | |
| 127 | + | is_platform_admin: platform_admin_id == Some(user.user_id), | |
| 128 | + | username: user.username.clone(), | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit. | |
| 133 | + | #[tracing::instrument(skip_all)] | |
| 134 | + | pub(crate) async fn check_user_post_rate( | |
| 135 | + | db: &sqlx::PgPool, | |
| 136 | + | user_id: Uuid, | |
| 137 | + | ) -> Result<(), Response> { | |
| 138 | + | let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS) | |
| 139 | + | .await | |
| 140 | + | .map_err(|e| { | |
| 141 | + | tracing::error!(error = ?e, "db error checking user post rate"); | |
| 142 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 143 | + | })?; | |
| 144 | + | if count >= USER_POST_RATE_LIMIT { | |
| 145 | + | return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response()); | |
| 146 | + | } | |
| 147 | + | Ok(()) | |
| 148 | + | } |
| @@ -0,0 +1,242 @@ | |||
| 1 | + | //! Input validation + parsing helpers that map failures to HTTP responses. | |
| 2 | + | ||
| 3 | + | use axum::{ | |
| 4 | + | http::StatusCode, | |
| 5 | + | response::{IntoResponse, Response}, | |
| 6 | + | }; | |
| 7 | + | use chrono::{DateTime, Duration, Utc}; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | /// Parse a UUID from a string, returning 404 on failure. | |
| 11 | + | #[allow(clippy::result_large_err)] | |
| 12 | + | pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> { | |
| 13 | + | Uuid::parse_str(id_str).map_err(|_| crate::error_page::not_found()) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Build a 422 validation response tagged with the offending form field. | |
| 17 | + | /// | |
| 18 | + | /// `field` matches the input's `name` attribute. The `X-Form-Field` header lets | |
| 19 | + | /// `mt.js` render the message as a persistent inline error next to that input | |
| 20 | + | /// (and focus it) instead of a transient toast — the A-grade form-failure UX. | |
| 21 | + | /// Submissions never navigate away on 422, so the user's input is preserved in | |
| 22 | + | /// the live DOM; only the error needs to come back. | |
| 23 | + | #[allow(clippy::result_large_err)] | |
| 24 | + | pub(crate) fn field_error(field: &'static str, message: impl Into<String>) -> Response { | |
| 25 | + | ( | |
| 26 | + | StatusCode::UNPROCESSABLE_ENTITY, | |
| 27 | + | [("X-Form-Field", field)], | |
| 28 | + | message.into(), | |
| 29 | + | ) | |
| 30 | + | .into_response() | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Validate a title field (1-256 chars). | |
| 34 | + | #[allow(clippy::result_large_err)] | |
| 35 | + | pub(crate) fn validate_title(text: &str) -> Result<&str, Response> { | |
| 36 | + | let t = text.trim(); | |
| 37 | + | // Count characters, not bytes, to match the client `maxlength` (UTF-16 | |
| 38 | + | // units) — else a multibyte title under the visible cap fails server-side. | |
| 39 | + | if t.is_empty() || t.chars().count() > 256 { | |
| 40 | + | return Err(field_error( | |
| 41 | + | "title", | |
| 42 | + | "Title must be between 1 and 256 characters.", | |
| 43 | + | )); | |
| 44 | + | } | |
| 45 | + | Ok(t) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Validate a body/content field (1 to max chars). `label` names the field in | |
| 49 | + | /// the message ("Body", "Footnote"); the inline error attaches to the `body` | |
| 50 | + | /// input, which every content textarea in the app uses as its `name`. | |
| 51 | + | #[allow(clippy::result_large_err)] | |
| 52 | + | pub(crate) fn validate_body<'a>(text: &'a str, max: usize, label: &str) -> Result<&'a str, Response> { | |
| 53 | + | let t = text.trim(); | |
| 54 | + | // Character count (not byte length) to match the client-side cap. | |
| 55 | + | if t.is_empty() || t.chars().count() > max { | |
| 56 | + | return Err(field_error( | |
| 57 | + | "body", | |
| 58 | + | format!("{label} must be between 1 and {max} characters."), | |
| 59 | + | )); | |
| 60 | + | } | |
| 61 | + | Ok(t) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Parse a ban duration string into an optional expiration datetime. | |
| 65 | + | /// Returns `Err` for unrecognized durations to prevent accidental permanent bans. | |
| 66 | + | #[allow(clippy::result_large_err)] | |
| 67 | + | pub(crate) fn parse_duration(duration: &str) -> Result<Option<DateTime<Utc>>, Response> { | |
| 68 | + | match duration { | |
| 69 | + | "permanent" => Ok(None), | |
| 70 | + | "1h" => Ok(Some(Utc::now() + Duration::hours(1))), | |
| 71 | + | "1d" => Ok(Some(Utc::now() + Duration::days(1))), | |
| 72 | + | "7d" => Ok(Some(Utc::now() + Duration::days(7))), | |
| 73 | + | "30d" => Ok(Some(Utc::now() + Duration::days(30))), | |
| 74 | + | _ => Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid duration.").into_response()), | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[cfg(test)] | |
| 79 | + | mod validation_tests { | |
| 80 | + | use super::*; | |
| 81 | + | ||
| 82 | + | // ── validate_title (1..=256 chars after trim) ── | |
| 83 | + | ||
| 84 | + | #[test] | |
| 85 | + | fn title_rejects_empty() { | |
| 86 | + | assert!(validate_title("").is_err()); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[test] | |
| 90 | + | fn title_rejects_whitespace_only() { | |
| 91 | + | // The function trims first, so whitespace-only collapses to empty. | |
| 92 | + | assert!(validate_title(" \t\n ").is_err()); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn title_accepts_single_char() { | |
| 97 | + | assert_eq!(validate_title("x").unwrap(), "x"); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[test] | |
| 101 | + | fn title_trims_surrounding_whitespace() { | |
| 102 | + | // Pins the `.trim()` step: returned slice excludes leading/trailing whitespace. | |
| 103 | + | assert_eq!(validate_title(" hello ").unwrap(), "hello"); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[test] | |
| 107 | + | fn title_accepts_exactly_256_chars() { | |
| 108 | + | let s = "a".repeat(256); | |
| 109 | + | assert_eq!(validate_title(&s).unwrap().len(), 256); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[test] | |
| 113 | + | fn title_rejects_257_chars() { | |
| 114 | + | // Pins `t.len() > 256` vs `>= 256`. At exactly 257, must reject. | |
| 115 | + | let s = "a".repeat(257); | |
| 116 | + | assert!(validate_title(&s).is_err()); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn title_counts_characters_not_bytes() { | |
| 121 | + | // 256 multibyte chars = 1024 bytes. Must pass (char count), matching the | |
| 122 | + | // client `maxlength`; a byte-length check would wrongly reject it. | |
| 123 | + | let s = "é".repeat(256); | |
| 124 | + | assert!(validate_title(&s).is_ok(), "256 multibyte chars must pass"); | |
| 125 | + | // 257 of them must still reject on character count. | |
| 126 | + | let s = "é".repeat(257); | |
| 127 | + | assert!(validate_title(&s).is_err(), "257 chars must reject"); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // ── validate_body (1..=max chars after trim) ── | |
| 131 | + | ||
| 132 | + | #[test] | |
| 133 | + | fn body_rejects_empty() { | |
| 134 | + | assert!(validate_body("", 100, "Body").is_err()); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn body_accepts_one_char() { | |
| 139 | + | assert_eq!(validate_body("x", 100, "Body").unwrap(), "x"); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn body_accepts_at_exact_max() { | |
| 144 | + | let s = "a".repeat(50); | |
| 145 | + | assert_eq!(validate_body(&s, 50, "Body").unwrap().len(), 50); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn body_rejects_one_over_max() { | |
| 150 | + | // Pins `t.len() > max` vs `>= max`. Length max+1 must reject. | |
| 151 | + | let s = "a".repeat(51); | |
| 152 | + | assert!(validate_body(&s, 50, "Body").is_err()); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn body_trims_before_length_check() { | |
| 157 | + | // Trimmed length, not raw length, is what counts. " a " (3 raw) → "a" | |
| 158 | + | // (1 trimmed) fits within max=1. | |
| 159 | + | assert_eq!(validate_body(" a ", 1, "Body").unwrap(), "a"); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn body_or_chain_requires_either_condition() { | |
| 164 | + | // Pins `is_empty() || len > max` vs `&&` (which would never reject). | |
| 165 | + | // An empty body alone (under max) must reject. | |
| 166 | + | assert!(validate_body(" ", 100, "Body").is_err()); | |
| 167 | + | // A too-long body alone (non-empty) must reject. | |
| 168 | + | let s = "x".repeat(101); | |
| 169 | + | assert!(validate_body(&s, 100, "Body").is_err()); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // ── parse_duration ── | |
| 173 | + | ||
| 174 | + | #[test] | |
| 175 | + | fn parse_duration_permanent_returns_none() { | |
| 176 | + | // Pins the `"permanent" => Ok(None)` arm; a mutation swapping arms | |
| 177 | + | // would either reject "permanent" or produce a non-None expiry. | |
| 178 | + | let r = parse_duration("permanent").unwrap(); | |
| 179 | + | assert!(r.is_none()); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn parse_duration_known_strings_produce_offsets() { | |
| 184 | + | // Each known string maps to a specific Duration arm. Assert the | |
| 185 | + | // returned datetime is roughly the expected offset from now. | |
| 186 | + | let before = Utc::now(); | |
| 187 | + | let h1 = parse_duration("1h").unwrap().unwrap(); | |
| 188 | + | let after = Utc::now(); | |
| 189 | + | let expected_min = before + Duration::hours(1); | |
| 190 | + | let expected_max = after + Duration::hours(1); | |
| 191 | + | assert!(h1 >= expected_min - Duration::seconds(2)); | |
| 192 | + | assert!(h1 <= expected_max + Duration::seconds(2)); | |
| 193 | + | ||
| 194 | + | let d1 = parse_duration("1d").unwrap().unwrap(); | |
| 195 | + | let d7 = parse_duration("7d").unwrap().unwrap(); | |
| 196 | + | let d30 = parse_duration("30d").unwrap().unwrap(); | |
| 197 | + | // Ordering must be strict (catches arm-swap mutations). | |
| 198 | + | assert!(h1 < d1); | |
| 199 | + | assert!(d1 < d7); | |
| 200 | + | assert!(d7 < d30); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[test] | |
| 204 | + | fn parse_duration_arm_offsets_are_distinct() { | |
| 205 | + | // Compare relative day gaps. From d1 to d7 should be ~6 days, from | |
| 206 | + | // d7 to d30 should be ~23 days. Catches mutations swapping arms. | |
| 207 | + | let d1 = parse_duration("1d").unwrap().unwrap(); | |
| 208 | + | let d7 = parse_duration("7d").unwrap().unwrap(); | |
| 209 | + | let d30 = parse_duration("30d").unwrap().unwrap(); | |
| 210 | + | ||
| 211 | + | let gap_1_to_7 = (d7 - d1).num_days(); | |
| 212 | + | let gap_7_to_30 = (d30 - d7).num_days(); | |
| 213 | + | assert!((5..=7).contains(&gap_1_to_7), "1d→7d gap was {gap_1_to_7}"); | |
| 214 | + | assert!((22..=24).contains(&gap_7_to_30), "7d→30d gap was {gap_7_to_30}"); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn parse_duration_unknown_is_rejected() { | |
| 219 | + | // Pins the `_ => Err(...)` arm: anything not in the allowlist must | |
| 220 | + | // return Err rather than (e.g.) defaulting to permanent. | |
| 221 | + | assert!(parse_duration("forever").is_err()); | |
| 222 | + | assert!(parse_duration("").is_err()); | |
| 223 | + | assert!(parse_duration("1d ").is_err(), "trailing whitespace must not match"); | |
| 224 | + | assert!(parse_duration("1H").is_err(), "case-sensitive: 1H is not 1h"); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // ── parse_uuid ── | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn parse_uuid_valid_string() { | |
| 231 | + | let raw = "11111111-2222-3333-4444-555555555555"; | |
| 232 | + | let parsed = parse_uuid(raw).unwrap(); | |
| 233 | + | assert_eq!(parsed.to_string(), raw); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn parse_uuid_invalid_string_is_err() { | |
| 238 | + | assert!(parse_uuid("not-a-uuid").is_err()); | |
| 239 | + | assert!(parse_uuid("").is_err()); | |
| 240 | + | assert!(parse_uuid("11111111-2222-3333-4444").is_err()); | |
| 241 | + | } | |
| 242 | + | } |