Skip to main content

acme_proxy/webadmin/pages/
error.rs

1//! The page layer's error type.
2//!
3//! Deliberately **not** [`AdminError`], for the same reason `AdminError` is not
4//! [`crate::error::Problem`]: its `IntoResponse` is hardcoded to
5//! `application/json`, and a browser navigating to `/ui/accounts` without a
6//! session must land on the sign-in page, not on a JSON document.
7//!
8//! ## Why the shape is chosen at construction
9//!
10//! There are two kinds of caller and they need two different answers to a `401`:
11//!
12//! - a **browser** following a link wants `303 See Other` and a `Location`;
13//! - an **htmx** request wants an `HX-Redirect` header, which makes htmx do a
14//!   real navigation rather than swapping the sign-in page into a `<div>`.
15//!
16//! A `303` cannot serve both: `fetch` follows a redirect transparently, so htmx
17//! would only ever see the *final* response's headers and would swap the
18//! sign-in page's markup wherever the click came from. The page extractors have
19//! `parts.headers` in hand and read `HX-Request` there, which is why the
20//! decision lives at construction rather than in `into_response`.
21//!
22//! ## Why it renders no template
23//!
24//! The error document is a `const` with two holes, not a `minijinja` render.
25//! An error path that can itself fail is not an error path — the same reasoning
26//! that keeps `AdminError`'s body a `serde_json::json!` literal.
27
28use axum::http::{StatusCode, header};
29use axum::response::{Html, IntoResponse, Response};
30
31use crate::webadmin::error::AdminError;
32
33/// A failed page request.
34#[derive(Debug, PartialEq, Eq, thiserror::Error)]
35pub enum PageError {
36    /// Sign-in is needed: `303` + `Location` for a browser, `HX-Redirect` for
37    /// htmx.
38    #[error("redirect: {location}")]
39    Redirect { location: String, hx: bool },
40    /// Anything else: the real status, plus a standalone HTML document.
41    #[error("{code}: {message}")]
42    Rendered {
43        status: StatusCode,
44        /// The same stable snake_case code the JSON API uses, so an operator
45        /// reporting a problem quotes one string whichever front end they were
46        /// on.
47        code: &'static str,
48        message: String,
49    },
50}
51
52/// Where an unauthenticated request is sent.
53pub const LOGIN_PATH: &str = "/ui/login";
54
55impl PageError {
56    /// The redirect an expired or absent session produces.
57    #[must_use]
58    pub fn login_required(hx: bool) -> Self {
59        Self::Redirect {
60            location: LOGIN_PATH.to_string(),
61            hx,
62        }
63    }
64
65    /// `404` — no such row.
66    #[must_use]
67    pub fn not_found(message: impl Into<String>) -> Self {
68        Self::Rendered {
69            status: StatusCode::NOT_FOUND,
70            code: "not_found",
71            message: message.into(),
72        }
73    }
74
75    /// `400` — the request named something that does not exist, and said so
76    /// itself (an unparseable filter, not a missing row).
77    #[must_use]
78    pub fn bad_request(message: impl Into<String>) -> Self {
79        Self::Rendered {
80            status: StatusCode::BAD_REQUEST,
81            code: "bad_request",
82            message: message.into(),
83        }
84    }
85
86    /// `500` — anything the operator can only find in the log.
87    #[must_use]
88    pub fn internal() -> Self {
89        Self::Rendered {
90            status: StatusCode::INTERNAL_SERVER_ERROR,
91            code: "internal",
92            message: "internal error".to_string(),
93        }
94    }
95
96    /// The status this will answer with, which for a redirect is the redirect's
97    /// own — useful mostly to tests.
98    #[must_use]
99    pub fn status(&self) -> StatusCode {
100        match self {
101            // 204 rather than 200: htmx must not swap anything, it must
102            // navigate.
103            Self::Redirect { hx: true, .. } => StatusCode::NO_CONTENT,
104            Self::Redirect { hx: false, .. } => StatusCode::SEE_OTHER,
105            Self::Rendered { status, .. } => *status,
106        }
107    }
108}
109
110/// A `401` from the API layer means "sign in"; everything else keeps its status.
111///
112/// This is the one place the two error vocabularies meet, and it is a
113/// conversion rather than a shared type on purpose: `AdminError` answers a
114/// script, `PageError` answers a browser, and the day one grows a member the
115/// other should not have, nothing has to be untangled.
116impl From<AdminError> for PageError {
117    fn from(error: AdminError) -> Self {
118        Self::Rendered {
119            status: error.status,
120            code: error.code,
121            message: error.message,
122        }
123    }
124}
125
126/// Routed through [`AdminError`] so the `admin_db_error` log line — which
127/// carries the table and column names the body must not — still happens exactly
128/// once.
129impl From<sqlx::Error> for PageError {
130    fn from(error: sqlx::Error) -> Self {
131        AdminError::from(error).into()
132    }
133}
134
135impl IntoResponse for PageError {
136    fn into_response(self) -> Response {
137        let status = self.status();
138        let mut response = match self {
139            Self::Redirect { location, hx } => redirect(&location, hx),
140            Self::Rendered { code, message, .. } => {
141                (status, Html(document(status, code, &message))).into_response()
142            }
143        };
144
145        // Every admin response is `no-store` at the layer level, but an
146        // extractor rejection never reaches it.
147        response.headers_mut().insert(
148            header::CACHE_CONTROL,
149            header::HeaderValue::from_static("no-store"),
150        );
151        response
152    }
153}
154
155/// A navigation the browser should perform, expressed the way the caller can
156/// actually obey.
157///
158/// Shared by the failure path above and by the successes that end somewhere
159/// else — signing in, signing out, deleting the row whose page you were on. The
160/// `hx` branch is the whole reason this is a function: see the module docs.
161pub(crate) fn redirect(location: &str, hx: bool) -> Response {
162    let Ok(value) = header::HeaderValue::from_str(location) else {
163        // Every caller passes a constant or a path this crate built; an
164        // unencodable one is a bug, and a `500` beats a panic in a handler.
165        tracing::error!(
166            event = "admin_redirect_unencodable",
167            outcome = "failure",
168            location = location
169        );
170        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
171    };
172
173    if hx {
174        // `204` so htmx swaps nothing and navigates instead.
175        (StatusCode::NO_CONTENT, [("hx-redirect", value)]).into_response()
176    } else {
177        (StatusCode::SEE_OTHER, [(header::LOCATION, value)]).into_response()
178    }
179}
180
181/// The error page: no template engine, no context, no way to fail.
182fn document(status: StatusCode, code: &str, message: &str) -> String {
183    format!(
184        "<!doctype html>\n\
185         <html lang=\"en\">\n\
186         <head><meta charset=\"utf-8\"><title>{status} — acme-proxy admin</title>\
187         <link rel=\"stylesheet\" href=\"/ui/static/admin.css\"></head>\n\
188         <body><main><h1>{status}</h1>\
189         <div class=\"panel\"><p>{}</p>\
190         <p class=\"muted small\">Error code: <code>{}</code></p></div>\
191         <p class=\"small\"><a href=\"/ui/\">← Back to the panel</a></p>\
192         </main></body></html>\n",
193        escape_html(message),
194        escape_html(code),
195    )
196}
197
198/// Minimal HTML escaping for the error document.
199///
200/// Not decoration: an error message interpolates the id from the path
201/// (`no such account: {id}`), and a path segment is attacker-controlled. The
202/// templates get this from minijinja; this document has no template.
203fn escape_html(raw: &str) -> String {
204    let mut out = String::with_capacity(raw.len());
205    for character in raw.chars() {
206        match character {
207            '&' => out.push_str("&amp;"),
208            '<' => out.push_str("&lt;"),
209            '>' => out.push_str("&gt;"),
210            '"' => out.push_str("&quot;"),
211            '\'' => out.push_str("&#x27;"),
212            other => out.push(other),
213        }
214    }
215    out
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use axum::body::to_bytes;
222
223    async fn body_of(error: PageError) -> String {
224        let response = error.into_response();
225        let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
226        String::from_utf8(bytes.to_vec()).unwrap()
227    }
228
229    #[test]
230    fn a_browser_redirect_is_a_see_other_with_a_location() {
231        let response = PageError::login_required(false).into_response();
232        assert_eq!(response.status(), StatusCode::SEE_OTHER);
233        assert_eq!(response.headers()["location"], LOGIN_PATH);
234        assert!(!response.headers().contains_key("hx-redirect"));
235        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
236    }
237
238    /// The pair the whole type exists for: htmx must navigate, not swap.
239    ///
240    /// A `303` here would be followed by `fetch` before htmx ever saw a header,
241    /// and the sign-in page would be swapped into whatever element the click
242    /// came from.
243    #[test]
244    fn an_htmx_redirect_is_a_header_and_no_body() {
245        let response = PageError::login_required(true).into_response();
246        assert_eq!(response.status(), StatusCode::NO_CONTENT);
247        assert_eq!(response.headers()["hx-redirect"], LOGIN_PATH);
248        assert!(!response.headers().contains_key("location"));
249    }
250
251    #[tokio::test]
252    async fn a_rendered_error_is_an_html_document_carrying_its_code() {
253        let error = PageError::not_found("no such account: acct-1");
254        assert_eq!(error.status(), StatusCode::NOT_FOUND);
255
256        let body = body_of(error).await;
257        assert!(body.starts_with("<!doctype html>"));
258        assert!(body.contains("no such account: acct-1"));
259        assert!(body.contains("<code>not_found</code>"));
260    }
261
262    #[tokio::test]
263    async fn a_message_carrying_markup_is_escaped() {
264        // The id comes from the path, so this is reachable by anyone who can
265        // reach the panel at all.
266        let body = body_of(PageError::not_found(
267            "no such account: <script>alert(1)</script>",
268        ))
269        .await;
270        assert!(!body.contains("<script>"));
271        assert!(body.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
272    }
273
274    #[test]
275    fn escape_html_covers_every_delimiter() {
276        assert_eq!(
277            escape_html(r#"<a href="x" id='y'>&</a>"#),
278            "&lt;a href=&quot;x&quot; id=&#x27;y&#x27;&gt;&amp;&lt;/a&gt;"
279        );
280        assert_eq!(escape_html("plain"), "plain");
281    }
282
283    #[test]
284    fn an_admin_error_keeps_its_status_and_code() {
285        let error: PageError = AdminError::conflict("already_revoked", "already revoked").into();
286        assert_eq!(error.status(), StatusCode::CONFLICT);
287        assert_eq!(
288            error,
289            PageError::Rendered {
290                status: StatusCode::CONFLICT,
291                code: "already_revoked",
292                message: "already revoked".to_string(),
293            }
294        );
295    }
296
297    #[test]
298    fn internal_says_nothing_a_log_should_have_said() {
299        let error = PageError::internal();
300        assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
301        assert_eq!(error.to_string(), "internal: internal error");
302    }
303
304    #[test]
305    fn display_names_the_redirect_target() {
306        assert_eq!(
307            PageError::login_required(true).to_string(),
308            "redirect: /ui/login"
309        );
310    }
311}