Skip to main content

max / makenotwork

Remediate audit findings: SSRF userinfo bypass, guest role, C1 scope gaps Same-day remediation of the 2026-07-04 audit (H1/M1/M2, L1-L6): - H1: reject userinfo-prefixed literal IPs in the link_preview SSRF guard (strip userinfo before the host split) and widen is_private_ip reserved ranges (0/8, 192.0.0.0/24, 198.18/15, 240/4, NAT64, v4-compatible IPv6) - M1: drop 'guest' from memberships_role_check so the schema can't hold a role CommunityRole cannot decode (migration 033) - M2: scope category and thread-tracking loads to the URL slug's community via CommunityScope, closing the residual C1 cross-community gaps - L1-L6: auth.rs PKCE/nonce unit tests; concurrency tests; delete dead templates/partials.rs; move logout csrf_input inside the form; log outbound-fetch failures; host-parse the loopback exemption in config Post-fix: cargo check/clippy clean, tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 23:04 UTC
Commit: 8e7b5fded71e9d7c10d0f4a02074994f0cdd6b08
Parent: bfd4533
32 files changed, +723 insertions, -607 deletions
@@ -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 - }
@@ -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 + }
@@ -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 + }
@@ -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(&quoted_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