max / makenotwork
32 files changed,
+723 insertions,
-607 deletions
D
multithreaded/.sqlx/query-15333e4a76a069b3279777bae4abbe6c1b57158c3c5a2e29350972d879bb177d.json
-42
| @@ -1,42 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "SELECT u.username,\n u.display_name,\n m.role AS \"role: CommunityRole\",\n m.joined_at AS \"joined_at: chrono::DateTime<chrono::Utc>\"\n FROM memberships m\n JOIN users u ON u.mnw_account_id = m.user_id\n WHERE m.community_id = $1\n ORDER BY\n CASE m.role\n WHEN 'owner' THEN 0\n WHEN 'moderator' THEN 1\n WHEN 'member' THEN 2\n ELSE 3\n END,\n m.joined_at\n LIMIT $2 OFFSET $3", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "username", | |
| 9 | - | "type_info": "Text" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "display_name", | |
| 14 | - | "type_info": "Text" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "role: CommunityRole", | |
| 19 | - | "type_info": "Text" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "joined_at: chrono::DateTime<chrono::Utc>", | |
| 24 | - | "type_info": "Timestamptz" | |
| 25 | - | } | |
| 26 | - | ], | |
| 27 | - | "parameters": { | |
| 28 | - | "Left": [ | |
| 29 | - | "Uuid", | |
| 30 | - | "Int8", | |
| 31 | - | "Int8" | |
| 32 | - | ] | |
| 33 | - | }, | |
| 34 | - | "nullable": [ | |
| 35 | - | false, | |
| 36 | - | true, | |
| 37 | - | false, | |
| 38 | - | false | |
| 39 | - | ] | |
| 40 | - | }, | |
| 41 | - | "hash": "15333e4a76a069b3279777bae4abbe6c1b57158c3c5a2e29350972d879bb177d" | |
| 42 | - | } |
A
multithreaded/.sqlx/query-2a4cff2675d7b7922a83ddb16555ef7a980541cfbbaea6e399c4e241d3457863.json
+47
| @@ -0,0 +1,47 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT id, name, slug, description, sort_order\n FROM categories\n WHERE id = $1 AND community_id = $2", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "name", | |
| 14 | + | "type_info": "Text" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "slug", | |
| 19 | + | "type_info": "Text" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "description", | |
| 24 | + | "type_info": "Text" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "sort_order", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | } | |
| 31 | + | ], | |
| 32 | + | "parameters": { | |
| 33 | + | "Left": [ | |
| 34 | + | "Uuid", | |
| 35 | + | "Uuid" | |
| 36 | + | ] | |
| 37 | + | }, | |
| 38 | + | "nullable": [ | |
| 39 | + | false, | |
| 40 | + | false, | |
| 41 | + | false, | |
| 42 | + | true, | |
| 43 | + | false | |
| 44 | + | ] | |
| 45 | + | }, | |
| 46 | + | "hash": "2a4cff2675d7b7922a83ddb16555ef7a980541cfbbaea6e399c4e241d3457863" | |
| 47 | + | } |
A
multithreaded/.sqlx/query-4151f8cee14608e833ae57a2bc89dee8f084ab963a0e145257f41b42715f50e5.json
+42
| @@ -0,0 +1,42 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT u.username,\n u.display_name,\n m.role AS \"role: CommunityRole\",\n m.joined_at AS \"joined_at: chrono::DateTime<chrono::Utc>\"\n FROM memberships m\n JOIN users u ON u.mnw_account_id = m.user_id\n WHERE m.community_id = $1\n ORDER BY\n CASE m.role\n WHEN 'owner' THEN 0\n WHEN 'moderator' THEN 1\n WHEN 'member' THEN 2\n END,\n m.joined_at\n LIMIT $2 OFFSET $3", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "username", | |
| 9 | + | "type_info": "Text" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "display_name", | |
| 14 | + | "type_info": "Text" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "role: CommunityRole", | |
| 19 | + | "type_info": "Text" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "joined_at: chrono::DateTime<chrono::Utc>", | |
| 24 | + | "type_info": "Timestamptz" | |
| 25 | + | } | |
| 26 | + | ], | |
| 27 | + | "parameters": { | |
| 28 | + | "Left": [ | |
| 29 | + | "Uuid", | |
| 30 | + | "Int8", | |
| 31 | + | "Int8" | |
| 32 | + | ] | |
| 33 | + | }, | |
| 34 | + | "nullable": [ | |
| 35 | + | false, | |
| 36 | + | true, | |
| 37 | + | false, | |
| 38 | + | false | |
| 39 | + | ] | |
| 40 | + | }, | |
| 41 | + | "hash": "4151f8cee14608e833ae57a2bc89dee8f084ab963a0e145257f41b42715f50e5" | |
| 42 | + | } |
D
multithreaded/.sqlx/query-6ca606fbba7c525e0e48b472381eaf3a4660092155db3abf821c5a45e01ec9b0.json
-46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "SELECT id, name, slug, description, sort_order\n FROM categories\n WHERE id = $1", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "id", | |
| 9 | - | "type_info": "Uuid" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "name", | |
| 14 | - | "type_info": "Text" | |
| 15 | - | }, | |
| 16 | - | { | |
| 17 | - | "ordinal": 2, | |
| 18 | - | "name": "slug", | |
| 19 | - | "type_info": "Text" | |
| 20 | - | }, | |
| 21 | - | { | |
| 22 | - | "ordinal": 3, | |
| 23 | - | "name": "description", | |
| 24 | - | "type_info": "Text" | |
| 25 | - | }, | |
| 26 | - | { | |
| 27 | - | "ordinal": 4, | |
| 28 | - | "name": "sort_order", | |
| 29 | - | "type_info": "Int4" | |
| 30 | - | } | |
| 31 | - | ], | |
| 32 | - | "parameters": { | |
| 33 | - | "Left": [ | |
| 34 | - | "Uuid" | |
| 35 | - | ] | |
| 36 | - | }, | |
| 37 | - | "nullable": [ | |
| 38 | - | false, | |
| 39 | - | false, | |
| 40 | - | false, | |
| 41 | - | true, | |
| 42 | - | false | |
| 43 | - | ] | |
| 44 | - | }, | |
| 45 | - | "hash": "6ca606fbba7c525e0e48b472381eaf3a4660092155db3abf821c5a45e01ec9b0" | |
| 46 | - | } |
| @@ -3062,6 +3062,7 @@ version = "0.1.1" | |||
| 3062 | 3062 | dependencies = [ | |
| 3063 | 3063 | "aws-config", | |
| 3064 | 3064 | "aws-sdk-s3", | |
| 3065 | + | "bytes", | |
| 3065 | 3066 | "tokio", | |
| 3066 | 3067 | "tracing", | |
| 3067 | 3068 | ] |
| @@ -673,7 +673,6 @@ pub async fn list_community_members( | |||
| 673 | 673 | WHEN 'owner' THEN 0 | |
| 674 | 674 | WHEN 'moderator' THEN 1 | |
| 675 | 675 | WHEN 'member' THEN 2 | |
| 676 | - | ELSE 3 | |
| 677 | 676 | END, | |
| 678 | 677 | m.joined_at | |
| 679 | 678 | LIMIT $2 OFFSET $3"#, | |
| @@ -699,17 +698,25 @@ pub async fn count_community_members( | |||
| 699 | 698 | .await | |
| 700 | 699 | } | |
| 701 | 700 | ||
| 701 | + | /// Load a category by id, scoped to the community it must belong to. | |
| 702 | + | /// | |
| 703 | + | /// The `community_id` filter is the C1 invariant for this resource: a category id | |
| 704 | + | /// from a `/p/{slug}/…` URL must belong to the slug's community. Loading by bare | |
| 705 | + | /// id would let an owner of community A render/act on community B's category | |
| 706 | + | /// (see `routes/scope.rs`). Callers pass the community resolved from the slug. | |
| 702 | 707 | #[tracing::instrument(skip_all)] | |
| 703 | - | pub async fn get_category_by_id( | |
| 708 | + | pub async fn get_category_in_community( | |
| 704 | 709 | pool: &PgPool, | |
| 705 | 710 | category_id: Uuid, | |
| 711 | + | community_id: Uuid, | |
| 706 | 712 | ) -> Result<Option<CategoryForSettings>, sqlx::Error> { | |
| 707 | 713 | sqlx::query_as!( | |
| 708 | 714 | CategoryForSettings, | |
| 709 | 715 | "SELECT id, name, slug, description, sort_order | |
| 710 | 716 | FROM categories | |
| 711 | - | WHERE id = $1", | |
| 717 | + | WHERE id = $1 AND community_id = $2", | |
| 712 | 718 | category_id, | |
| 719 | + | community_id, | |
| 713 | 720 | ) | |
| 714 | 721 | .fetch_optional(pool) | |
| 715 | 722 | .await |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | -- Reconcile the memberships role CHECK with the `CommunityRole` domain enum, | |
| 2 | + | -- which has only Owner/Moderator/Member. Migration 006 allowed a fourth value, | |
| 3 | + | -- 'guest', that the enum cannot decode: a 'guest' row would fail the sqlx | |
| 4 | + | -- decode and 500 every role lookup and member list for that community. Nothing | |
| 5 | + | -- ever writes 'guest' (all inserts bind 'member' or a CommunityRole), so this | |
| 6 | + | -- tightening is safe. 006's inline CHECK cannot be edited (applied-migration | |
| 7 | + | -- checksum rule), so replace the auto-named constraint here. | |
| 8 | + | ALTER TABLE memberships DROP CONSTRAINT IF EXISTS memberships_role_check; | |
| 9 | + | ALTER TABLE memberships ADD CONSTRAINT memberships_role_check | |
| 10 | + | CHECK (role IN ('owner', 'moderator', 'member')); |
| @@ -166,6 +166,31 @@ impl FromRequestParts<AppState> for MaybeUser { | |||
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | + | /// Axum extractor that requires an authenticated session. | |
| 170 | + | /// | |
| 171 | + | /// Yields the [`SessionUser`] directly, or rejects with a redirect to | |
| 172 | + | /// `/auth/login` — the exact behaviour ~30 write/settings handlers previously | |
| 173 | + | /// open-coded as `session_user.ok_or_else(|| Redirect::to("/auth/login")…)?`. | |
| 174 | + | /// Use this instead of `MaybeUser` whenever the handler needs a logged-in user. | |
| 175 | + | pub struct RequireUser(pub SessionUser); | |
| 176 | + | ||
| 177 | + | impl FromRequestParts<AppState> for RequireUser { | |
| 178 | + | type Rejection = axum::response::Response; | |
| 179 | + | ||
| 180 | + | async fn from_request_parts( | |
| 181 | + | parts: &mut Parts, | |
| 182 | + | state: &AppState, | |
| 183 | + | ) -> Result<Self, Self::Rejection> { | |
| 184 | + | let session = Session::from_request_parts(parts, state) | |
| 185 | + | .await | |
| 186 | + | .expect("session layer missing"); | |
| 187 | + | let user = SessionUser::from_session(&session) | |
| 188 | + | .await | |
| 189 | + | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 190 | + | Ok(RequireUser(user)) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 169 | 194 | /// Axum extractor that requires the user to be the platform admin. | |
| 170 | 195 | /// Returns 404 to non-admins (hides admin routes). | |
| 171 | 196 | pub struct PlatformAdmin(pub SessionUser); | |
| @@ -771,3 +796,62 @@ pub async fn logout(session: Session) -> impl IntoResponse { | |||
| 771 | 796 | } | |
| 772 | 797 | Redirect::to("/") | |
| 773 | 798 | } | |
| 799 | + | ||
| 800 | + | #[cfg(test)] | |
| 801 | + | mod tests { | |
| 802 | + | use super::*; | |
| 803 | + | ||
| 804 | + | #[test] | |
| 805 | + | fn pkce_challenge_matches_rfc7636_test_vector() { | |
| 806 | + | // RFC 7636 Appendix B known-answer vector. | |
| 807 | + | let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; | |
| 808 | + | let challenge = pkce_challenge(verifier); | |
| 809 | + | assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | #[test] | |
| 813 | + | fn pkce_challenge_is_deterministic() { | |
| 814 | + | let v = generate_verifier(); | |
| 815 | + | assert_eq!(pkce_challenge(&v), pkce_challenge(&v)); | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | #[test] | |
| 819 | + | fn verifier_is_url_safe_base64_of_32_bytes() { | |
| 820 | + | let v = generate_verifier(); | |
| 821 | + | // 32 bytes → 43 chars of unpadded base64url. | |
| 822 | + | assert_eq!(v.len(), 43); | |
| 823 | + | assert!(!v.contains('='), "must be unpadded"); | |
| 824 | + | assert!( | |
| 825 | + | v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), | |
| 826 | + | "must be url-safe: {v}" | |
| 827 | + | ); | |
| 828 | + | // And the challenge is likewise url-safe/unpadded (sent as a query param). | |
| 829 | + | let c = pkce_challenge(&v); | |
| 830 | + | assert_eq!(c.len(), 43); | |
| 831 | + | assert!(!c.contains('=')); | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | #[test] | |
| 835 | + | fn verifier_and_nonce_are_unpredictable() { | |
| 836 | + | // Sanity that we're not returning a constant. Collisions across 32/16 | |
| 837 | + | // random bytes are astronomically unlikely, so equality means a bug. | |
| 838 | + | assert_ne!(generate_verifier(), generate_verifier()); | |
| 839 | + | assert_ne!(generate_state_nonce(), generate_state_nonce()); | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | #[test] | |
| 843 | + | fn state_nonce_is_128_bits_of_hex() { | |
| 844 | + | let n = generate_state_nonce(); | |
| 845 | + | assert_eq!(n.len(), 32); // 16 bytes → 32 hex chars | |
| 846 | + | assert!(n.bytes().all(|b| b.is_ascii_hexdigit())); | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | #[test] | |
| 850 | + | fn state_comparison_is_constant_time_and_correct() { | |
| 851 | + | // The callback compares the returned `state` against the session nonce via | |
| 852 | + | // this shared constant-time primitive (auth.rs). Guard the wiring here. | |
| 853 | + | assert!(crate::csrf::constant_time_compare("abc123", "abc123")); | |
| 854 | + | assert!(!crate::csrf::constant_time_compare("abc123", "abc124")); | |
| 855 | + | assert!(!crate::csrf::constant_time_compare("abc", "abc123")); | |
| 856 | + | } | |
| 857 | + | } |
| @@ -80,14 +80,45 @@ impl Config { | |||
| 80 | 80 | /// `localhost`, `[::1]`) are exempt so local HTTP development still works; | |
| 81 | 81 | /// anything else must be `https`. | |
| 82 | 82 | fn assert_secure_url(var_name: &str, url: &str) { | |
| 83 | - | let is_loopback = | |
| 84 | - | url.contains("127.0.0.1") || url.contains("localhost") || url.contains("[::1]"); | |
| 85 | 83 | assert!( | |
| 86 | - | is_loopback || url.starts_with("https://"), | |
| 84 | + | is_loopback_url(url) || url.starts_with("https://"), | |
| 87 | 85 | "{var_name} must be https for a non-loopback deployment (got `{url}`)" | |
| 88 | 86 | ); | |
| 89 | 87 | } | |
| 90 | 88 | ||
| 89 | + | /// Whether an http(s) URL's host is a loopback literal. | |
| 90 | + | /// | |
| 91 | + | /// Parses the host exactly rather than substring-matching: a naive | |
| 92 | + | /// `url.contains("127.0.0.1")` would treat `http://127.0.0.1.attacker.com` as | |
| 93 | + | /// loopback and boot it over cleartext. We strip scheme, userinfo, and port, then | |
| 94 | + | /// match the bare host against the loopback set (any `127.0.0.0/8`, `localhost`, | |
| 95 | + | /// `::1`). | |
| 96 | + | fn is_loopback_url(url: &str) -> bool { | |
| 97 | + | let after_scheme = | |
| 98 | + | match url.strip_prefix("http://").or_else(|| url.strip_prefix("https://")) { | |
| 99 | + | Some(rest) => rest, | |
| 100 | + | None => return false, | |
| 101 | + | }; | |
| 102 | + | let authority = after_scheme.split('/').next().unwrap_or(""); | |
| 103 | + | let host_and_port = match authority.rsplit_once('@') { | |
| 104 | + | Some((_userinfo, host)) => host, | |
| 105 | + | None => authority, | |
| 106 | + | }; | |
| 107 | + | let host = if let Some(rest) = host_and_port.strip_prefix('[') { | |
| 108 | + | // `[::1]:port` → `::1` | |
| 109 | + | rest.split(']').next().unwrap_or("") | |
| 110 | + | } else { | |
| 111 | + | // `host:port` → `host` | |
| 112 | + | host_and_port.split(':').next().unwrap_or("") | |
| 113 | + | }; | |
| 114 | + | if host == "localhost" || host == "::1" { | |
| 115 | + | return true; | |
| 116 | + | } | |
| 117 | + | host.parse::<IpAddr>() | |
| 118 | + | .map(|ip| ip.is_loopback()) | |
| 119 | + | .unwrap_or(false) | |
| 120 | + | } | |
| 121 | + | ||
| 91 | 122 | /// Parse `TRUSTED_PROXIES` (comma-separated IPs). Unset → loopback only; an | |
| 92 | 123 | /// explicit empty value → trust no proxy (every request keys on its peer). | |
| 93 | 124 | /// Unparseable entries are skipped with a warning rather than failing boot. | |
| @@ -127,6 +158,32 @@ mod tests { | |||
| 127 | 158 | } | |
| 128 | 159 | ||
| 129 | 160 | #[test] | |
| 161 | + | fn loopback_url_detection_is_exact() { | |
| 162 | + | assert!(is_loopback_url("http://127.0.0.1:3000/auth/callback")); | |
| 163 | + | assert!(is_loopback_url("http://127.0.0.5")); | |
| 164 | + | assert!(is_loopback_url("http://localhost:8080")); | |
| 165 | + | assert!(is_loopback_url("http://[::1]:3400/auth/callback")); | |
| 166 | + | assert!(is_loopback_url("http://user@127.0.0.1/")); | |
| 167 | + | // The substring-spoof the old check let through must now be rejected. | |
| 168 | + | assert!(!is_loopback_url("http://127.0.0.1.attacker.com/")); | |
| 169 | + | assert!(!is_loopback_url("http://localhost.evil.com/")); | |
| 170 | + | assert!(!is_loopback_url("http://example.com/")); | |
| 171 | + | assert!(!is_loopback_url("https://makenot.work/")); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[test] | |
| 175 | + | #[should_panic] | |
| 176 | + | fn non_https_public_url_panics() { | |
| 177 | + | assert_secure_url("MNW_BASE_URL", "http://127.0.0.1.attacker.com/"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn https_and_loopback_urls_boot() { | |
| 182 | + | assert_secure_url("MNW_BASE_URL", "https://makenot.work"); | |
| 183 | + | assert_secure_url("OAUTH_REDIRECT_URI", "http://127.0.0.1:3400/auth/callback"); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 130 | 187 | fn parses_list_and_skips_garbage() { | |
| 131 | 188 | let p = parse_trusted_proxies(Some("100.64.0.1, garbage, 10.0.0.2")); | |
| 132 | 189 | assert_eq!(p.len(), 2); |
| @@ -13,19 +13,32 @@ const MAX_BODY_SIZE: usize = 1_048_576; | |||
| 13 | 13 | fn is_private_ip(ip: std::net::IpAddr) -> bool { | |
| 14 | 14 | match ip { | |
| 15 | 15 | std::net::IpAddr::V4(v4) => { | |
| 16 | + | let o = v4.octets(); | |
| 16 | 17 | v4.is_loopback() | |
| 17 | 18 | || v4.is_private() | |
| 18 | 19 | || v4.is_link_local() | |
| 19 | 20 | || v4.is_broadcast() | |
| 20 | 21 | || v4.is_unspecified() | |
| 21 | - | || v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale) | |
| 22 | + | || o[0] == 0 // 0.0.0.0/8 "this network" | |
| 23 | + | || o[0] == 100 && (o[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale) | |
| 24 | + | || o[0] == 192 && o[1] == 0 && o[2] == 0 // 192.0.0.0/24 IETF protocol assignments | |
| 25 | + | || o[0] == 198 && (o[1] & 0xFE) == 18 // 198.18.0.0/15 benchmarking | |
| 26 | + | || o[0] >= 240 // 240.0.0.0/4 reserved / experimental | |
| 22 | 27 | } | |
| 23 | 28 | std::net::IpAddr::V6(v6) => { | |
| 29 | + | let seg = v6.segments(); | |
| 24 | 30 | v6.is_loopback() | |
| 25 | 31 | || v6.is_unspecified() | |
| 26 | - | || (v6.segments()[0] & 0xfe00) == 0xfc00 // ULA fd00::/7 | |
| 27 | - | || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local | |
| 28 | - | || matches!(v6.to_ipv4_mapped(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4))) | |
| 32 | + | || (seg[0] & 0xfe00) == 0xfc00 // ULA fd00::/7 | |
| 33 | + | || (seg[0] & 0xffc0) == 0xfe80 // link-local | |
| 34 | + | // NAT64 64:ff9b::/96 embeds a v4 address — check the embedded v4. | |
| 35 | + | || (seg[0] == 0x0064 && seg[1] == 0xff9b | |
| 36 | + | && is_private_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::new( | |
| 37 | + | (seg[6] >> 8) as u8, seg[6] as u8, (seg[7] >> 8) as u8, seg[7] as u8, | |
| 38 | + | )))) | |
| 39 | + | // v4-mapped (::ffff:a.b.c.d) and v4-compatible (::a.b.c.d) both | |
| 40 | + | // carry an embedded v4 that reqwest may connect to directly. | |
| 41 | + | || matches!(v6.to_ipv4(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4))) | |
| 29 | 42 | } | |
| 30 | 43 | } | |
| 31 | 44 | } | |
| @@ -44,7 +57,21 @@ fn validate_url(url: &str) -> bool { | |||
| 44 | 57 | .strip_prefix("http://") | |
| 45 | 58 | .or_else(|| lower.strip_prefix("https://")) | |
| 46 | 59 | .unwrap_or(""); | |
| 47 | - | let host_and_port = host_part.split('/').next().unwrap_or(""); | |
| 60 | + | let authority = host_part.split('/').next().unwrap_or(""); | |
| 61 | + | // Strip any userinfo ("user:pass@" or bare "@") — the real host is whatever | |
| 62 | + | // follows the LAST '@'. This must happen before the host/port split: without | |
| 63 | + | // it, `http://@10.0.0.1/` leaves `@10.0.0.1` as the "host", which fails | |
| 64 | + | // `IpAddr::parse` and slips past the literal-IP guard below. reqwest connects | |
| 65 | + | // to IP literals directly (it does NOT consult the custom `SsrfSafeResolver` | |
| 66 | + | // for a literal-IP host), so the literal-IP check here is the ONLY guard on | |
| 67 | + | // that path — the userinfo form must not be allowed to hide the IP from it. | |
| 68 | + | let host_and_port = match authority.rsplit_once('@') { | |
| 69 | + | Some((_userinfo, host)) => host, | |
| 70 | + | None => authority, | |
| 71 | + | }; | |
| 72 | + | if host_and_port.is_empty() { | |
| 73 | + | return false; | |
| 74 | + | } | |
| 48 | 75 | // Split host and optional port (`[ipv6]:port` or `host:port`). | |
| 49 | 76 | let (host, port) = if host_and_port.starts_with('[') { | |
| 50 | 77 | match host_and_port.split_once(']') { | |
| @@ -186,18 +213,26 @@ pub async fn fetch_og_metadata( | |||
| 186 | 213 | url: &str, | |
| 187 | 214 | ) -> Option<(Option<String>, Option<String>)> { | |
| 188 | 215 | if !validate_url(url) { | |
| 216 | + | tracing::warn!(%url, "link preview blocked: url failed scheme/host SSRF validation"); | |
| 189 | 217 | return None; | |
| 190 | 218 | } | |
| 191 | 219 | ||
| 192 | - | let resp = http | |
| 220 | + | let resp = match http | |
| 193 | 221 | .get(url) | |
| 194 | 222 | .timeout(std::time::Duration::from_secs(5)) | |
| 195 | 223 | .header("User-Agent", "Multithreaded/LinkPreview") | |
| 196 | 224 | .send() | |
| 197 | 225 | .await | |
| 198 | - | .ok()?; | |
| 226 | + | { | |
| 227 | + | Ok(r) => r, | |
| 228 | + | Err(e) => { | |
| 229 | + | tracing::debug!(%url, error = ?e, "link preview fetch failed (transport/timeout/blocked resolver)"); | |
| 230 | + | return None; | |
| 231 | + | } | |
| 232 | + | }; | |
| 199 | 233 | ||
| 200 | 234 | if !resp.status().is_success() { | |
| 235 | + | tracing::debug!(%url, status = %resp.status(), "link preview fetch: non-success status"); | |
| 201 | 236 | return None; | |
| 202 | 237 | } | |
| 203 | 238 | ||
| @@ -263,18 +298,26 @@ fn allowed_image_content_type(ct: &str) -> Option<&'static str> { | |||
| 263 | 298 | #[tracing::instrument(skip_all)] | |
| 264 | 299 | pub async fn fetch_image(http: &reqwest::Client, url: &str) -> Option<(Vec<u8>, &'static str)> { | |
| 265 | 300 | if !validate_url(url) { | |
| 301 | + | tracing::warn!(%url, "image proxy blocked: url failed scheme/host SSRF validation"); | |
| 266 | 302 | return None; | |
| 267 | 303 | } | |
| 268 | 304 | ||
| 269 | - | let resp = http | |
| 305 | + | let resp = match http | |
| 270 | 306 | .get(url) | |
| 271 | 307 | .timeout(std::time::Duration::from_secs(5)) | |
| 272 | 308 | .header("User-Agent", "Multithreaded/ImageProxy") | |
| 273 | 309 | .send() | |
| 274 | 310 | .await | |
| 275 | - | .ok()?; | |
| 311 | + | { | |
| 312 | + | Ok(r) => r, | |
| 313 | + | Err(e) => { | |
| 314 | + | tracing::debug!(%url, error = ?e, "image proxy fetch failed (transport/timeout/blocked resolver)"); | |
| 315 | + | return None; | |
| 316 | + | } | |
| 317 | + | }; | |
| 276 | 318 | ||
| 277 | 319 | if !resp.status().is_success() { | |
| 320 | + | tracing::debug!(%url, status = %resp.status(), "image proxy fetch: non-success status"); | |
| 278 | 321 | return None; | |
| 279 | 322 | } | |
| 280 | 323 | ||
| @@ -500,6 +543,49 @@ mod tests { | |||
| 500 | 543 | assert!(validate_url("https://93.184.216.34")); | |
| 501 | 544 | } | |
| 502 | 545 | ||
| 546 | + | #[test] | |
| 547 | + | fn validate_url_blocks_userinfo_literal_ip() { | |
| 548 | + | // H1: a userinfo-prefixed literal IP must not slip past the literal-IP | |
| 549 | + | // guard. reqwest connects straight to the IP (skipping SsrfSafeResolver), | |
| 550 | + | // so validate_url is the only defense on this path. | |
| 551 | + | assert!(!validate_url("http://@10.0.0.1/")); | |
| 552 | + | assert!(!validate_url("http://@169.254.169.254/")); // cloud metadata | |
| 553 | + | assert!(!validate_url("http://user:pass@127.0.0.1/")); | |
| 554 | + | assert!(!validate_url("http://foo@192.168.1.1:80/path")); | |
| 555 | + | assert!(!validate_url("http://a@b@10.0.0.1/")); // last '@' wins | |
| 556 | + | assert!(!validate_url("http://@[::1]/")); | |
| 557 | + | // A userinfo-prefixed *public* host is still allowed (host resolves and is | |
| 558 | + | // re-checked at connect time by SsrfSafeResolver). | |
| 559 | + | assert!(validate_url("http://user@example.com/")); | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | #[test] | |
| 563 | + | fn validate_url_blocks_empty_host() { | |
| 564 | + | assert!(!validate_url("http://@/")); | |
| 565 | + | assert!(!validate_url("http:///path")); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn validate_url_blocks_reserved_ranges() { | |
| 570 | + | assert!(!validate_url("http://0.1.2.3")); // 0.0.0.0/8 | |
| 571 | + | assert!(!validate_url("http://192.0.0.1")); // 192.0.0.0/24 | |
| 572 | + | assert!(!validate_url("http://198.18.0.1")); // benchmarking 198.18/15 | |
| 573 | + | assert!(!validate_url("http://198.19.255.255")); | |
| 574 | + | assert!(!validate_url("http://240.0.0.1")); // reserved 240/4 | |
| 575 | + | assert!(!validate_url("http://255.255.255.254")); | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | #[test] | |
| 579 | + | fn is_private_ip_blocks_v6_embedded_v4() { | |
| 580 | + | use std::net::{IpAddr, Ipv6Addr}; | |
| 581 | + | // v4-mapped private | |
| 582 | + | assert!(is_private_ip(IpAddr::V6("::ffff:10.0.0.1".parse::<Ipv6Addr>().unwrap()))); | |
| 583 | + | // v4-mapped public | |
| 584 | + | assert!(!is_private_ip(IpAddr::V6("::ffff:8.8.8.8".parse::<Ipv6Addr>().unwrap()))); | |
| 585 | + | // NAT64 wrapping link-local metadata | |
| 586 | + | assert!(is_private_ip(IpAddr::V6("64:ff9b::169.254.169.254".parse::<Ipv6Addr>().unwrap()))); | |
| 587 | + | } | |
| 588 | + | ||
| 503 | 589 | #[tokio::test] | |
| 504 | 590 | async fn noop_fetcher_returns_none_without_network() { | |
| 505 | 591 | let fetcher = LinkPreviewFetcher::Noop; |
| @@ -12,13 +12,13 @@ use axum::{ | |||
| 12 | 12 | }; | |
| 13 | 13 | use tower_sessions::Session; | |
| 14 | 14 | ||
| 15 | - | use crate::auth::MaybeUser; | |
| 15 | + | use crate::auth::RequireUser; | |
| 16 | 16 | use crate::csrf; | |
| 17 | 17 | use crate::templates::*; | |
| 18 | 18 | use crate::AppState; | |
| 19 | 19 | ||
| 20 | 20 | use super::{ | |
| 21 | - | field_error, render_markdown, render_markdown_plus, template_user, SignatureForm, | |
| 21 | + | db_error, field_error, render_markdown, render_markdown_plus, template_user, SignatureForm, | |
| 22 | 22 | }; | |
| 23 | 23 | ||
| 24 | 24 | const SIGNATURE_MAX: usize = 1024; | |
| @@ -27,19 +27,13 @@ const SIGNATURE_MAX: usize = 1024; | |||
| 27 | 27 | pub(super) async fn account_settings( | |
| 28 | 28 | State(state): State<AppState>, | |
| 29 | 29 | session: Session, | |
| 30 | - | MaybeUser(session_user): MaybeUser, | |
| 30 | + | RequireUser(user): RequireUser, | |
| 31 | 31 | ) -> Result<AccountSettingsTemplate, Response> { | |
| 32 | 32 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 33 | - | let user = session_user | |
| 34 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 35 | - | ||
| 36 | 33 | let (signature_markdown, signature_html) = | |
| 37 | 34 | mt_db::queries::get_user_signature(&state.db, user.user_id) | |
| 38 | 35 | .await | |
| 39 | - | .map_err(|e| { | |
| 40 | - | tracing::error!(error = ?e, "db error loading signature"); | |
| 41 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 42 | - | })? | |
| 36 | + | .map_err(db_error)? | |
| 43 | 37 | .unwrap_or((None, None)); | |
| 44 | 38 | ||
| 45 | 39 | Ok(AccountSettingsTemplate { | |
| @@ -56,12 +50,9 @@ pub(super) async fn account_settings( | |||
| 56 | 50 | #[tracing::instrument(skip_all)] | |
| 57 | 51 | pub(super) async fn update_signature_handler( | |
| 58 | 52 | State(state): State<AppState>, | |
| 59 | - | MaybeUser(session_user): MaybeUser, | |
| 53 | + | RequireUser(user): RequireUser, | |
| 60 | 54 | Form(form): Form<SignatureForm>, | |
| 61 | 55 | ) -> Result<Redirect, Response> { | |
| 62 | - | let user = session_user | |
| 63 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 64 | - | ||
| 65 | 56 | if !user.perks.effective_plus() { | |
| 66 | 57 | return Err(( | |
| 67 | 58 | StatusCode::FORBIDDEN, | |
| @@ -75,10 +66,7 @@ pub(super) async fn update_signature_handler( | |||
| 75 | 66 | if form.clear.as_deref() == Some("1") { | |
| 76 | 67 | mt_db::mutations::clear_user_signature(&state.db, user.user_id) | |
| 77 | 68 | .await | |
| 78 | - | .map_err(|e| { | |
| 79 | - | tracing::error!(error = ?e, "db error clearing signature"); | |
| 80 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 81 | - | })?; | |
| 69 | + | .map_err(db_error)?; | |
| 82 | 70 | return Ok(Redirect::to("/account?toast=Signature+cleared")); | |
| 83 | 71 | } | |
| 84 | 72 | ||
| @@ -108,10 +96,7 @@ pub(super) async fn update_signature_handler( | |||
| 108 | 96 | ||
| 109 | 97 | mt_db::mutations::set_user_signature(&state.db, user.user_id, trimmed, &signature_html) | |
| 110 | 98 | .await | |
| 111 | - | .map_err(|e| { | |
| 112 | - | tracing::error!(error = ?e, "db error saving signature"); | |
| 113 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 114 | - | })?; | |
| 99 | + | .map_err(db_error)?; | |
| 115 | 100 | ||
| 116 | 101 | Ok(Redirect::to("/account?toast=Signature+saved")) | |
| 117 | 102 | } |
| @@ -16,7 +16,7 @@ use crate::AppState; | |||
| 16 | 16 | use mt_core::types::{ModAction, ModActor}; | |
| 17 | 17 | ||
| 18 | 18 | use super::{ | |
| 19 | - | audit, begin_tx, commit_tx, get_community, parse_uuid, template_user, AdminSearchQuery, | |
| 19 | + | audit, begin_tx, commit_tx, db_error, get_community, parse_uuid, template_user, AdminSearchQuery, | |
| 20 | 20 | CleanSlateForm, SuspendForm, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| @@ -203,17 +203,11 @@ pub(super) async fn admin_community_detail( | |||
| 203 | 203 | ||
| 204 | 204 | let thread_count = mt_db::queries::count_threads_in_community(&state.db, community.id) | |
| 205 | 205 | .await | |
| 206 | - | .map_err(|e| { | |
| 207 | - | tracing::error!(error = ?e, "db error counting threads"); | |
| 208 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 209 | - | })?; | |
| 206 | + | .map_err(db_error)?; | |
| 210 | 207 | ||
| 211 | 208 | let member_count = mt_db::queries::count_community_members(&state.db, community.id) | |
| 212 | 209 | .await | |
| 213 | - | .map_err(|e| { | |
| 214 | - | tracing::error!(error = ?e, "db error counting members"); | |
| 215 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 216 | - | })?; | |
| 210 | + | .map_err(db_error)?; | |
| 217 | 211 | ||
| 218 | 212 | let suspension_reason: Option<String> = if community.suspended_at.is_some() { | |
| 219 | 213 | mt_db::queries::get_community_suspension_reason(&state.db, community.id) |
| @@ -8,7 +8,7 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use serde::Deserialize; | |
| 10 | 10 | ||
| 11 | - | use crate::auth::MaybeUser; | |
| 11 | + | use crate::auth::RequireUser; | |
| 12 | 12 | use crate::AppState; | |
| 13 | 13 | ||
| 14 | 14 | use mt_core::types::{ModAction, ModActor}; | |
| @@ -30,12 +30,9 @@ pub(super) struct FlagForm { | |||
| 30 | 30 | pub(super) async fn flag_post_handler( | |
| 31 | 31 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 32 | 32 | Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, | |
| 33 | - | MaybeUser(session_user): MaybeUser, | |
| 33 | + | RequireUser(user): RequireUser, | |
| 34 | 34 | Form(form): Form<FlagForm>, | |
| 35 | 35 | ) -> Result<Redirect, Response> { | |
| 36 | - | let user = session_user | |
| 37 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 38 | - | ||
| 39 | 36 | // Validate reason | |
| 40 | 37 | if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") { | |
| 41 | 38 | return Err(field_error("reason", "Invalid flag reason.")); | |
| @@ -110,11 +107,8 @@ pub(super) async fn flag_post_handler( | |||
| 110 | 107 | pub(super) async fn dismiss_flag_handler( | |
| 111 | 108 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 112 | 109 | Path((slug, flag_id_str)): Path<(String, String)>, | |
| 113 | - | MaybeUser(session_user): MaybeUser, | |
| 110 | + | RequireUser(user): RequireUser, | |
| 114 | 111 | ) -> Result<Redirect, Response> { | |
| 115 | - | let user = session_user | |
| 116 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 117 | - | ||
| 118 | 112 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 119 | 113 | let flag_id = parse_uuid(&flag_id_str)?; | |
| 120 | 114 | ||
| @@ -147,11 +141,8 @@ pub(super) async fn dismiss_flag_handler( | |||
| 147 | 141 | pub(super) async fn remove_flagged_post_handler( | |
| 148 | 142 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 149 | 143 | Path((slug, flag_id_str)): Path<(String, String)>, | |
| 150 | - | MaybeUser(session_user): MaybeUser, | |
| 144 | + | RequireUser(user): RequireUser, | |
| 151 | 145 | ) -> Result<Redirect, Response> { | |
| 152 | - | let user = session_user | |
| 153 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 154 | - | ||
| 155 | 146 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 156 | 147 | let flag_id = parse_uuid(&flag_id_str)?; | |
| 157 | 148 |
| @@ -8,12 +8,12 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use uuid::Uuid; | |
| 10 | 10 | ||
| 11 | - | use crate::auth::MaybeUser; | |
| 11 | + | use crate::auth::RequireUser; | |
| 12 | 12 | use crate::AppState; | |
| 13 | 13 | ||
| 14 | 14 | use super::super::{ | |
| 15 | - | check_community_access, check_user_post_rate, check_write_state, CommunityScope, WriteScope, | |
| 16 | - | validate_body, FootnoteForm, | |
| 15 | + | check_community_access, check_user_post_rate, check_write_state, db_error, CommunityScope, | |
| 16 | + | WriteScope, validate_body, FootnoteForm, | |
| 17 | 17 | }; | |
| 18 | 18 | use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST}; | |
| 19 | 19 | use mt_db::queries::PostForEdit; | |
| @@ -81,28 +81,19 @@ pub(super) fn check_endorsement_permission( | |||
| 81 | 81 | pub(in crate::routes) async fn add_footnote_handler( | |
| 82 | 82 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 83 | 83 | Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, | |
| 84 | - | MaybeUser(session_user): MaybeUser, | |
| 84 | + | RequireUser(user): RequireUser, | |
| 85 | 85 | Form(form): Form<FootnoteForm>, | |
| 86 | 86 | ) -> Result<Redirect, Response> { | |
| 87 | - | let user = session_user | |
| 88 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 89 | - | ||
| 90 | 87 | let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?; | |
| 91 | 88 | let post_id = scope.resource.id; | |
| 92 | 89 | ||
| 93 | 90 | let removed = mt_db::queries::is_post_removed(&state.db, post_id) | |
| 94 | 91 | .await | |
| 95 | - | .map_err(|e| { | |
| 96 | - | tracing::error!(error = ?e, "db error checking removal status"); | |
| 97 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 98 | - | })?; | |
| 92 | + | .map_err(db_error)?; | |
| 99 | 93 | ||
| 100 | 94 | let footnote_count = mt_db::queries::count_footnotes_for_post(&state.db, post_id) | |
| 101 | 95 | .await | |
| 102 | - | .map_err(|e| { | |
| 103 | - | tracing::error!(error = ?e, "db error counting footnotes"); | |
| 104 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 105 | - | })?; | |
| 96 | + | .map_err(db_error)?; | |
| 106 | 97 | ||
| 107 | 98 | check_footnote_permission(user.user_id, scope.resource.author_id, removed, footnote_count) | |
| 108 | 99 | .map_err(|denial| match denial { | |
| @@ -124,10 +115,7 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 124 | 115 | check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; | |
| 125 | 116 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 126 | 117 | .await | |
| 127 | - | .map_err(|e| { | |
| 128 | - | tracing::error!(error = ?e, "db error ensuring membership"); | |
| 129 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 130 | - | })?; | |
| 118 | + | .map_err(db_error)?; | |
| 131 | 119 | check_user_post_rate(&state.db, user.user_id).await?; | |
| 132 | 120 | ||
| 133 | 121 | let body = validate_body(&form.body, 65536, "Footnote")?; | |
| @@ -142,10 +130,7 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 142 | 130 | ||
| 143 | 131 | mt_db::mutations::insert_footnote(&state.db, post_id, user.user_id, body, &body_html) | |
| 144 | 132 | .await | |
| 145 | - | .map_err(|e| { | |
| 146 | - | tracing::error!(error = ?e, "db error inserting footnote"); | |
| 147 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 148 | - | })?; | |
| 133 | + | .map_err(db_error)?; | |
| 149 | 134 | ||
| 150 | 135 | Ok(Redirect::to(&format!( | |
| 151 | 136 | "/p/{slug}/{category_slug}/{thread_id_str}?toast=Footnote+added" | |
| @@ -160,20 +145,14 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 160 | 145 | pub(in crate::routes) async fn toggle_endorsement_handler( | |
| 161 | 146 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 162 | 147 | Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, | |
| 163 | - | MaybeUser(session_user): MaybeUser, | |
| 148 | + | RequireUser(user): RequireUser, | |
| 164 | 149 | ) -> Result<Redirect, Response> { | |
| 165 | - | let user = session_user | |
| 166 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 167 | - | ||
| 168 | 150 | let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?; | |
| 169 | 151 | let post_id = scope.resource.id; | |
| 170 | 152 | ||
| 171 | 153 | let removed = mt_db::queries::is_post_removed(&state.db, post_id) | |
| 172 | 154 | .await | |
| 173 | - | .map_err(|e| { | |
| 174 | - | tracing::error!(error = ?e, "db error checking removal status"); | |
| 175 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 176 | - | })?; | |
| 155 | + | .map_err(db_error)?; | |
| 177 | 156 | ||
| 178 | 157 | // Community access (suspension + ban) against the scope-verified community — | |
| 179 | 158 | // no mute check since endorsing is not content. CommunityScope already proved | |
| @@ -185,10 +164,7 @@ pub(in crate::routes) async fn toggle_endorsement_handler( | |||
| 185 | 164 | ||
| 186 | 165 | let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id) | |
| 187 | 166 | .await | |
| 188 | - | .map_err(|e| { | |
| 189 | - | tracing::error!(error = ?e, "db error checking user suspension"); | |
| 190 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 191 | - | })?; | |
| 167 | + | .map_err(db_error)?; | |
| 192 | 168 | ||
| 193 | 169 | check_endorsement_permission(user.user_id, scope.resource.author_id, removed, suspended) | |
| 194 | 170 | .map_err(|denial| match denial { | |
| @@ -202,10 +178,7 @@ pub(in crate::routes) async fn toggle_endorsement_handler( | |||
| 202 | 178 | ||
| 203 | 179 | mt_db::mutations::toggle_endorsement(&state.db, post_id, user.user_id) | |
| 204 | 180 | .await | |
| 205 | - | .map_err(|e| { | |
| 206 | - | tracing::error!(error = ?e, "db error toggling endorsement"); | |
| 207 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 208 | - | })?; | |
| 181 | + | .map_err(db_error)?; | |
| 209 | 182 | ||
| 210 | 183 | Ok(Redirect::to(&format!( | |
| 211 | 184 | "/p/{slug}/{category_slug}/{thread_id_str}#post-{post_id_str}" |
| @@ -11,14 +11,14 @@ use uuid::Uuid; | |||
| 11 | 11 | ||
| 12 | 12 | use sha2::{Sha256, Digest}; | |
| 13 | 13 | ||
| 14 | - | use crate::auth::MaybeUser; | |
| 14 | + | use crate::auth::RequireUser; | |
| 15 | 15 | use crate::AppState; | |
| 16 | 16 | ||
| 17 | 17 | use mt_core::types::ModAction; | |
| 18 | 18 | ||
| 19 | 19 | use super::super::{ | |
| 20 | 20 | audit, begin_tx, check_user_post_rate, check_write_access, check_write_state, commit_tx, | |
| 21 | - | get_community, is_mod_or_owner, parse_uuid, reject_embeds_for_free_user, | |
| 21 | + | db_error, get_community, is_mod_or_owner, parse_uuid, reject_embeds_for_free_user, | |
| 22 | 22 | render_markdown, render_markdown_plus, render_markdown_with_mentions, template_user, | |
| 23 | 23 | validate_body, validate_title, CommunityScope, WriteScope, | |
| 24 | 24 | CreateReplyForm, CreateThreadForm, | |
| @@ -120,10 +120,7 @@ pub(super) async fn verify_quotes( | |||
| 120 | 120 | ||
| 121 | 121 | let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id) | |
| 122 | 122 | .await | |
| 123 | - | .map_err(|e| { | |
| 124 | - | tracing::error!(error = ?e, "db error fetching post for quote verification"); | |
| 125 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 126 | - | })? | |
| 123 | + | .map_err(db_error)? | |
| 127 | 124 | .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?; | |
| 128 | 125 | ||
| 129 | 126 | if !original_markdown.contains("ed_text) { | |
| @@ -168,10 +165,7 @@ pub(super) async fn resolve_and_render_mentions( | |||
| 168 | 165 | ||
| 169 | 166 | let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames) | |
| 170 | 167 | .await | |
| 171 | - | .map_err(|e| { | |
| 172 | - | tracing::error!(error = ?e, "db error resolving mention usernames"); | |
| 173 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 174 | - | })?; | |
| 168 | + | .map_err(db_error)?; | |
| 175 | 169 | ||
| 176 | 170 | let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect(); | |
| 177 | 171 | let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images); | |
| @@ -243,22 +237,16 @@ async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uu | |||
| 243 | 237 | pub(in crate::routes) async fn create_thread_handler( | |
| 244 | 238 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 245 | 239 | Path((slug, category_slug)): Path<(String, String)>, | |
| 246 | - | MaybeUser(session_user): MaybeUser, | |
| 240 | + | RequireUser(user): RequireUser, | |
| 247 | 241 | Form(form): Form<CreateThreadForm>, | |
| 248 | 242 | ) -> Result<Redirect, Response> { | |
| 249 | - | let user = session_user | |
| 250 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 251 | - | ||
| 252 | 243 | let community = get_community(&state.db, &slug).await?; | |
| 253 | 244 | ||
| 254 | 245 | check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; | |
| 255 | 246 | check_write_state(&state, &community, &user, WriteScope::NewThread).await?; | |
| 256 | 247 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 257 | 248 | .await | |
| 258 | - | .map_err(|e| { | |
| 259 | - | tracing::error!(error = ?e, "db error ensuring membership"); | |
| 260 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 261 | - | })?; | |
| 249 | + | .map_err(db_error)?; | |
| 262 | 250 | check_user_post_rate(&state.db, user.user_id).await?; | |
| 263 | 251 | ||
| 264 | 252 | let title = validate_title(&form.title)?; | |
| @@ -270,10 +258,7 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 270 | 258 | ||
| 271 | 259 | let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug) | |
| 272 | 260 | .await | |
| 273 | - | .map_err(|e| { | |
| 274 | - | tracing::error!(error = ?e, "db error looking up category"); | |
| 275 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 276 | - | })? | |
| 261 | + | .map_err(db_error)? | |
| 277 | 262 | .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; | |
| 278 | 263 | ||
| 279 | 264 | verify_quotes(&state.db, body).await?; | |
| @@ -297,23 +282,14 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 297 | 282 | &mut tx, category_id, user.user_id, title, body, &body_html, | |
| 298 | 283 | ) | |
| 299 | 284 | .await | |
| 300 | - | .map_err(|e| { | |
| 301 | - | tracing::error!(error = ?e, "db error creating thread"); | |
| 302 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 303 | - | })?; | |
| 285 | + | .map_err(db_error)?; | |
| 304 | 286 | mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) | |
| 305 | 287 | .await | |
| 306 | - | .map_err(|e| { | |
| 307 | - | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 308 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 309 | - | })?; | |
| 288 | + | .map_err(db_error)?; | |
| 310 | 289 | if !tag_ids.is_empty() { | |
| 311 | 290 | mt_db::mutations::set_thread_tags_tx(&mut tx, thread_id, &tag_ids) | |
| 312 | 291 | .await | |
| 313 | - | .map_err(|e| { | |
| 314 | - | tracing::error!(error = ?e, "db error setting thread tags"); | |
| 315 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 316 | - | })?; | |
| 292 | + | .map_err(db_error)?; | |
| 317 | 293 | } | |
| 318 | 294 | commit_tx(tx).await?; | |
| 319 | 295 | ||
| @@ -329,12 +305,9 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 329 | 305 | pub(in crate::routes) async fn create_reply_handler( | |
| 330 | 306 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 331 | 307 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 332 | - | MaybeUser(session_user): MaybeUser, | |
| 308 | + | RequireUser(user): RequireUser, | |
| 333 | 309 | Form(form): Form<CreateReplyForm>, | |
| 334 | 310 | ) -> Result<Redirect, Response> { | |
| 335 | - | let user = session_user | |
| 336 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 337 | - | ||
| 338 | 311 | let scope = | |
| 339 | 312 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 340 | 313 | scope.require_write_access(&state.db, user.user_id).await?; | |
| @@ -343,10 +316,7 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 343 | 316 | check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; | |
| 344 | 317 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 345 | 318 | .await | |
| 346 | - | .map_err(|e| { | |
| 347 | - | tracing::error!(error = ?e, "db error ensuring membership"); | |
| 348 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 349 | - | })?; | |
| 319 | + | .map_err(db_error)?; | |
| 350 | 320 | check_user_post_rate(&state.db, user.user_id).await?; | |
| 351 | 321 | ||
| 352 | 322 | if thread_data.locked { | |
| @@ -372,16 +342,10 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 372 | 342 | let mut tx = begin_tx(&state.db).await?; | |
| 373 | 343 | let post_id = mt_db::mutations::create_post_tx(&mut tx, thread_id, user.user_id, body, &body_html) | |
| 374 | 344 | .await | |
| 375 | - | .map_err(|e| { | |
| 376 | - | tracing::error!(error = ?e, "db error creating reply"); | |
| 377 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 378 | - | })?; | |
| 345 | + | .map_err(db_error)?; | |
| 379 | 346 | mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) | |
| 380 | 347 | .await | |
| 381 | - | .map_err(|e| { | |
| 382 | - | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 383 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 384 | - | })?; | |
| 348 | + | .map_err(db_error)?; | |
| 385 | 349 | commit_tx(tx).await?; | |
| 386 | 350 | ||
| 387 | 351 | // Fetch link previews in background (best-effort, never blocks response) | |
| @@ -401,12 +365,9 @@ pub(in crate::routes) async fn edit_thread_form( | |||
| 401 | 365 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 402 | 366 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 403 | 367 | session: tower_sessions::Session, | |
| 404 | - | MaybeUser(session_user): MaybeUser, | |
| 368 | + | RequireUser(user): RequireUser, | |
| 405 | 369 | ) -> Result<impl IntoResponse, Response> { | |
| 406 | 370 | let csrf_token = Some(crate::csrf::get_or_create_token(&session).await); | |
| 407 | - | let user = session_user | |
| 408 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 409 | - | ||
| 410 | 371 | let scope = | |
| 411 | 372 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 412 | 373 | scope.require_write_access(&state.db, user.user_id).await?; | |
| @@ -433,12 +394,9 @@ pub(in crate::routes) async fn edit_thread_form( | |||
| 433 | 394 | pub(in crate::routes) async fn edit_thread_handler( | |
| 434 | 395 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 435 | 396 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 436 | - | MaybeUser(session_user): MaybeUser, | |
| 397 | + | RequireUser(user): RequireUser, | |
| 437 | 398 | Form(form): Form<super::super::EditThreadForm>, | |
| 438 | 399 | ) -> Result<Redirect, Response> { | |
| 439 | - | let user = session_user | |
| 440 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 441 | - | ||
| 442 | 400 | let scope = | |
| 443 | 401 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 444 | 402 | scope.require_write_access(&state.db, user.user_id).await?; | |
| @@ -452,10 +410,7 @@ pub(in crate::routes) async fn edit_thread_handler( | |||
| 452 | 410 | let thread_id = parse_uuid(&thread_id_str)?; | |
| 453 | 411 | mt_db::mutations::update_thread_title(&state.db, thread_id, title) | |
| 454 | 412 | .await | |
| 455 | - | .map_err(|e| { | |
| 456 | - | tracing::error!(error = ?e, "db error updating thread title"); | |
| 457 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 458 | - | })?; | |
| 413 | + | .map_err(db_error)?; | |
| 459 | 414 | ||
| 460 | 415 | Ok(Redirect::to(&format!( | |
| 461 | 416 | "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated" | |
| @@ -466,11 +421,8 @@ pub(in crate::routes) async fn edit_thread_handler( | |||
| 466 | 421 | pub(in crate::routes) async fn delete_thread_handler( | |
| 467 | 422 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 468 | 423 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 469 | - | MaybeUser(session_user): MaybeUser, | |
| 424 | + | RequireUser(user): RequireUser, | |
| 470 | 425 | ) -> Result<Redirect, Response> { | |
| 471 | - | let user = session_user | |
| 472 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 473 | - | ||
| 474 | 426 | let scope = | |
| 475 | 427 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 476 | 428 | scope.require_write_access(&state.db, user.user_id).await?; | |
| @@ -484,10 +436,7 @@ pub(in crate::routes) async fn delete_thread_handler( | |||
| 484 | 436 | let mut tx = begin_tx(&state.db).await?; | |
| 485 | 437 | mt_db::mutations::soft_delete_thread(&mut *tx, thread_id) | |
| 486 | 438 | .await | |
| 487 | - | .map_err(|e| { | |
| 488 | - | tracing::error!(error = ?e, "db error deleting thread"); | |
| 489 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 490 | - | })?; | |
| 439 | + | .map_err(db_error)?; | |
| 491 | 440 | audit( | |
| 492 | 441 | &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), | |
| 493 | 442 | ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None, |
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, Query}, | |
| 5 | - | http::StatusCode, | |
| 6 | 5 | response::{IntoResponse, Response}, | |
| 7 | 6 | }; | |
| 8 | 7 | use tower_sessions::Session; | |
| @@ -15,7 +14,8 @@ use crate::AppState; | |||
| 15 | 14 | use std::collections::HashMap; | |
| 16 | 15 | ||
| 17 | 16 | use super::super::{ | |
| 18 | - | check_community_access, is_mod_or_owner, parse_uuid, template_user, CommunityScope, PageQuery, | |
| 17 | + | check_community_access, db_error, is_mod_or_owner, parse_uuid, template_user, CommunityScope, | |
| 18 | + | PageQuery, | |
| 19 | 19 | }; | |
| 20 | 20 | use mt_db::queries::ThreadWithBreadcrumb; | |
| 21 | 21 | ||
| @@ -46,10 +46,7 @@ pub(in crate::routes) async fn thread( | |||
| 46 | 46 | let thread_uuid = parse_uuid(&thread_id)?; | |
| 47 | 47 | let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid) | |
| 48 | 48 | .await | |
| 49 | - | .map_err(|e| { | |
| 50 | - | tracing::error!(error = ?e, "db error counting posts"); | |
| 51 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 52 | - | })?; | |
| 49 | + | .map_err(db_error)?; | |
| 53 | 50 | ||
| 54 | 51 | let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); | |
| 55 | 52 | let offset = pagination.offset(per_page); | |
| @@ -58,10 +55,7 @@ pub(in crate::routes) async fn thread( | |||
| 58 | 55 | &state.db, thread_uuid, per_page, offset, | |
| 59 | 56 | ) | |
| 60 | 57 | .await | |
| 61 | - | .map_err(|e| { | |
| 62 | - | tracing::error!(error = ?e, "db error listing posts"); | |
| 63 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 64 | - | })?; | |
| 58 | + | .map_err(db_error)?; | |
| 65 | 59 | ||
| 66 | 60 | let mod_status = is_mod_or_owner(&role); | |
| 67 | 61 | ||
| @@ -82,26 +76,17 @@ pub(in crate::routes) async fn thread( | |||
| 82 | 76 | async { | |
| 83 | 77 | mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids) | |
| 84 | 78 | .await | |
| 85 | - | .map_err(|e| { | |
| 86 | - | tracing::error!(error = ?e, "db error fetching footnotes"); | |
| 87 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 88 | - | }) | |
| 79 | + | .map_err(db_error) | |
| 89 | 80 | }, | |
| 90 | 81 | async { | |
| 91 | 82 | mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids) | |
| 92 | 83 | .await | |
| 93 | - | .map_err(|e| { | |
| 94 | - | tracing::error!(error = ?e, "db error fetching endorsements"); | |
| 95 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 96 | - | }) | |
| 84 | + | .map_err(db_error) | |
| 97 | 85 | }, | |
| 98 | 86 | async { | |
| 99 | 87 | mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids) | |
| 100 | 88 | .await | |
| 101 | - | .map_err(|e| { | |
| 102 | - | tracing::error!(error = ?e, "db error fetching link previews"); | |
| 103 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 104 | - | }) | |
| 89 | + | .map_err(db_error) | |
| 105 | 90 | }, | |
| 106 | 91 | )?; | |
| 107 | 92 |
| @@ -18,7 +18,7 @@ use std::collections::HashMap; | |||
| 18 | 18 | use mt_core::types::{SortColumn, SortOrder}; | |
| 19 | 19 | ||
| 20 | 20 | use super::super::{ | |
| 21 | - | check_community_access, get_community, get_role, is_mod_or_owner, is_owner, | |
| 21 | + | check_community_access, db_error, get_community, get_role, is_mod_or_owner, is_owner, | |
| 22 | 22 | parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery, PageQuery, | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| @@ -42,6 +42,9 @@ pub(in crate::routes) async fn forum_directory( | |||
| 42 | 42 | } else { | |
| 43 | 43 | mt_db::queries::count_communities(&state.db).await | |
| 44 | 44 | } | |
| 45 | + | .inspect_err( | |
| 46 | + | |e| tracing::error!(error = ?e, viewing_archived, "db error counting communities for directory"), | |
| 47 | + | ) | |
| 45 | 48 | .unwrap_or(0); | |
| 46 | 49 | let mut pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page); | |
| 47 | 50 | // Keep the archived filter across page links (else page 2+ reverts to the | |
| @@ -57,6 +60,9 @@ pub(in crate::routes) async fn forum_directory( | |||
| 57 | 60 | mt_db::queries::list_communities(&state.db, per_page, offset).await | |
| 58 | 61 | }; | |
| 59 | 62 | let communities = rows | |
| 63 | + | .inspect_err( | |
| 64 | + | |e| tracing::error!(error = ?e, viewing_archived, "db error listing communities for directory"), | |
| 65 | + | ) | |
| 60 | 66 | .unwrap_or_default() | |
| 61 | 67 | .into_iter() | |
| 62 | 68 | .map(|c| CommunityDirectoryRow { | |
| @@ -95,10 +101,7 @@ pub(in crate::routes) async fn project_forum( | |||
| 95 | 101 | ||
| 96 | 102 | let db_categories = mt_db::queries::list_categories_with_counts(&state.db, &slug) | |
| 97 | 103 | .await | |
| 98 | - | .map_err(|e| { | |
| 99 | - | tracing::error!(error = ?e, "db error listing categories"); | |
| 100 | - | crate::error_page::internal_error() | |
| 101 | - | })?; | |
| 104 | + | .map_err(db_error)?; | |
| 102 | 105 | ||
| 103 | 106 | let role = if let Some(ref user) = session_user { | |
| 104 | 107 | get_role(&state.db, user.user_id, community.id).await? | |
| @@ -149,19 +152,13 @@ pub(in crate::routes) async fn community_members( | |||
| 149 | 152 | let per_page: i64 = 100; | |
| 150 | 153 | let total = mt_db::queries::count_community_members(&state.db, community.id) | |
| 151 | 154 | .await | |
| 152 | - | .map_err(|e| { | |
| 153 | - | tracing::error!(error = ?e, "db error counting members"); | |
| 154 | - | crate::error_page::internal_error() | |
| 155 | - | })?; | |
| 155 | + | .map_err(db_error)?; | |
| 156 | 156 | let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); | |
| 157 | 157 | let offset = pagination.offset(per_page); | |
| 158 | 158 | ||
| 159 | 159 | let db_members = mt_db::queries::list_community_members(&state.db, community.id, per_page, offset) | |
| 160 | 160 | .await | |
| 161 | - | .map_err(|e| { | |
| 162 | - | tracing::error!(error = ?e, "db error listing members"); | |
| 163 | - | crate::error_page::internal_error() | |
| 164 | - | })?; | |
| 161 | + | .map_err(db_error)?; | |
| 165 | 162 | ||
| 166 | 163 | let members = db_members | |
| 167 | 164 | .into_iter() | |
| @@ -211,10 +208,7 @@ pub(in crate::routes) async fn category( | |||
| 211 | 208 | async { | |
| 212 | 209 | mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug) | |
| 213 | 210 | .await | |
| 214 | - | .map_err(|e| { | |
| 215 | - | tracing::error!(error = ?e, "db error fetching category"); | |
| 216 | - | crate::error_page::internal_error() | |
| 217 | - | })? | |
| 211 | + | .map_err(db_error)? | |
| 218 | 212 | .ok_or_else(crate::error_page::not_found) | |
| 219 | 213 | }, | |
| 220 | 214 | async { | |
| @@ -222,26 +216,30 @@ pub(in crate::routes) async fn category( | |||
| 222 | 216 | &state.db, &slug, &category_slug, tag_filter, | |
| 223 | 217 | ) | |
| 224 | 218 | .await | |
| 225 | - | .map_err(|e| { | |
| 226 | - | tracing::error!(error = ?e, "db error counting threads"); | |
| 227 | - | crate::error_page::internal_error() | |
| 228 | - | }) | |
| 219 | + | .map_err(db_error) | |
| 229 | 220 | }, | |
| 230 | 221 | )?; | |
| 231 | 222 | ||
| 232 | 223 | check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; | |
| 233 | 224 | ||
| 234 | - | let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page); | |
| 225 | + | // Carry the sort/order (and optional tag) across page links so page 2+ keeps | |
| 226 | + | // the current view. Feeding this into the shared pagination partial via | |
| 227 | + | // query_suffix means category.html no longer hand-rolls its own pagination | |
| 228 | + | // markup (which had drifted from partials/pagination.html). sort/order are | |
| 229 | + | // enum-derived and tag is a validated slug, so the suffix is URL-safe. | |
| 230 | + | let mut page_suffix = format!("&sort={}&order={}", sort.as_str(), order.as_str()); | |
| 231 | + | if let Some(tag) = tag_filter { | |
| 232 | + | page_suffix.push_str(&format!("&tag={tag}")); | |
| 233 | + | } | |
| 234 | + | let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page) | |
| 235 | + | .with_query_suffix(&page_suffix); | |
| 235 | 236 | let offset = pagination.offset(per_page); | |
| 236 | 237 | ||
| 237 | 238 | let db_threads = mt_db::queries::list_threads_in_category_sorted( | |
| 238 | 239 | &state.db, &slug, &category_slug, sort, order, per_page, offset, tag_filter, | |
| 239 | 240 | ) | |
| 240 | 241 | .await | |
| 241 | - | .map_err(|e| { | |
| 242 | - | tracing::error!(error = ?e, "db error listing threads"); | |
| 243 | - | crate::error_page::internal_error() | |
| 244 | - | })?; | |
| 242 | + | .map_err(db_error)?; | |
| 245 | 243 | ||
| 246 | 244 | // Per-thread tags, the user's mention status, and the community's tag list | |
| 247 | 245 | // are all independent given the thread ids (and community id) — fetch them | |
| @@ -252,10 +250,7 @@ pub(in crate::routes) async fn category( | |||
| 252 | 250 | async { | |
| 253 | 251 | mt_db::queries::list_tags_for_threads(&state.db, &thread_ids) | |
| 254 | 252 | .await | |
| 255 | - | .map_err(|e| { | |
| 256 | - | tracing::error!(error = ?e, "db error fetching thread tags"); | |
| 257 | - | crate::error_page::internal_error() | |
| 258 | - | }) | |
| 253 | + | .map_err(db_error) | |
| 259 | 254 | }, | |
| 260 | 255 | async { | |
| 261 | 256 | // Mention status for the logged-in user; a query error degrades to | |
| @@ -276,10 +271,7 @@ pub(in crate::routes) async fn category( | |||
| 276 | 271 | async { | |
| 277 | 272 | mt_db::queries::list_tags_for_community(&state.db, community.id) | |
| 278 | 273 | .await | |
| 279 | - | .map_err(|e| { | |
| 280 | - | tracing::error!(error = ?e, "db error listing tags"); | |
| 281 | - | crate::error_page::internal_error() | |
| 282 | - | }) | |
| 274 | + | .map_err(db_error) | |
| 283 | 275 | }, | |
| 284 | 276 | )?; | |
| 285 | 277 | ||
| @@ -351,18 +343,12 @@ pub(in crate::routes) async fn new_thread( | |||
| 351 | 343 | ||
| 352 | 344 | let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug) | |
| 353 | 345 | .await | |
| 354 | - | .map_err(|e| { | |
| 355 | - | tracing::error!(error = ?e, "db error fetching category"); | |
| 356 | - | crate::error_page::internal_error() | |
| 357 | - | })? | |
| 346 | + | .map_err(db_error)? | |
| 358 | 347 | .ok_or_else(crate::error_page::not_found)?; | |
| 359 | 348 | ||
| 360 | 349 | let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) | |
| 361 | 350 | .await | |
| 362 | - | .map_err(|e| { | |
| 363 | - | tracing::error!(error = ?e, "db error listing tags"); | |
| 364 | - | crate::error_page::internal_error() | |
| 365 | - | })?; | |
| 351 | + | .map_err(db_error)?; | |
| 366 | 352 | let available_tags = db_tags | |
| 367 | 353 | .into_iter() | |
| 368 | 354 | .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug }) | |
| @@ -397,20 +383,14 @@ pub(in crate::routes) async fn user_profile( | |||
| 397 | 383 | ||
| 398 | 384 | let profile = mt_db::queries::get_user_profile_in_community(&state.db, &slug, &username) | |
| 399 | 385 | .await | |
| 400 | - | .map_err(|e| { | |
| 401 | - | tracing::error!(error = ?e, "db error fetching user profile"); | |
| 402 | - | crate::error_page::internal_error() | |
| 403 | - | })? | |
| 386 | + | .map_err(db_error)? | |
| 404 | 387 | .ok_or_else(crate::error_page::not_found)?; | |
| 405 | 388 | ||
| 406 | 389 | let activity = mt_db::queries::get_user_activity_in_community( | |
| 407 | 390 | &state.db, community.id, profile.user_id, 20, | |
| 408 | 391 | ) | |
| 409 | 392 | .await | |
| 410 | - | .map_err(|e| { | |
| 411 | - | tracing::error!(error = ?e, "db error fetching user activity"); | |
| 412 | - | crate::error_page::internal_error() | |
| 413 | - | })?; | |
| 393 | + | .map_err(db_error)?; | |
| 414 | 394 | ||
| 415 | 395 | let activity_rows = activity | |
| 416 | 396 | .into_iter() | |
| @@ -461,10 +441,7 @@ pub(in crate::routes) async fn user_summary_api( | |||
| 461 | 441 | ||
| 462 | 442 | let memberships = mt_db::queries::get_user_membership_summary(&state.db, user_id) | |
| 463 | 443 | .await | |
| 464 | - | .map_err(|e| { | |
| 465 | - | tracing::error!(error = ?e, "db error fetching membership summary"); | |
| 466 | - | crate::error_page::internal_error() | |
| 467 | - | })?; | |
| 444 | + | .map_err(db_error)?; | |
| 468 | 445 | ||
| 469 | 446 | Ok(Json(serde_json::json!({ "memberships": memberships }))) | |
| 470 | 447 | } |
| @@ -37,6 +37,17 @@ pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60; | |||
| 37 | 37 | // Common DB / transaction / context helpers | |
| 38 | 38 | // ============================================================================ | |
| 39 | 39 | ||
| 40 | + | /// Log a DB error and map it to a branded 500 page. | |
| 41 | + | /// | |
| 42 | + | /// The one-liner `.map_err(db_error)?` most page/write handlers want, replacing | |
| 43 | + | /// the open-coded `|e| { tracing::error!(error = ?e, "…"); internal_error() }` | |
| 44 | + | /// closure. For the internal HMAC API, keep that module's local `db_error` | |
| 45 | + | /// (which returns a plain 500, not an HTML page). | |
| 46 | + | pub(crate) fn db_error(e: sqlx::Error) -> Response { | |
| 47 | + | tracing::error!(error = ?e, "db error"); | |
| 48 | + | crate::error_page::internal_error() | |
| 49 | + | } | |
| 50 | + | ||
| 40 | 51 | /// Fetch community by slug, returning 404/500 on failure. | |
| 41 | 52 | #[tracing::instrument(skip_all)] | |
| 42 | 53 | pub(crate) async fn get_community( |
| @@ -8,7 +8,7 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use tower_sessions::Session; | |
| 10 | 10 | ||
| 11 | - | use crate::auth::MaybeUser; | |
| 11 | + | use crate::auth::RequireUser; | |
| 12 | 12 | use crate::csrf; | |
| 13 | 13 | use crate::templates::*; | |
| 14 | 14 | use crate::AppState; | |
| @@ -16,7 +16,7 @@ use crate::AppState; | |||
| 16 | 16 | use mt_core::types::{BanType, ModAction}; | |
| 17 | 17 | ||
| 18 | 18 | use super::{ | |
| 19 | - | audit, begin_tx, commit_tx, field_error, get_role, get_user_by_username, is_mod_or_owner, | |
| 19 | + | audit, begin_tx, commit_tx, db_error, field_error, get_role, get_user_by_username, is_mod_or_owner, | |
| 20 | 20 | is_owner, parse_duration, parse_uuid, require_mod_or_owner, template_user, CommunityScope, | |
| 21 | 21 | BanForm, PageQuery, UnbanForm, | |
| 22 | 22 | }; | |
| @@ -27,11 +27,8 @@ use mt_db::queries::{PostForEdit, ThreadWithBreadcrumb}; | |||
| 27 | 27 | pub(super) async fn pin_thread_handler( | |
| 28 | 28 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 29 | 29 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 30 | - | MaybeUser(session_user): MaybeUser, | |
| 30 | + | RequireUser(user): RequireUser, | |
| 31 | 31 | ) -> Result<Redirect, Response> { | |
| 32 | - | let user = session_user | |
| 33 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 34 | - | ||
| 35 | 32 | let scope = | |
| 36 | 33 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 37 | 34 | let role = scope.role(&state.db, user.user_id).await?; | |
| @@ -47,10 +44,7 @@ pub(super) async fn pin_thread_handler( | |||
| 47 | 44 | let mut tx = begin_tx(&state.db).await?; | |
| 48 | 45 | mt_db::mutations::set_thread_pinned(&mut *tx, thread_data.id, new_pinned) | |
| 49 | 46 | .await | |
| 50 | - | .map_err(|e| { | |
| 51 | - | tracing::error!(error = ?e, "db error toggling pin"); | |
| 52 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 53 | - | })?; | |
| 47 | + | .map_err(db_error)?; | |
| 54 | 48 | audit( | |
| 55 | 49 | &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), | |
| 56 | 50 | action, None, Some(thread_data.id), None, | |
| @@ -67,11 +61,8 @@ pub(super) async fn pin_thread_handler( | |||
| 67 | 61 | pub(super) async fn lock_thread_handler( | |
| 68 | 62 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 69 | 63 | Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, | |
| 70 | - | MaybeUser(session_user): MaybeUser, | |
| 64 | + | RequireUser(user): RequireUser, | |
| 71 | 65 | ) -> Result<Redirect, Response> { | |
| 72 | - | let user = session_user | |
| 73 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 74 | - | ||
| 75 | 66 | let scope = | |
| 76 | 67 | CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?; | |
| 77 | 68 | let role = scope.role(&state.db, user.user_id).await?; | |
| @@ -87,10 +78,7 @@ pub(super) async fn lock_thread_handler( | |||
| 87 | 78 | let mut tx = begin_tx(&state.db).await?; | |
| 88 | 79 | mt_db::mutations::set_thread_locked(&mut *tx, thread_data.id, new_locked) | |
| 89 | 80 | .await | |
| 90 | - | .map_err(|e| { | |
| 91 | - | tracing::error!(error = ?e, "db error toggling lock"); | |
| 92 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 93 | - | })?; | |
| 81 | + | .map_err(db_error)?; | |
| 94 | 82 | audit( | |
| 95 | 83 | &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), | |
| 96 | 84 | action, None, Some(thread_data.id), None, | |
| @@ -111,11 +99,8 @@ pub(super) async fn lock_thread_handler( | |||
| 111 | 99 | pub(super) async fn mod_remove_post_handler( | |
| 112 | 100 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 113 | 101 | Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, | |
| 114 | - | MaybeUser(session_user): MaybeUser, | |
| 102 | + | RequireUser(user): RequireUser, | |
| 115 | 103 | ) -> Result<Redirect, Response> { | |
| 116 | - | let user = session_user | |
| 117 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 118 | - | ||
| 119 | 104 | let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?; | |
| 120 | 105 | let role = scope.role(&state.db, user.user_id).await?; | |
| 121 | 106 | ||
| @@ -132,10 +117,7 @@ pub(super) async fn mod_remove_post_handler( | |||
| 132 | 117 | let mut tx = begin_tx(&state.db).await?; | |
| 133 | 118 | let removal = mt_db::mutations::mod_remove_post_cascade(&mut tx, post_id, user.user_id) | |
| 134 | 119 | .await | |
| 135 | - | .map_err(|e| { | |
| 136 | - | tracing::error!(error = ?e, "db error removing post"); | |
| 137 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 138 | - | })?; | |
| 120 | + | .map_err(db_error)?; | |
| 139 | 121 | ||
| 140 | 122 | audit( | |
| 141 | 123 | &mut tx, Some(post_data.community_id), ModActor::User(user.user_id), | |
| @@ -173,12 +155,9 @@ pub(super) async fn moderation_page( | |||
| 173 | 155 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 174 | 156 | Path(slug): Path<String>, | |
| 175 | 157 | session: Session, | |
| 176 | - | MaybeUser(session_user): MaybeUser, | |
| 158 | + | RequireUser(user): RequireUser, | |
| 177 | 159 | ) -> Result<impl IntoResponse, Response> { | |
| 178 | 160 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 179 | - | let user = session_user | |
| 180 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 181 | - | ||
| 182 | 161 | let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 183 | 162 | ||
| 184 | 163 | if community.suspended_at.is_some() { | |
| @@ -197,10 +176,7 @@ pub(super) async fn moderation_page( | |||
| 197 | 176 | ||
| 198 | 177 | let mut db_bans = mt_db::queries::list_community_bans(&state.db, community.id, MOD_LIST_CAP as i64 + 1) | |
| 199 | 178 | .await | |
| 200 | - | .map_err(|e| { | |
| 201 | - | tracing::error!(error = ?e, "db error listing bans"); | |
| 202 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 203 | - | })?; | |
| 179 | + | .map_err(db_error)?; | |
| 204 | 180 | let bans_truncated = db_bans.len() > MOD_LIST_CAP; | |
| 205 | 181 | db_bans.truncate(MOD_LIST_CAP); | |
| 206 | 182 | ||
| @@ -219,10 +195,7 @@ pub(super) async fn moderation_page( | |||
| 219 | 195 | ||
| 220 | 196 | let mut db_flags = mt_db::queries::list_pending_flags(&state.db, community.id, MOD_LIST_CAP as i64 + 1) | |
| 221 | 197 | .await | |
| 222 | - | .map_err(|e| { | |
| 223 | - | tracing::error!(error = ?e, "db error listing flags"); | |
| 224 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 225 | - | })?; | |
| 198 | + | .map_err(db_error)?; | |
| 226 | 199 | let flags_truncated = db_flags.len() > MOD_LIST_CAP; | |
| 227 | 200 | db_flags.truncate(MOD_LIST_CAP); | |
| 228 | 201 | ||
| @@ -259,12 +232,9 @@ pub(super) async fn moderation_page( | |||
| 259 | 232 | pub(super) async fn ban_user_handler( | |
| 260 | 233 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 261 | 234 | Path(slug): Path<String>, | |
| 262 | - | MaybeUser(session_user): MaybeUser, | |
| 235 | + | RequireUser(user): RequireUser, | |
| 263 | 236 | Form(form): Form<BanForm>, | |
| 264 | 237 | ) -> Result<Redirect, Response> { | |
| 265 | - | let user = session_user | |
| 266 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 267 | - | ||
| 268 | 238 | let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 269 | 239 | ||
| 270 | 240 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| @@ -296,10 +266,7 @@ pub(super) async fn ban_user_handler( | |||
| 296 | 266 | BanType::Ban, reason, expires_at, | |
| 297 | 267 | ) | |
| 298 | 268 | .await | |
| 299 | - | .map_err(|e| { | |
| 300 | - | tracing::error!(error = ?e, "db error creating ban"); | |
| 301 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 302 | - | })?; | |
| 269 | + | .map_err(db_error)?; | |
| 303 | 270 | audit( | |
| 304 | 271 | &mut tx, Some(community.id), ModActor::User(user.user_id), | |
| 305 | 272 | ModAction::Ban, Some(target_id), None, reason, | |
| @@ -315,12 +282,9 @@ pub(super) async fn ban_user_handler( | |||
| 315 | 282 | pub(super) async fn unban_user_handler( | |
| 316 | 283 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 317 | 284 | Path(slug): Path<String>, | |
| 318 | - | MaybeUser(session_user): MaybeUser, | |
| 285 | + | RequireUser(user): RequireUser, | |
| 319 | 286 | Form(form): Form<UnbanForm>, | |
| 320 | 287 | ) -> Result<Redirect, Response> { | |
| 321 | - | let user = session_user | |
| 322 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 323 | - | ||
| 324 | 288 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 325 | 289 | ||
| 326 | 290 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| @@ -328,10 +292,7 @@ pub(super) async fn unban_user_handler( | |||
| 328 | 292 | let mut tx = begin_tx(&state.db).await?; | |
| 329 | 293 | mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Ban) | |
| 330 | 294 | .await | |
| 331 | - | .map_err(|e| { | |
| 332 | - | tracing::error!(error = ?e, "db error removing ban"); | |
| 333 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 334 | - | })?; | |
| 295 | + | .map_err(db_error)?; | |
| 335 | 296 | audit( | |
| 336 | 297 | &mut tx, Some(community.id), ModActor::User(user.user_id), | |
| 337 | 298 | ModAction::Unban, Some(target_id), None, None, | |
| @@ -347,12 +308,9 @@ pub(super) async fn unban_user_handler( | |||
| 347 | 308 | pub(super) async fn mute_user_handler( | |
| 348 | 309 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 349 | 310 | Path(slug): Path<String>, | |
| 350 | - | MaybeUser(session_user): MaybeUser, | |
| 311 | + | RequireUser(user): RequireUser, | |
| 351 | 312 | Form(form): Form<BanForm>, | |
| 352 | 313 | ) -> Result<Redirect, Response> { | |
| 353 | - | let user = session_user | |
| 354 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 355 | - | ||
| 356 | 314 | let (community, role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 357 | 315 | ||
| 358 | 316 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| @@ -383,10 +341,7 @@ pub(super) async fn mute_user_handler( | |||
| 383 | 341 | BanType::Mute, reason, expires_at, | |
| 384 | 342 | ) | |
| 385 | 343 | .await | |
| 386 | - | .map_err(|e| { | |
| 387 | - | tracing::error!(error = ?e, "db error creating mute"); | |
| 388 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 389 | - | })?; | |
| 344 | + | .map_err(db_error)?; | |
| 390 | 345 | audit( | |
| 391 | 346 | &mut tx, Some(community.id), ModActor::User(user.user_id), | |
| 392 | 347 | ModAction::Mute, Some(target_id), None, reason, | |
| @@ -402,12 +357,9 @@ pub(super) async fn mute_user_handler( | |||
| 402 | 357 | pub(super) async fn unmute_user_handler( | |
| 403 | 358 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 404 | 359 | Path(slug): Path<String>, | |
| 405 | - | MaybeUser(session_user): MaybeUser, | |
| 360 | + | RequireUser(user): RequireUser, | |
| 406 | 361 | Form(form): Form<UnbanForm>, | |
| 407 | 362 | ) -> Result<Redirect, Response> { | |
| 408 | - | let user = session_user | |
| 409 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 410 | - | ||
| 411 | 363 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 412 | 364 | ||
| 413 | 365 | let target_id = get_user_by_username(&state.db, form.username.trim()).await?; | |
| @@ -415,10 +367,7 @@ pub(super) async fn unmute_user_handler( | |||
| 415 | 367 | let mut tx = begin_tx(&state.db).await?; | |
| 416 | 368 | mt_db::mutations::remove_community_ban(&mut *tx, community.id, target_id, BanType::Mute) | |
| 417 | 369 | .await | |
| 418 | - | .map_err(|e| { | |
| 419 | - | tracing::error!(error = ?e, "db error removing mute"); | |
| 420 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 421 | - | })?; | |
| 370 | + | .map_err(db_error)?; | |
| 422 | 371 | audit( | |
| 423 | 372 | &mut tx, Some(community.id), ModActor::User(user.user_id), | |
| 424 | 373 | ModAction::Unmute, Some(target_id), None, None, | |
| @@ -436,31 +385,22 @@ pub(super) async fn mod_log_page( | |||
| 436 | 385 | Path(slug): Path<String>, | |
| 437 | 386 | Query(page_query): Query<PageQuery>, | |
| 438 | 387 | session: Session, | |
| 439 | - | MaybeUser(session_user): MaybeUser, | |
| 388 | + | RequireUser(user): RequireUser, | |
| 440 | 389 | ) -> Result<impl IntoResponse, Response> { | |
| 441 | 390 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 442 | - | let user = session_user | |
| 443 | - | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 444 | - | ||
| 445 | 391 | let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; | |
| 446 | 392 | ||
| 447 | 393 | let per_page: i64 = 50; | |
| 448 | 394 | let total = mt_db::queries::count_mod_log(&state.db, community.id) | |
| 449 | 395 | .await | |
| 450 | - | .map_err(|e| { | |
| 451 | - | tracing::error!(error = ?e, "db error counting mod log"); | |
| 452 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 453 | - | })?; | |
| 396 | + | .map_err(db_error)?; | |
| 454 | 397 | ||
| 455 | 398 | let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); | |
| 456 | 399 | let offset = pagination.offset(per_page); | |
| 457 | 400 | ||
| 458 | 401 | let db_entries = mt_db::queries::list_mod_log(&state.db, community.id, per_page, offset) | |
| 459 | 402 | .await | |
| 460 | - | .map_err(|e| { | |
| 461 | - | tracing::error!(error = ?e, "db error listing mod log"); | |
| 462 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 463 | - | })?; | |
| 403 | + | .map_err(db_error)?; | |
| 464 | 404 | ||
| 465 | 405 | let entries = db_entries | |
| 466 | 406 | .into_iter() |
| @@ -35,7 +35,7 @@ use uuid::Uuid; | |||
| 35 | 35 | use mt_core::types::CommunityRole; | |
| 36 | 36 | use mt_db::queries::{CommunityRow, PostForEdit, ThreadWithBreadcrumb}; | |
| 37 | 37 | ||
| 38 | - | use super::{check_write_access, get_community, get_role, parse_uuid}; | |
| 38 | + | use super::{check_write_access, db_error, get_community, get_role, parse_uuid}; | |
| 39 | 39 | ||
| 40 | 40 | /// A resource that lives inside a community and can be loaded by id. | |
| 41 | 41 | /// | |
| @@ -96,10 +96,7 @@ impl<T: ScopedResource> CommunityScope<T> { | |||
| 96 | 96 | let id = parse_uuid(resource_id_str)?; | |
| 97 | 97 | let resource = T::load(db, id) | |
| 98 | 98 | .await | |
| 99 | - | .map_err(|e| { | |
| 100 | - | tracing::error!(error = ?e, "db error loading scoped resource"); | |
| 101 | - | crate::error_page::internal_error() | |
| 102 | - | })? | |
| 99 | + | .map_err(db_error)? | |
| 103 | 100 | .ok_or_else(crate::error_page::not_found)?; | |
| 104 | 101 | if resource.community_id() != community.id { | |
| 105 | 102 | // The resource exists but lives in a different community than the |