fabrica

hanna/fabrica

4452 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The application error type and its themed error pages.
6//!
7//! User-facing pages never expose internals: an internal error logs its detail
8//! (with a correlation id via `tracing`) and the page shows only the id. Error
9//! pages are self-contained — they link `base.css` and the default theme by stable
10//! URL, so they render even when produced by an extractor rejection with no access
11//! to application state.
12
13use axum::http::StatusCode;
14use axum::response::{Html, IntoResponse, Response};
15use maud::{DOCTYPE, Markup, html};
16
17/// A web-layer error mapped to a themed HTTP response.
18#[derive(Debug)]
19pub enum AppError {
20 /// 404 — also the "does not exist" response for private resources, so their
21 /// existence never leaks.
22 NotFound,
23 /// 401 — authentication required.
24 Unauthorized,
25 /// 403 — authenticated but not permitted.
26 Forbidden,
27 /// 400 — a malformed or invalid request.
28 BadRequest(String),
29 /// 500 — an unexpected internal failure. The detail is logged, never shown.
30 Internal(String),
31}
32
33/// A convenient result alias for handlers.
34pub type AppResult<T> = Result<T, AppError>;
35
36impl AppError {
37 /// Build an internal error from any displayable error, for `map_err`.
38 pub fn internal(err: impl std::fmt::Display) -> Self {
39 Self::Internal(err.to_string())
40 }
41}
42
43impl From<store::StoreError> for AppError {
44 fn from(err: store::StoreError) -> Self {
45 Self::Internal(err.to_string())
46 }
47}
48
49impl From<git::GitError> for AppError {
50 fn from(err: git::GitError) -> Self {
51 Self::internal(err)
52 }
53}
54
55impl IntoResponse for AppError {
56 fn into_response(self) -> Response {
57 let (status, heading, detail): (StatusCode, &str, String) = match self {
58 AppError::NotFound => (
59 StatusCode::NOT_FOUND,
60 "Not found",
61 "The page you were looking for does not exist.".to_string(),
62 ),
63 AppError::Unauthorized => (
64 StatusCode::UNAUTHORIZED,
65 "Sign in required",
66 "You need to sign in to view this page.".to_string(),
67 ),
68 AppError::Forbidden => (
69 StatusCode::FORBIDDEN,
70 "Forbidden",
71 "You do not have access to this page.".to_string(),
72 ),
73 AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "Bad request", msg),
74 AppError::Internal(detail) => {
75 // Log the real detail with a short correlation id; show only the id.
76 let id = correlation_id();
77 tracing::error!(error.id = %id, error.detail = %detail, "internal error");
78 (
79 StatusCode::INTERNAL_SERVER_ERROR,
80 "Something went wrong",
81 format!("An internal error occurred. Reference: {id}"),
82 )
83 }
84 };
85 (
86 status,
87 Html(error_page(status.as_u16(), heading, &detail).into_string()),
88 )
89 .into_response()
90 }
91}
92
93/// A short, log-correlatable id. Derived from the monotonic-ish wall clock; it only
94/// needs to be unique enough to find the matching log line.
95fn correlation_id() -> String {
96 use std::time::{SystemTime, UNIX_EPOCH};
97 let nanos = SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .map_or(0, |d| d.as_nanos());
100 format!("{nanos:x}")
101}
102
103/// A minimal, self-contained themed error page.
104fn error_page(code: u16, heading: &str, detail: &str) -> Markup {
105 html! {
106 (DOCTYPE)
107 html lang="en" {
108 head {
109 meta charset="utf-8";
110 meta name="viewport" content="width=device-width, initial-scale=1";
111 title { (code) " — " (heading) }
112 link rel="stylesheet" href="/assets/base.css";
113 link rel="stylesheet" href="/theme.css";
114 }
115 body {
116 div class="error-page" {
117 div class="code" { (code) }
118 h1 { (heading) }
119 p class="muted" { (detail) }
120 p { a href="/" { "Return home" } }
121 }
122 }
123 }
124 }
125}