// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The application error type and its themed error pages. //! //! User-facing pages never expose internals: an internal error logs its detail //! (with a correlation id via `tracing`) and the page shows only the id. Error //! pages are self-contained — they link `base.css` and the default theme by stable //! URL, so they render even when produced by an extractor rejection with no access //! to application state. use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; use maud::{DOCTYPE, Markup, html}; /// A web-layer error mapped to a themed HTTP response. #[derive(Debug)] pub enum AppError { /// 404 — also the "does not exist" response for private resources, so their /// existence never leaks. NotFound, /// 401 — authentication required. Unauthorized, /// 403 — authenticated but not permitted. Forbidden, /// 400 — a malformed or invalid request. BadRequest(String), /// 500 — an unexpected internal failure. The detail is logged, never shown. Internal(String), } /// A convenient result alias for handlers. pub type AppResult = Result; impl AppError { /// Build an internal error from any displayable error, for `map_err`. pub fn internal(err: impl std::fmt::Display) -> Self { Self::Internal(err.to_string()) } } impl From for AppError { fn from(err: store::StoreError) -> Self { Self::Internal(err.to_string()) } } impl From for AppError { fn from(err: git::GitError) -> Self { Self::internal(err) } } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, heading, detail): (StatusCode, &str, String) = match self { AppError::NotFound => ( StatusCode::NOT_FOUND, "Not found", "The page you were looking for does not exist.".to_string(), ), AppError::Unauthorized => ( StatusCode::UNAUTHORIZED, "Sign in required", "You need to sign in to view this page.".to_string(), ), AppError::Forbidden => ( StatusCode::FORBIDDEN, "Forbidden", "You do not have access to this page.".to_string(), ), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "Bad request", msg), AppError::Internal(detail) => { // Log the real detail with a short correlation id; show only the id. let id = correlation_id(); tracing::error!(error.id = %id, error.detail = %detail, "internal error"); ( StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", format!("An internal error occurred. Reference: {id}"), ) } }; ( status, Html(error_page(status.as_u16(), heading, &detail).into_string()), ) .into_response() } } /// A short, log-correlatable id. Derived from the monotonic-ish wall clock; it only /// needs to be unique enough to find the matching log line. fn correlation_id() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| d.as_nanos()); format!("{nanos:x}") } /// A minimal, self-contained themed error page. fn error_page(code: u16, heading: &str, detail: &str) -> Markup { html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; title { (code) " — " (heading) } link rel="stylesheet" href="/assets/base.css"; link rel="stylesheet" href="/theme.css"; } body { div class="error-page" { div class="code" { (code) } h1 { (heading) } p class="muted" { (detail) } p { a href="/" { "Return home" } } } } } } }