Skip to main content

max / makenotwork

Convert .map_err Internal(anyhow!) to ResultExt::context, fix variant misuse Sweep across server/. Most sites now use ResultExt::context (or with_context) instead of the manual .map_err(|e| AppError::Internal(anyhow::anyhow!("X: {}", e)))? form. Same resulting error chain, fewer lines, harder to drift from convention. Variant-misuse fixes (now return ServiceUnavailable instead of Internal, so on-call alerts don't fire when a feature is intentionally disabled on a deployment): - SyncKit storage / JWT secret not configured (lib.rs, synckit_auth.rs, routes/synckit/auth.rs, routes/synckit/blobs.rs, routes/oauth.rs) - Stripe not configured for refund (routes/api/items/refund.rs) - File storage not configured for export and guest download (routes/api/exports/content.rs, routes/api/guest_checkout.rs) - GIT_REPOS_PATH not configured (routes/api/internal/git.rs) Other fixes: - routes/api/users/profile.rs: email send error path now propagates the original AppError instead of re-wrapping as Internal with an uninformative string. - routes/api/promo_codes.rs: NaiveTime::from_hms_opt with literal args is infallible — replace ok_or_else(Internal) with .expect(). - routes/git_issues/push_refs.rs: static regex compiles use .expect() with a reason instead of .unwrap(). - lib.rs: robots.txt static response builder uses .expect() instead of .unwrap(). argon2 / password_hash / totp_rs::TOTP::new errors don't satisfy std::error::Error + Send + Sync + 'static, so those few sites keep the manual map_err form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 03:45 UTC
Commit: d3ff18af95bff4ae62d44a95abef5335a957919d
Parent: 4ea58fd
28 files changed, +118 insertions, -125 deletions
M server/src/auth.rs +12 -12
@@ -32,7 +32,7 @@ use std::time::Instant;
32 32 use crate::config::Config;
33 33 use crate::constants;
34 34 use crate::db::{self, UserId, UserSessionId, Username};
35 - use crate::error::AppError;
35 + use crate::error::{AppError, ResultExt};
36 36 use crate::helpers::constant_time_compare;
37 37
38 38 /// Session key for storing user data
@@ -143,7 +143,7 @@ impl FromRequestParts<crate::AppState> for AuthUser {
143 143 let user: SessionUser = session
144 144 .get(USER_SESSION_KEY)
145 145 .await
146 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
146 + .context("session error")?
147 147 .ok_or(AppError::Unauthorized)?;
148 148
149 149 // Validate session tracking (skip for legacy sessions without tracking ID).
@@ -220,7 +220,7 @@ where
220 220 let user: Option<SessionUser> = session
221 221 .get(USER_SESSION_KEY)
222 222 .await
223 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
223 + .context("session error")?;
224 224
225 225 Ok(MaybeUser(user))
226 226 }
@@ -285,15 +285,15 @@ pub fn hash_password(password: &str) -> Result<String, AppError> {
285 285 let salt = SaltString::generate(&mut OsRng);
286 286 #[cfg(feature = "fast-tests")]
287 287 let params = Params::new(8 * 1024, 1, 1, None)
288 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Argon2 params error: {}", e)))?;
288 + .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
289 289 #[cfg(not(feature = "fast-tests"))]
290 290 let params = Params::new(46 * 1024, 2, 1, None)
291 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Argon2 params error: {}", e)))?;
291 + .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
292 292 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
293 293
294 294 let hash = argon2
295 295 .hash_password(password.as_bytes(), &salt)
296 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Password hashing failed: {}", e)))?;
296 + .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing: {e}")))?;
297 297
298 298 Ok(hash.to_string())
299 299 }
@@ -301,7 +301,7 @@ pub fn hash_password(password: &str) -> Result<String, AppError> {
301 301 /// Verify a password against a hash
302 302 pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
303 303 let parsed_hash = PasswordHash::new(hash)
304 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid password hash: {}", e)))?;
304 + .map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash: {e}")))?;
305 305
306 306 Ok(Argon2::default()
307 307 .verify_password(password.as_bytes(), &parsed_hash)
@@ -316,19 +316,19 @@ pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppE
316 316 session
317 317 .cycle_id()
318 318 .await
319 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session cycle failed: {}", e)))?;
319 + .context("session cycle")?;
320 320
321 321 // Regenerate CSRF token so pre-auth tokens can't be used post-auth
322 322 let new_csrf = crate::csrf::generate_token();
323 323 session
324 324 .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf)
325 325 .await
326 - .map_err(|e| AppError::Internal(anyhow::anyhow!("CSRF token insert failed: {}", e)))?;
326 + .context("csrf token insert")?;
327 327
328 328 session
329 329 .insert(USER_SESSION_KEY, user)
330 330 .await
331 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session insert failed: {}", e)))?;
331 + .context("session insert")?;
332 332 Ok(())
333 333 }
334 334
@@ -339,7 +339,7 @@ pub async fn logout_user(session: &Session) -> Result<(), AppError> {
339 339 session
340 340 .flush()
341 341 .await
342 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session flush failed: {}", e)))?;
342 + .context("session flush")?;
343 343 Ok(())
344 344 }
345 345
@@ -365,7 +365,7 @@ pub async fn track_session(
365 365 session
366 366 .insert(SESSION_TRACKING_KEY, tracking_id)
367 367 .await
368 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session insert failed: {}", e)))?;
368 + .context("session insert")?;
369 369
370 370 Ok(())
371 371 }
@@ -14,7 +14,7 @@ use axum::{
14 14 use rand::RngCore;
15 15 use tower_sessions::Session;
16 16
17 - use crate::error::AppError;
17 + use crate::error::{AppError, ResultExt};
18 18
19 19 /// Session key for storing CSRF token
20 20 pub const CSRF_SESSION_KEY: &str = "csrf_token";
@@ -35,7 +35,7 @@ pub async fn get_or_create_token(session: &Session) -> Result<String, AppError>
35 35 if let Some(token) = session
36 36 .get::<String>(CSRF_SESSION_KEY)
37 37 .await
38 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
38 + .context("session error")?
39 39 {
40 40 return Ok(token);
41 41 }
@@ -45,7 +45,7 @@ pub async fn get_or_create_token(session: &Session) -> Result<String, AppError>
45 45 session
46 46 .insert(CSRF_SESSION_KEY, &token)
47 47 .await
48 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session insert error: {}", e)))?;
48 + .context("session insert")?;
49 49
50 50 Ok(token)
51 51 }
@@ -55,7 +55,7 @@ pub async fn validate_token(session: &Session, provided_token: &str) -> Result<b
55 55 let session_token: Option<String> = session
56 56 .get(CSRF_SESSION_KEY)
57 57 .await
58 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
58 + .context("session error")?;
59 59
60 60 match session_token {
61 61 Some(token) => Ok(crate::helpers::constant_time_compare(&token, provided_token)),
@@ -9,7 +9,7 @@ pub use tokens::*;
9 9
10 10 use std::sync::Arc;
11 11
12 - use crate::error::{AppError, Result};
12 + use crate::error::{AppError, Result, ResultExt};
13 13
14 14 /// Format an optional display name as a greeting suffix: " Alice" or "".
15 15 fn greeting(name: Option<&str>) -> String {
@@ -216,7 +216,7 @@ impl PostmarkTransport {
216 216 .json(&payload)
217 217 .send()
218 218 .await
219 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to send email: {}", e)))?;
219 + .context("postmark http request")?;
220 220
221 221 if response.status().is_success() {
222 222 tracing::info!(recipient = %to, subject = %subject, "email sent");
@@ -106,14 +106,14 @@ impl AppState {
106 106 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
107 107 self.s3
108 108 .as_ref()
109 - .ok_or_else(|| error::AppError::Storage("File storage is not configured".to_string()))
109 + .ok_or_else(|| error::AppError::ServiceUnavailable("File storage is not configured".to_string()))
110 110 }
111 111
112 112 /// Get the SyncKit S3 storage backend, or error if not configured.
113 113 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
114 114 self.synckit_s3
115 115 .as_ref()
116 - .ok_or_else(|| error::AppError::Internal(anyhow::anyhow!("SyncKit storage not configured")))
116 + .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()))
117 117 }
118 118 }
119 119
@@ -143,7 +143,7 @@ pub fn build_app(
143 143 axum::response::Response::builder()
144 144 .header("content-type", "text/plain")
145 145 .body(axum::body::Body::from("User-agent: *\nDisallow: /api/\nDisallow: /admin/\nDisallow: /settings/\n"))
146 - .unwrap()
146 + .expect("static robots.txt response builds")
147 147 }))
148 148 .nest_service(
149 149 "/static",
@@ -16,7 +16,7 @@ use zip::write::SimpleFileOptions;
16 16 use crate::{
17 17 auth::AuthUser,
18 18 db,
19 - error::{AppError, Result},
19 + error::{AppError, Result, ResultExt},
20 20 helpers::is_htmx_request,
21 21 templates::ExportContentReadyTemplate,
22 22 AppState,
@@ -50,7 +50,7 @@ pub(in crate::routes::api) async fn export_content(
50 50 let is_htmx = is_htmx_request(&headers);
51 51
52 52 let s3 = state.s3.as_ref().ok_or_else(|| {
53 - AppError::Internal(anyhow::anyhow!("Storage not configured"))
53 + AppError::ServiceUnavailable("File storage is not configured".to_string())
54 54 })?;
55 55
56 56 // Collect all S3 keys from items, versions, and insertions
@@ -120,12 +120,12 @@ pub(in crate::routes::api) async fn export_content(
120 120 let username = user.username.to_string();
121 121
122 122 let tmp_dir = tempfile::tempdir()
123 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create temp dir: {e}")))?;
123 + .context("create temp dir for export")?;
124 124 let zip_path = tmp_dir.path().join("export.zip");
125 125
126 126 {
127 127 let zip_file = std::fs::File::create(&zip_path)
128 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create temp file: {e}")))?;
128 + .context("create export zip file")?;
129 129 let mut zip = zip::ZipWriter::new(std::io::BufWriter::new(zip_file));
130 130 let options = SimpleFileOptions::default()
131 131 .compression_method(zip::CompressionMethod::Stored);
@@ -148,9 +148,9 @@ pub(in crate::routes::api) async fn export_content(
148 148 }
149 149 let file_size = data.len() as i64;
150 150 zip.start_file(zip_path_entry, options)
151 - .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP error: {e}")))?;
151 + .context("zip start file")?;
152 152 zip.write_all(&data)
153 - .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP write error: {e}")))?;
153 + .context("zip write")?;
154 154 manifest.push((zip_path_entry.clone(), file_size));
155 155 // data dropped here -- only one file in RAM at a time
156 156 }
@@ -193,12 +193,12 @@ pub(in crate::routes::api) async fn export_content(
193 193 readme.push_str("\nNote: Git repositories are not included in this export.\n");
194 194 readme.push_str("Clone them separately: git clone https://makenot.work/source/<username>/<repo>.git\n");
195 195 zip.start_file("README.txt", options)
196 - .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP error: {e}")))?;
196 + .context("zip start readme")?;
197 197 zip.write_all(readme.as_bytes())
198 - .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP write error: {e}")))?;
198 + .context("zip write readme")?;
199 199
200 200 zip.finish()
201 - .map_err(|e| AppError::Internal(anyhow::anyhow!("ZIP finish error: {e}")))?;
201 + .context("zip finish")?;
202 202 }
203 203
204 204 // Upload ZIP to S3 via multipart upload (streams from disk in 10 MB parts)
@@ -233,7 +233,7 @@ pub(in crate::routes::api) async fn export_content(
233 233 .status(303)
234 234 .header("Location", &download_url)
235 235 .body("".into())
236 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e)))
236 + .context("build export redirect response")
237 237 }
238 238
239 239 /// Extract file extension from an S3 key (e.g. "user/item/audio/track.mp3" -> "mp3").
@@ -12,7 +12,7 @@ use axum::{
12 12 use crate::{
13 13 auth::AuthUser,
14 14 db,
15 - error::{AppError, Result},
15 + error::{Result, ResultExt},
16 16 helpers::{is_htmx_request, sanitize_csv_cell},
17 17 templates::{ExportDownloadTemplate, FormStatusTemplate},
18 18 AppState,
@@ -41,7 +41,7 @@ fn download_response(content: Vec<u8>, filename: &str, content_type: &str) -> Re
41 41 format!("attachment; filename=\"{filename}\""),
42 42 )
43 43 .body(content.into())
44 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e)))
44 + .context("build download response")
45 45 }
46 46
47 47 // =============================================================================
@@ -268,7 +268,7 @@ pub(super) async fn guest_download(
268 268 .ok_or_else(|| AppError::NotFound)?;
269 269
270 270 let s3 = state.s3.as_ref()
271 - .ok_or_else(|| AppError::BadRequest("Storage not configured".to_string()))?;
271 + .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?;
272 272
273 273 let download_url = s3.presign_download(s3_key, Some(3600)).await?;
274 274
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
8 8
9 9 use crate::auth::ServiceAuth;
10 10 use crate::db::{self, CollectionId, ItemId, ProjectId, Slug, UserId};
11 - use crate::error::{AppError, Result};
11 + use crate::error::{AppError, Result, ResultExt};
12 12 use crate::AppState;
13 13
14 14 /// User ID query parameter shared by all internal endpoints.
@@ -419,10 +419,10 @@ pub(super) async fn verify_domain(
419 419 .timeout(std::time::Duration::from_secs(5))
420 420 .send()
421 421 .await
422 - .map_err(|_| AppError::Internal(anyhow::anyhow!("DNS lookup failed")))?;
422 + .context("dns lookup")?;
423 423
424 424 let json: serde_json::Value = resp.json().await
425 - .map_err(|_| AppError::Internal(anyhow::anyhow!("DNS response parse failed")))?;
425 + .context("parse dns response")?;
426 426
427 427 let verified = json["Answer"]
428 428 .as_array()
@@ -123,7 +123,7 @@ pub(super) async fn git_authorize(
123 123 .config
124 124 .git_repos_path
125 125 .as_deref()
126 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("GIT_REPOS_PATH not configured")))?;
126 + .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?;
127 127
128 128 // Look up the namespace owner
129 129 let owner_user = db::users::get_user_by_username(
@@ -61,7 +61,7 @@ pub(in crate::routes::api) async fn refund_transaction(
61 61
62 62 // Issue the refund via Stripe — the webhook handler does the rest
63 63 let stripe = state.stripe.as_ref()
64 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Stripe not configured")))?;
64 + .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
65 65 stripe.create_refund(payment_intent_id, stripe_account_id).await?;
66 66
67 67 Ok(Json(serde_json::json!({ "ok": true })))
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
14 14 use crate::{
15 15 auth::AuthUser,
16 16 db::{self, ItemId, KeyCode, LicenseKeyId},
17 - error::{AppError, Result},
17 + error::{AppError, Result, ResultExt},
18 18 helpers::{self, hx_toast, is_htmx_request},
19 19 templates::{ItemLicenseKeysTemplate, SaveStatusTemplate},
20 20 types::LicenseKeyRow,
@@ -630,7 +630,7 @@ pub(super) async fn license_verify(
630 630 &claims,
631 631 &EncodingKey::from_secret(state.config.signing_secret.as_bytes()),
632 632 )
633 - .map_err(|e| AppError::Internal(anyhow::anyhow!("JWT encode error: {}", e)))?;
633 + .context("jwt encode")?;
634 634
635 635 Ok(Json(LicenseVerifyResponse {
636 636 valid: true,
@@ -12,7 +12,7 @@ use webauthn_rs::prelude::*;
12 12 use crate::{
13 13 auth::{verify_password, AuthUser},
14 14 db::{self, PasskeyId},
15 - error::{AppError, Result},
15 + error::{AppError, Result, ResultExt},
16 16 helpers::hx_toast,
17 17 templates::{PasskeyListTemplate, PasskeyDisplay},
18 18 AppState,
@@ -79,13 +79,13 @@ pub(super) async fn register_start(
79 79 user.username.as_ref(),
80 80 exclude,
81 81 )
82 - .map_err(|e| AppError::Internal(anyhow::anyhow!("WebAuthn registration start: {}", e)))?;
82 + .context("webauthn registration start")?;
83 83
84 84 // Store the registration state in session for the finish step
85 85 session
86 86 .insert(PASSKEY_REG_STATE_KEY, &reg_state)
87 87 .await
88 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
88 + .context("session error")?;
89 89
90 90 Ok(Json(ccr).into_response())
91 91 }
@@ -101,7 +101,7 @@ pub(super) async fn register_finish(
101 101 let reg_state: PasskeyRegistration = session
102 102 .get(PASSKEY_REG_STATE_KEY)
103 103 .await
104 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
104 + .context("session error")?
105 105 .ok_or_else(|| AppError::BadRequest("No pending registration".to_string()))?;
106 106
107 107 // Clean up session state
@@ -116,7 +116,7 @@ pub(super) async fn register_finish(
116 116 .map_err(|e| AppError::BadRequest(format!("Registration failed: {}", e)))?;
117 117
118 118 let credential_json = serde_json::to_value(&passkey)
119 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Serialization error: {}", e)))?;
119 + .context("serialize passkey")?;
120 120 let credential_id = passkey.cred_id().to_vec();
121 121
122 122 db::passkeys::create_passkey(&state.db, user.id, "Passkey", &credential_json, &credential_id)
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
11 11 use crate::{
12 12 auth::AuthUser,
13 13 db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility},
14 - error::{AppError, Result},
14 + error::{AppError, Result, ResultExt},
15 15 helpers::{htmx_toast_response, is_htmx_request},
16 16 types::ListResponse,
17 17 validation,
@@ -439,10 +439,10 @@ pub(super) async fn create_repo(
439 439 }
440 440
441 441 std::fs::create_dir_all(&owner_dir)
442 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create owner directory: {e}")))?;
442 + .context("create git owner directory")?;
443 443
444 444 git2::Repository::init_bare(&repo_dir)
445 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to init bare repo: {e}")))?;
445 + .context("init bare git repo")?;
446 446
447 447 // Install post-receive hook if build triggers are configured
448 448 if let Some(token) = &state.config.build_trigger_token {
@@ -210,10 +210,8 @@ pub(super) async fn create_promo_code(
210 210 } else {
211 211 let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
212 212 .map_err(|_| AppError::BadRequest("Invalid expiry date".to_string()))?;
213 - // Expire at end of the given day (UTC). 23:59:59 is always valid,
214 - // but avoid unwrap in request handlers as a matter of policy.
215 213 Some(date.and_hms_opt(23, 59, 59)
216 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time 23:59:59")))?
214 + .expect("23:59:59 is a valid time")
217 215 .and_utc())
218 216 }
219 217 } else {
@@ -229,7 +227,7 @@ pub(super) async fn create_promo_code(
229 227 let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
230 228 .map_err(|_| AppError::BadRequest("Invalid start date".to_string()))?;
231 229 Some(date.and_hms_opt(0, 0, 0)
232 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time 00:00:00")))?
230 + .expect("00:00:00 is a valid time")
233 231 .and_utc())
234 232 }
235 233 } else {
@@ -419,7 +417,7 @@ pub(super) async fn update_promo_code(
419 417 .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?;
420 418 Ok(Some(
421 419 date.and_hms_opt(23, 59, 59)
422 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time")))?
420 + .expect("23:59:59 is a valid time")
423 421 .and_utc(),
424 422 ))
425 423 };
@@ -434,7 +432,7 @@ pub(super) async fn update_promo_code(
434 432 .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?;
435 433 Ok::<_, AppError>(Some(
436 434 date.and_hms_opt(0, 0, 0)
437 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("invalid time")))?
435 + .expect("00:00:00 is a valid time")
438 436 .and_utc(),
439 437 ))
440 438 }).transpose()?;
@@ -11,7 +11,7 @@ use crate::{
11 11 auth::{verify_password, AuthUser},
12 12 constants::{BACKUP_CODE_COUNT, BACKUP_CODE_LENGTH, TOTP_DIGITS, TOTP_SKEW, TOTP_STEP},
13 13 db,
14 - error::{AppError, Result},
14 + error::{AppError, Result, ResultExt},
15 15 helpers::hx_toast,
16 16 templates::{TotpSetupTemplate, TotpStatusTemplate},
17 17 AppState,
@@ -37,7 +37,7 @@ pub(super) async fn setup(
37 37 Some("Makenotwork".to_string()),
38 38 user.email.clone(),
39 39 )
40 - .map_err(|e| AppError::Internal(anyhow::anyhow!("TOTP generation failed: {}", e)))?;
40 + .context("totp generation")?;
41 41
42 42 let secret_base32 = totp.get_secret_base32();
43 43
@@ -47,7 +47,7 @@ pub(super) async fn setup(
47 47 // Generate QR code as base64 PNG
48 48 let qr_base64 = totp
49 49 .get_qr_base64()
50 - .map_err(|e| AppError::Internal(anyhow::anyhow!("QR code generation failed: {}", e)))?;
50 + .map_err(|e| AppError::Internal(anyhow::anyhow!("qr code generation: {e}")))?;
51 51
52 52 // Generate backup codes
53 53 let backup_codes = generate_backup_codes();
@@ -210,7 +210,7 @@ pub(super) async fn status(
210 210 pub(crate) fn build_totp(secret_base32: &str, account_name: &str) -> Result<totp_rs::TOTP> {
211 211 let secret_bytes = totp_rs::Secret::Encoded(secret_base32.to_string())
212 212 .to_bytes()
213 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid TOTP secret: {}", e)))?;
213 + .context("parse totp secret")?;
214 214
215 215 totp_rs::TOTP::new(
216 216 totp_rs::Algorithm::SHA1,
@@ -221,7 +221,7 @@ pub(crate) fn build_totp(secret_base32: &str, account_name: &str) -> Result<totp
221 221 Some("Makenotwork".to_string()),
222 222 account_name.to_string(),
223 223 )
224 - .map_err(|e| AppError::Internal(anyhow::anyhow!("TOTP creation failed: {}", e)))
224 + .context("totp creation")
225 225 }
226 226
227 227 /// Generate random alphanumeric backup codes.
@@ -13,7 +13,7 @@ use crate::{
13 13 auth::AuthUser,
14 14 db::{self, UserId, Username},
15 15 email,
16 - error::{AppError, Result},
16 + error::{AppError, Result, ResultExt},
17 17 helpers::is_htmx_request,
18 18 templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate},
19 19 validation,
@@ -165,7 +165,7 @@ pub(in crate::routes::api) async fn update_password(
165 165
166 166 // Rotate session ID so old session cookie is invalidated
167 167 session.cycle_id().await
168 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session cycle failed: {}", e)))?;
168 + .context("session cycle")?;
169 169
170 170 if is_htmx {
171 171 return Ok(Html(SaveStatusTemplate {
@@ -356,11 +356,11 @@ pub(in crate::routes::api) async fn resend_verification(
356 356 .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url)
357 357 .await
358 358 {
359 - tracing::error!(error = ?e, "failed to send verification email");
360 359 if is_htmx {
360 + tracing::error!(error = ?e, "failed to send verification email");
361 361 return Ok(AlertTemplate::new("error", "Failed to send verification email. Please try again.").into_response());
362 362 }
363 - return Err(AppError::Internal(anyhow::anyhow!("Failed to send email")));
363 + return Err(e);
364 364 }
365 365
366 366 tracing::info!(user_id = %user.id, "verification email sent");
@@ -421,14 +421,14 @@ pub(in crate::routes::api) async fn request_account_deletion(
421 421 .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url)
422 422 .await
423 423 {
424 - tracing::error!(error = ?e, "failed to send deletion email");
425 424 if is_htmx {
425 + tracing::error!(error = ?e, "failed to send deletion email");
426 426 return Ok(Html(FormStatusTemplate {
427 427 success: false,
428 428 message: "Failed to send email. Please try again.".to_string(),
429 429 }.render_string()).into_response());
430 430 }
431 - return Err(AppError::Internal(anyhow::anyhow!("Failed to send email")));
431 + return Err(e);
432 432 }
433 433
434 434 tracing::info!(user_id = %user.id, "deletion confirmation email sent");
@@ -177,18 +177,12 @@ async fn login_handler(
177 177
178 178 // Check if user has 2FA enabled — redirect to verification page if so
179 179 if user.totp_enabled {
180 - session.cycle_id().await
181 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session cycle error: {}", e)))?;
182 - session.insert("pending_2fa_user_id", user.id).await
183 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
184 - session.insert("pending_2fa_notify_enabled", user.login_notification_enabled).await
185 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
186 - session.insert("pending_2fa_notify_email", &user.email).await
187 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
188 - session.insert("pending_2fa_notify_name", &user.display_name).await
189 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
190 - session.insert("pending_2fa_remember_me", remember).await
191 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
180 + session.cycle_id().await.context("session cycle")?;
181 + session.insert("pending_2fa_user_id", user.id).await.context("session insert")?;
182 + session.insert("pending_2fa_notify_enabled", user.login_notification_enabled).await.context("session insert")?;
183 + session.insert("pending_2fa_notify_email", &user.email).await.context("session insert")?;
184 + session.insert("pending_2fa_notify_name", &user.display_name).await.context("session insert")?;
185 + session.insert("pending_2fa_remember_me", remember).await.context("session insert")?;
192 186
193 187 tracing::info!(user_id = %user.id, event = "login_2fa_pending", "User requires 2FA verification");
194 188
@@ -322,12 +316,12 @@ async fn passkey_auth_start(
322 316 let (rcr, auth_state) = state
323 317 .webauthn
324 318 .start_discoverable_authentication()
325 - .map_err(|e| AppError::Internal(anyhow::anyhow!("WebAuthn auth start: {}", e)))?;
319 + .context("webauthn auth start")?;
326 320
327 321 session
328 322 .insert(PASSKEY_AUTH_STATE_KEY, &auth_state)
329 323 .await
330 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
324 + .context("session error")?;
331 325
332 326 Ok(axum::Json(rcr).into_response())
333 327 }
@@ -343,7 +337,7 @@ async fn passkey_auth_finish(
343 337 let auth_state: DiscoverableAuthentication = session
344 338 .get(PASSKEY_AUTH_STATE_KEY)
345 339 .await
346 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
340 + .context("session error")?
347 341 .ok_or_else(|| AppError::BadRequest("No pending passkey authentication".to_string()))?;
348 342
349 343 // Clean up session state
@@ -367,7 +361,7 @@ async fn passkey_auth_finish(
367 361
368 362 // Parse credential and convert for discoverable verification
369 363 let mut passkey: Passkey = serde_json::from_value(cred_json)
370 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Credential deserialize: {}", e)))?;
364 + .context("deserialize passkey credential")?;
371 365 let discoverable_key = DiscoverableKey::from(&passkey);
372 366
373 367 // Verify the authentication response
@@ -379,7 +373,7 @@ async fn passkey_auth_finish(
379 373 // Update the credential counter to prevent cloning attacks
380 374 passkey.update_credential(&auth_result);
381 375 let updated_json = serde_json::to_value(&passkey)
382 - .map_err(|e| AppError::Internal(anyhow::anyhow!("Credential serialize: {}", e)))?;
376 + .context("serialize passkey credential")?;
383 377 db::passkeys::update_passkey_after_auth(&state.db, &cred_id_bytes, &updated_json)
384 378 .await
385 379 .context("update passkey counter after auth")?;
@@ -11,7 +11,7 @@ use serde::Deserialize;
11 11 use crate::{
12 12 auth::MaybeUser,
13 13 constants,
14 - error::{AppError, Result},
14 + error::{AppError, Result, ResultExt},
15 15 git,
16 16 AppState,
17 17 };
@@ -77,7 +77,7 @@ pub(super) async fn raw_file(
77 77 ),
78 78 )
79 79 .body(Body::from(content))
80 - .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build response: {e}")))
80 + .context("build git http response")
81 81 }
82 82
83 83 // ============================================================================
@@ -115,7 +115,7 @@ pub(super) async fn smart_http_info_refs(
115 115 .arg(&repo_path)
116 116 .output()
117 117 .await
118 - .map_err(|e| AppError::Internal(anyhow::anyhow!("git upload-pack failed: {e}")))?;
118 + .context("run git upload-pack")?;
119 119
120 120 if !output.status.success() {
121 121 return Err(AppError::Internal(anyhow::anyhow!(
@@ -139,7 +139,7 @@ pub(super) async fn smart_http_info_refs(
139 139 )
140 140 .header(header::CACHE_CONTROL, "no-cache")
141 141 .body(Body::from(body))
142 - .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build response: {e}")))
142 + .context("build git http response")
143 143 }
144 144
145 145 /// `POST /git/{owner}/{repo}/git-upload-pack`
@@ -164,20 +164,20 @@ pub(super) async fn smart_http_upload_pack(
164 164 .stdout(std::process::Stdio::piped())
165 165 .stderr(std::process::Stdio::null())
166 166 .spawn()
167 - .map_err(|e| AppError::Internal(anyhow::anyhow!("git upload-pack spawn failed: {e}")))?;
167 + .context("spawn git upload-pack")?;
168 168
169 169 if let Some(mut stdin) = child.stdin.take() {
170 170 use tokio::io::AsyncWriteExt;
171 171 stdin
172 172 .write_all(&body)
173 173 .await
174 - .map_err(|e| AppError::Internal(anyhow::anyhow!("stdin write failed: {e}")))?;
174 + .context("write to git upload-pack stdin")?;
175 175 }
176 176
177 177 let output = child
178 178 .wait_with_output()
179 179 .await
180 - .map_err(|e| AppError::Internal(anyhow::anyhow!("git upload-pack failed: {e}")))?;
180 + .context("wait git upload-pack output")?;
181 181
182 182 Response::builder()
183 183 .status(StatusCode::OK)
@@ -187,5 +187,5 @@ pub(super) async fn smart_http_upload_pack(
187 187 )
188 188 .header(header::CACHE_CONTROL, "no-cache")
189 189 .body(Body::from(output.stdout))
190 - .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build response: {e}")))
190 + .context("build git http response")
191 191 }
@@ -12,7 +12,7 @@ use std::sync::LazyLock;
12 12
13 13 use crate::{
14 14 db::{self, IssueStatus},
15 - error::{AppError, Result},
15 + error::{AppError, Result, ResultExt},
16 16 AppState,
17 17 };
18 18
@@ -42,13 +42,16 @@ struct IssueRef {
42 42 /// Deduplicates by issue number (close wins over reference).
43 43 fn parse_issue_refs(message: &str) -> Vec<IssueRef> {
44 44 static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| {
45 - Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)").unwrap()
45 + Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)")
46 + .expect("static issue-close regex compiles")
46 47 });
47 48 static REOPEN_RE: LazyLock<Regex> = LazyLock::new(|| {
48 - Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)").unwrap()
49 + Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)")
50 + .expect("static issue-reopen regex compiles")
49 51 });
50 52 static REF_RE: LazyLock<Regex> = LazyLock::new(|| {
51 - Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").unwrap()
53 + Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)")
54 + .expect("static issue-ref regex compiles")
52 55 });
53 56
54 57 let mut by_number: HashMap<i32, IssueRefAction> = HashMap::new();
@@ -133,15 +136,15 @@ pub(super) async fn process_push(
133 136 let commit_refs = {
134 137 let root = repos_root(&state)?;
135 138 let git_repo = crate::git::open_repo(&root, &req.repo_owner, &req.repo_name)
136 - .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to open repo: {}", e)))?;
139 + .context("open git repo")?;
137 140
138 141 let after_oid = git2::Oid::from_str(&req.after)
139 142 .map_err(|e| AppError::BadRequest(format!("invalid 'after' oid: {}", e)))?;
140 143
141 144 let mut revwalk = git_repo.revwalk()
142 - .map_err(|e| AppError::Internal(anyhow::anyhow!("revwalk init failed: {}", e)))?;
145 + .context("revwalk init")?;
143 146 revwalk.push(after_oid)
144 - .map_err(|e| AppError::Internal(anyhow::anyhow!("revwalk push failed: {}", e)))?;
147 + .context("revwalk push")?;
145 148
146 149 let is_new_branch = req.before.chars().all(|c| c == '0');
147 150 if !is_new_branch
@@ -430,7 +430,7 @@ async fn token_exchange(
430 430 .config
431 431 .synckit_jwt_secret
432 432 .as_deref()
433 - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("SyncKit not configured")))?;
433 + .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
434 434
435 435 // Atomically consume code (must exist, not expired, not used).
436 436 // Single UPDATE...RETURNING prevents TOCTOU race on concurrent requests.