Skip to main content

max / makenotwork

server: mt architectural refactors (audit 2026-07-02 follow-up) Consistency/type-safety/readability refactors; gate-green (clippy -D warnings, 437 tests, offline .sqlx build): - Move inlined handler SQL into mt-db: 7 compile-checked fns (get_flag_removal_target, is_post_removed, count_threads_in_community, get_community_suspension_reason, get_user_signature, clear/set_user_signature); flagging/admin/actions/account handlers call them. .sqlx cache regenerated. - De-dup sorted-thread SQL: collapse _sorted + _sorted_filtered into one list_threads_in_category_sorted(..., tag_slug: Option<&str>). - Push enums to the DB boundary: the list fn takes SortColumn/SortOrder (exhaustive match); ensure_membership_with_role takes CommunityRole. - Decompose auth.rs::callback into exchange_code_for_token / fetch_userinfo_with_retry / upsert_login_user; loops return on success, so the unwrap()/expect() panic paths are gone (~240 -> ~90 lines). - Split helpers.rs (886 lines) into helpers/{markdown,validation,authz}.rs + a slim mod.rs that re-exports them; all call sites unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:02 UTC
Commit: 376071e952519c28bcfb8d0a823790e65f6d2f93
Parent: 37d5fdc
23 files changed, +1455 insertions, -782 deletions
@@ -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 + }
@@ -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 + }
@@ -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 + }
@@ -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 + }
@@ -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 + }
@@ -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 + }
@@ -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 - /// `&amp;` 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: &regex_lite::Captures| {
59 - let url = caps[2].replace("&amp;", "&");
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 `![alt](url)`. 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 + /// `&amp;` 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: &regex_lite::Captures| {
39 + let url = caps[2].replace("&amp;", "&");
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 `![alt](url)`. 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 `&amp;`; 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&amp;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("&amp;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 + }