Three features bundled into one release: - rate-limit /auth/login, /register, /me/password (token bucket, 5 req/sec sustained with 10-request burst by default; 429 + Retry-After header on hit; tracing::warn! per hit so operators see attack patterns; AUTH_RATE_PER_SEC / AUTH_RATE_BURST env knobs) - handle SIGTERM for graceful container stops (replaces bare ctrl_c() with a select over ctrl_c + SignalKind::terminate() so docker compose stop runs the daemon shutdown path instead of letting Chromium leak past SIGKILL) - clear session.user on 401 from any API call (setOn401Hook in api/client.ts, registered from session.svelte.ts gated on $app/environment::browser so the SSR bundle never installs it; fixes "logged in but no bookmarks/collections" mid-session expiry state) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
176 lines
7.0 KiB
Rust
176 lines
7.0 KiB
Rust
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
|
|
use crate::storage::StorageError;
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum AppError {
|
|
#[error("not found")]
|
|
NotFound,
|
|
#[error("invalid input: {0}")]
|
|
InvalidInput(String),
|
|
#[error("unauthenticated")]
|
|
Unauthenticated,
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
#[error("conflict: {0}")]
|
|
Conflict(String),
|
|
#[error("payload too large: {0}")]
|
|
PayloadTooLarge(String),
|
|
#[error("unsupported media type: {0}")]
|
|
UnsupportedMediaType(String),
|
|
/// 429 with an optional `Retry-After` header value (in seconds).
|
|
#[error("too many requests")]
|
|
TooManyRequests {
|
|
retry_after_secs: Option<u64>,
|
|
},
|
|
/// Semantic per-field validation failure. `details` is rendered into the
|
|
/// envelope so the client can highlight the bad field(s).
|
|
#[error("validation failed")]
|
|
ValidationFailed {
|
|
message: String,
|
|
details: serde_json::Value,
|
|
},
|
|
#[error(transparent)]
|
|
Database(#[from] sqlx::Error),
|
|
#[error(transparent)]
|
|
Storage(#[from] StorageError),
|
|
#[error(transparent)]
|
|
Other(#[from] anyhow::Error),
|
|
}
|
|
|
|
pub type AppResult<T> = Result<T, AppError>;
|
|
|
|
impl AppError {
|
|
/// Stable, snake_case code that clients pattern-match on. Every top-level
|
|
/// variant is matched explicitly — adding a new variant without giving it
|
|
/// a code is a compile error, on purpose.
|
|
pub fn code(&self) -> &'static str {
|
|
match self {
|
|
AppError::NotFound => "not_found",
|
|
AppError::InvalidInput(_) => "invalid_input",
|
|
AppError::Unauthenticated => "unauthenticated",
|
|
AppError::Forbidden => "forbidden",
|
|
AppError::Conflict(_) => "conflict",
|
|
AppError::PayloadTooLarge(_) => "payload_too_large",
|
|
AppError::UnsupportedMediaType(_) => "unsupported_media_type",
|
|
AppError::TooManyRequests { .. } => "too_many_requests",
|
|
AppError::ValidationFailed { .. } => "validation_failed",
|
|
AppError::Database(sqlx::Error::RowNotFound) => "not_found",
|
|
AppError::Database(_) => "internal_error",
|
|
AppError::Storage(StorageError::NotFound) => "not_found",
|
|
AppError::Storage(StorageError::BadKey) => "bad_file_key",
|
|
AppError::Storage(StorageError::Io(_)) => "internal_error",
|
|
AppError::Other(_) => "internal_error",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let code = self.code();
|
|
let (status, message, details) = match &self {
|
|
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string(), None),
|
|
AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone(), None),
|
|
AppError::Unauthenticated => {
|
|
(StatusCode::UNAUTHORIZED, "unauthenticated".to_string(), None)
|
|
}
|
|
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string(), None),
|
|
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone(), None),
|
|
AppError::PayloadTooLarge(msg) => {
|
|
(StatusCode::PAYLOAD_TOO_LARGE, msg.clone(), None)
|
|
}
|
|
AppError::UnsupportedMediaType(msg) => {
|
|
(StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None)
|
|
}
|
|
AppError::TooManyRequests { retry_after_secs } => {
|
|
// Emit `Retry-After: N` (RFC 6585 §4) so a well-behaved
|
|
// client can back off correctly. Done by building the
|
|
// response by hand below — the `(status, headers,
|
|
// body)` tuple shape doesn't fit the standard
|
|
// `(status, body)` IntoResponse path for the other
|
|
// variants.
|
|
let body = json!({
|
|
"error": {
|
|
"code": code,
|
|
"message": "too many requests; slow down",
|
|
}
|
|
});
|
|
let mut resp = (StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response();
|
|
if let Some(secs) = retry_after_secs {
|
|
// `HeaderValue: From<u64>` skips both the
|
|
// intermediate `String` allocation and the
|
|
// fallible-by-shape `from_str` path.
|
|
resp.headers_mut().insert(
|
|
axum::http::header::RETRY_AFTER,
|
|
axum::http::HeaderValue::from(*secs),
|
|
);
|
|
}
|
|
return resp;
|
|
}
|
|
AppError::ValidationFailed { message, details } => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
message.clone(),
|
|
Some(details.clone()),
|
|
),
|
|
AppError::Database(sqlx::Error::RowNotFound) => {
|
|
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
|
}
|
|
AppError::Storage(StorageError::NotFound) => {
|
|
(StatusCode::NOT_FOUND, "not found".to_string(), None)
|
|
}
|
|
AppError::Storage(StorageError::BadKey) => (
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid file key".to_string(),
|
|
None,
|
|
),
|
|
AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => {
|
|
tracing::error!(error = ?self, "internal error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal error".to_string(),
|
|
None,
|
|
)
|
|
}
|
|
};
|
|
let body = match details {
|
|
Some(d) => json!({ "error": { "code": code, "message": message, "details": d } }),
|
|
None => json!({ "error": { "code": code, "message": message } }),
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn codes_are_stable() {
|
|
assert_eq!(AppError::NotFound.code(), "not_found");
|
|
assert_eq!(AppError::InvalidInput("x".into()).code(), "invalid_input");
|
|
assert_eq!(AppError::Unauthenticated.code(), "unauthenticated");
|
|
assert_eq!(AppError::Forbidden.code(), "forbidden");
|
|
assert_eq!(AppError::Conflict("x".into()).code(), "conflict");
|
|
assert_eq!(AppError::PayloadTooLarge("x".into()).code(), "payload_too_large");
|
|
assert_eq!(
|
|
AppError::UnsupportedMediaType("x".into()).code(),
|
|
"unsupported_media_type"
|
|
);
|
|
assert_eq!(
|
|
AppError::ValidationFailed {
|
|
message: "x".into(),
|
|
details: json!({}),
|
|
}
|
|
.code(),
|
|
"validation_failed"
|
|
);
|
|
assert_eq!(AppError::Storage(StorageError::BadKey).code(), "bad_file_key");
|
|
assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
|
|
assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
|
|
assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
|
|
}
|
|
}
|