max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
30 files changed,
+638 insertions,
-522 deletions
| @@ -3062,6 +3062,7 @@ | |||
| 3062 | 3062 | dependencies = [ | |
| 3063 | 3063 | "aws-config", | |
| 3064 | 3064 | "aws-sdk-s3", | |
| 3065 | + | "bytes", | |
| 3065 | 3066 | "tokio", | |
| 3066 | 3067 | "tracing", | |
| 3067 | 3068 | ] |
| @@ -166,6 +166,31 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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. | |
| @@ -126,6 +157,32 @@ | |||
| 126 | 157 | assert_eq!(parse_trusted_proxies(Some(" ")).len(), 0); | |
| 127 | 158 | } | |
| 128 | 159 | ||
| 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 | + | ||
| 129 | 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")); |
| @@ -13,19 +13,32 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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) |