umbral_core/errors.rs
1//! Custom 404 / 500 page helpers.
2//!
3//! Two pieces of [`AppBuilder`](crate::app::AppBuilder) state plug
4//! together with the existing templates engine to deliver the
5//! "drop a `404.html` in your templates dir" experience:
6//!
7//! - `not_found_template(name)` — installs a fallback that renders
8//! the named template with `{ path }` in scope and returns 404.
9//! - `server_error_template(name)` — wraps the router with a
10//! panic-catching tower-http layer that renders the named template
11//! on any handler panic and returns 500.
12//!
13//! Gap 35 extensions:
14//!
15//! - `on_server_error(hook)` — an opt-in hook that fires before the 500
16//! template is rendered. The closure receives the error message and the
17//! request path. Runs synchronously on the error path (panic or `Err`
18//! propagated as a 500). Cannot change the response; used for logging,
19//! Sentry dispatch, etc.
20//! - Default Tailwind 404/500 templates — shipped as embedded strings so
21//! they work without any `templates/` directory on disk. Used when the
22//! user hasn't set their own template name via the builder. Opt-out via
23//! `App::builder().disable_default_error_pages()`.
24//! - Dev-mode error detail — when `settings.environment == Dev`, the 500
25//! template receives an `error_chain` context variable listing the full
26//! `std::error::Error` source chain. In prod the variable is empty.
27//!
28//! Both the 404 and 500 fallbacks are opt-in. When unset (and default
29//! pages are disabled), the fallback returns plain-text "Not Found" and
30//! panics propagate axum-style (log + empty 500 body).
31//!
32//! The 404 path composes with [`SlashRedirect`](crate::slash::SlashRedirect)
33//! — if the redirect probe finds an alternate, it 308s; otherwise the
34//! configured not-found template renders. Users get one consistent
35//! 404 page across normal misses and slash-redirect dead-ends.
36
37use std::any::Any;
38
39use axum::body::Body;
40use axum::http::{Request, Response, StatusCode, header};
41use axum::response::IntoResponse;
42use minijinja::context;
43
44// ─── Embedded default templates ─────────────────────────────────────────────
45
46/// Default 404 page: centered, inline Tailwind utility classes. Degrades
47/// gracefully without Tailwind loaded — the page is functional even as
48/// unstyled HTML.
49pub const DEFAULT_404_HTML: &str = include_str!("templates/defaults/default_404.html");
50
51/// Default 500 page: same shape as the 404. In dev mode the template
52/// receives `error_display`, `error_chain` (vec of source strings), and
53/// `request_path` context variables that render into an expandable detail
54/// block. In prod those variables are empty strings / empty vecs.
55pub const DEFAULT_500_HTML: &str = include_str!("templates/defaults/default_500.html");
56
57// Template names used when registering the defaults into minijinja.
58// `pub` so integration tests can verify the constants without duplicating
59// the string literals.
60pub const DEFAULT_404_TEMPLATE_NAME: &str = "__umbral__/default_404.html";
61pub const DEFAULT_500_TEMPLATE_NAME: &str = "__umbral__/default_500.html";
62
63// ─── On-server-error hook type ───────────────────────────────────────────────
64
65/// Shared callback type for the `on_server_error` hook.
66///
67/// The hook fires on every 500 — both panics and handler errors that are
68/// turned into a 500 response — before the template is rendered.
69///
70/// Arguments:
71/// - `error_display`: the `Display` form of the error, or the stringified
72/// panic payload.
73/// - `request_path`: the URI path of the failing request.
74pub type ServerErrorHook = std::sync::Arc<dyn Fn(&str, &str) + Send + Sync + 'static>;
75
76// ─── Ambient default-pages flag ─────────────────────────────────────────────
77
78use std::sync::OnceLock;
79
80/// Whether the default error pages are enabled. Set during `App::build()`.
81/// `true` (default) — use the embedded templates when the user hasn't
82/// supplied their own. `false` — user called `.disable_default_error_pages()`.
83static DEFAULT_PAGES_ENABLED: OnceLock<bool> = OnceLock::new();
84
85/// Publish the default-pages flag. Called by `AppBuilder::build()` only.
86pub(crate) fn init_default_pages(enabled: bool) {
87 // Ignore the error if already set (e.g. two App builds in the same
88 // process in tests). The first caller wins, matching the OnceLock
89 // contract everywhere else in the framework.
90 let _ = DEFAULT_PAGES_ENABLED.set(enabled);
91}
92
93/// Return whether default pages are enabled.
94pub(crate) fn default_pages_enabled() -> bool {
95 // When called outside App::build() (unit tests that exercise render_*
96 // directly), default to true so the helpers behave like a real app.
97 *DEFAULT_PAGES_ENABLED.get().unwrap_or(&true)
98}
99
100// ─── 404 helpers ────────────────────────────────────────────────────────────
101
102/// Render the configured 404 template with `{ path }` in scope, or
103/// fall back to the plain-text response when no template is set or
104/// rendering fails.
105///
106/// When `template` is `None` and the default pages are enabled, the
107/// framework's own `default_404.html` is rendered instead. When
108/// default pages are disabled and no template name is set, returns
109/// plain "Not Found".
110///
111/// Used by the not-found fallback installed at build Phase 5.6 — which
112/// serves both the plain-404 case and, since the slash-redirect probe
113/// moved into a layer (gaps4 #50), the no-alternate branch of a
114/// slash-redirect app (the layer passes the rendered 404 through
115/// untouched when the alternate form doesn't answer).
116///
117/// The template gets the request path as `path` so it can render
118/// `The page {{ path }} doesn't exist.` without the user wiring
119/// extractors. Other request state isn't exposed yet — the v1 shape
120/// is intentionally narrow.
121pub fn render_not_found(template: Option<&str>, path: &str) -> Response<Body> {
122 // Resolve the effective template name:
123 // 1. User-supplied name takes highest priority.
124 // 2. Embedded default (registered as __umbral__/default_404.html) when
125 // default pages are enabled.
126 // 3. Plain-text fallback.
127 let effective_template = template.or_else(|| {
128 if default_pages_enabled() {
129 Some(DEFAULT_404_TEMPLATE_NAME)
130 } else {
131 None
132 }
133 });
134
135 // Derive Content-Type from whether render actually produced HTML.
136 // When the engine isn't initialised or the template fails to render,
137 // the fallback "Not Found" body is plaintext; it would be wrong to
138 // ship it as text/html.
139 //
140 // In dev mode, surface the registered-route registry so a
141 // developer who hits a typoed URL can see what's actually
142 // available. Production responses stay minimal — `dev_mode` is
143 // false there, so the template's `{% if dev_mode %}` block
144 // collapses to nothing.
145 let dev_mode = crate::settings::get_opt()
146 .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
147 .unwrap_or(false);
148 let routes_ctx: Vec<minijinja::Value> = if dev_mode {
149 crate::routes::get()
150 .map(|reg| {
151 reg.by_plugin
152 .iter()
153 .filter(|(_, specs)| !specs.is_empty())
154 .map(|(plugin, specs)| {
155 // Pre-shape each route entry for the
156 // template's loop: a path string and a
157 // pre-joined method label. Pre-joining here
158 // lets the template render the badge with a
159 // single `{{ route.method_label }}` access
160 // instead of nesting another for-loop.
161 let routes: Vec<minijinja::Value> = specs
162 .iter()
163 .map(|s| {
164 let method_label = if s.methods.is_empty() {
165 "ANY".to_string()
166 } else {
167 s.methods.join("·")
168 };
169 minijinja::context! {
170 path => s.path.as_str(),
171 methods => s.methods.clone(),
172 method_label => method_label,
173 }
174 })
175 .collect();
176 minijinja::context! {
177 plugin => plugin.as_str(),
178 routes => routes,
179 }
180 })
181 .collect()
182 })
183 .unwrap_or_default()
184 } else {
185 Vec::new()
186 };
187 let ctx = context! {
188 path => path,
189 dev_mode => dev_mode,
190 routes_by_plugin => routes_ctx,
191 };
192 let (body, content_type) = effective_template
193 .and_then(|name| match crate::templates::render(name, &ctx) {
194 Ok(html) => Some(html),
195 // Falling back to plain text is intentional (no double-fault on
196 // the error path), but a broken error template should leave a
197 // trace rather than silently degrade.
198 Err(e) => {
199 tracing::warn!(
200 "error-page template `{name}` failed to render ({e}); \
201 falling back to plain text"
202 );
203 None
204 }
205 })
206 .map(|html| (html, "text/html; charset=utf-8"))
207 .unwrap_or_else(|| ("Not Found".to_string(), "text/plain; charset=utf-8"));
208
209 let mut response = Response::new(Body::from(body));
210 *response.status_mut() = StatusCode::NOT_FOUND;
211 response.headers_mut().insert(
212 header::CONTENT_TYPE,
213 content_type.parse().expect("valid content-type"),
214 );
215 response
216}
217
218/// Build an axum fallback handler that renders the configured 404
219/// template. Used when `not_found_template` is set but
220/// `slash_redirect` is `Off` — `App::build` skips the slash redirect
221/// path and installs this directly.
222pub fn not_found_fallback(
223 template: Option<String>,
224) -> impl Fn(
225 Request<Body>,
226) -> std::pin::Pin<Box<dyn std::future::Future<Output = Response<Body>> + Send>>
227+ Clone
228+ Send
229+ Sync
230+ 'static {
231 move |req: Request<Body>| {
232 let template = template.clone();
233 Box::pin(async move {
234 let path = req.uri().path().to_owned();
235 render_not_found(template.as_deref(), &path)
236 })
237 }
238}
239
240// ─── 500 helpers ────────────────────────────────────────────────────────────
241
242/// Walk the `std::error::Error::source()` chain and collect every
243/// `Display` message into a `Vec<String>`. The first entry is the top-level
244/// error itself; subsequent entries are its causes.
245///
246/// Used by the handler-error path (where `Err` variants produce 500s) to
247/// surface the full cause chain in dev-mode 500 pages. The panic path uses
248/// a synthetic single-element chain instead (panics aren't `dyn Error`).
249pub fn collect_error_chain(top: &str, mut source: Option<&dyn std::error::Error>) -> Vec<String> {
250 let mut chain = vec![top.to_owned()];
251 while let Some(cause) = source {
252 chain.push(cause.to_string());
253 source = cause.source();
254 }
255 chain
256}
257
258/// Determine whether the current settings are dev mode.
259///
260/// Returns `false` when the settings OnceLock isn't initialised (i.e. tests
261/// that exercise the 500 helpers directly without calling `App::build`).
262fn is_dev_mode() -> bool {
263 crate::settings::SETTINGS
264 .get()
265 .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
266 .unwrap_or(false)
267}
268
269/// Build the template context for a 500 response.
270///
271/// In dev mode, `error_display`, `error_chain` (Vec<String>), and
272/// `request_path` are populated. In prod they are empty string / empty
273/// vec / empty string so the template's conditional block collapses
274/// to nothing.
275fn build_500_context(
276 error_display: &str,
277 error_chain: &[String],
278 request_path: &str,
279 dev: bool,
280) -> minijinja::Value {
281 if dev {
282 context! {
283 dev_mode => true,
284 error_display => error_display,
285 error_chain => error_chain,
286 request_path => request_path,
287 }
288 } else {
289 context! {
290 dev_mode => false,
291 error_display => "",
292 error_chain => Vec::<String>::new(),
293 request_path => "",
294 }
295 }
296}
297
298/// Render the 500 template with the given context.
299///
300/// Resolves the effective template name the same way `render_not_found`
301/// resolves the 404: user-supplied name → embedded default → plain text.
302/// If the chosen template itself errors during render (the
303/// recovery-path-failed case: usually a `{% extends "wrapper.html" %}`
304/// that breaks because wrapper.html shares the bug that fired the
305/// original 500), the secondary error gets `tracing::error!`'d AND
306/// — when dev mode is on — embedded in the plain-text fallback body
307/// so the developer sees the recovery failure inline instead of
308/// staring at a generic "Internal Server Error" while the real
309/// chain hides in the logs.
310fn render_500(template: Option<&str>, ctx: &minijinja::Value) -> (String, &'static str) {
311 let effective = template.or_else(|| {
312 if default_pages_enabled() {
313 Some(DEFAULT_500_TEMPLATE_NAME)
314 } else {
315 None
316 }
317 });
318
319 let Some(name) = effective else {
320 return (
321 "Internal Server Error".to_string(),
322 "text/plain; charset=utf-8",
323 );
324 };
325
326 match crate::templates::render(name, ctx) {
327 Ok(html) => (html, "text/html; charset=utf-8"),
328 Err(secondary) => {
329 // The secondary failure WAS being silently swallowed by
330 // `.ok()`. Loud-fail it instead — the operator needs to
331 // see both errors (the original handler 500 already
332 // logged in `render_500_middleware`, plus this one).
333 tracing::error!(
334 template = %name,
335 error = %secondary,
336 "render_500: secondary template render failed; the configured \
337 server-error template can't render itself. Likely a broken \
338 `{{% extends \"wrapper.html\" %}}` chain. Falling back to \
339 plain text.",
340 );
341 if is_dev_mode() {
342 // In dev, include both errors in the body so the
343 // user doesn't have to grep server logs to see why
344 // their 500 page didn't render.
345 let body = format!(
346 "Internal Server Error\n\n\
347 (dev) The configured 500 template `{name}` itself failed \
348 to render: {secondary}\n\n\
349 Check the original handler error in the server logs \
350 (line above this one) for the trigger."
351 );
352 (body, "text/plain; charset=utf-8")
353 } else {
354 (
355 "Internal Server Error".to_string(),
356 "text/plain; charset=utf-8",
357 )
358 }
359 }
360 }
361}
362
363/// Build the panic-handler closure for
364/// `tower_http::catch_panic::CatchPanicLayer::custom`.
365///
366/// Renders the configured `server_error_template` (or the built-in default
367/// when enabled) with optional dev-mode error context. Before rendering,
368/// calls the `on_server_error` hook if one was registered.
369///
370/// In dev mode the template receives:
371/// - `dev_mode: true`
372/// - `error_display`: the stringified panic payload
373/// - `error_chain`: `[error_display]` (panics have no error chain)
374/// - `request_path`: empty string (not available in a panic handler)
375///
376/// In prod, all three are empty.
377pub fn server_error_panic_handler(
378 template: Option<String>,
379 hook: Option<ServerErrorHook>,
380) -> impl Fn(Box<dyn Any + Send + 'static>) -> Response<Body> + Clone + Send + Sync + 'static {
381 move |err: Box<dyn Any + Send + 'static>| {
382 // Extract a human-readable panic message for the log line.
383 let panic_message = if let Some(s) = err.downcast_ref::<&'static str>() {
384 (*s).to_string()
385 } else if let Some(s) = err.downcast_ref::<String>() {
386 s.clone()
387 } else {
388 "<non-string panic payload>".to_string()
389 };
390 tracing::error!(
391 panic_message = %panic_message,
392 "handler panicked; serving 500 page",
393 );
394
395 // Fire the on_server_error hook before rendering.
396 if let Some(ref h) = hook {
397 h(&panic_message, "");
398 }
399
400 let dev = is_dev_mode();
401 let chain = vec![panic_message.clone()];
402 let ctx = build_500_context(&panic_message, &chain, "", dev);
403 let (body, content_type) = render_500(template.as_deref(), &ctx);
404
405 (
406 StatusCode::INTERNAL_SERVER_ERROR,
407 [(header::CONTENT_TYPE, content_type)],
408 body,
409 )
410 .into_response()
411 }
412}
413
414/// Build an axum fallback or middleware that converts a handler `Err`
415/// response into a 500 with optional dev-mode detail and hook notification.
416///
417/// Used internally when a handler returns a type that produces a 500
418/// status code (e.g. `(StatusCode::INTERNAL_SERVER_ERROR, body)`). The
419/// wrapper intercepts 500 responses, fires the hook if set, and optionally
420/// re-renders them through the 500 template.
421///
422/// Because axum handlers choose their own `IntoResponse` impl, this path
423/// is specifically for handlers that return
424/// `(StatusCode::INTERNAL_SERVER_ERROR, ...)` tuples. Panics are caught
425/// by the `CatchPanicLayer` above.
426///
427/// Note: this function is primarily used by the test suite to verify that
428/// `on_server_error` fires for handler errors. In production the hook is
429/// most naturally wired through a middleware.
430pub fn fire_server_error_hook(hook: &Option<ServerErrorHook>, error_msg: &str, path: &str) {
431 if let Some(h) = hook {
432 h(error_msg, path);
433 }
434}
435
436// ─── Response-rendering middleware (handler-Err path) ───────────────────────
437
438/// State for the response-rendering middleware. Cloned per-request; both
439/// fields are cheap to clone (`Option<String>` + `Option<Arc<...>>`).
440#[derive(Clone)]
441pub struct Render500State {
442 pub template: Option<String>,
443 pub hook: Option<ServerErrorHook>,
444}
445
446/// Turn an error response body into a human sentence for an error page.
447///
448/// gaps3 #57. `ApiError` — the error type every handler is supposed to return — renders
449/// as JSON, because its first audience is an API client. The error-page middlewares then
450/// captured that JSON *verbatim* and printed it as the page's message, so a browser hit
451/// a styled 500 that said `{"code":"database_error","error":"internal server error"}`.
452///
453/// That papercut is not cosmetic: it is a reason not to use `ApiError` in an HTML
454/// handler, which leaves the handler hand-rolling `(StatusCode, String)` — and the
455/// hand-rolled version passes `err.to_string()` straight to the browser, leaking table
456/// names and SQL fragments. The ugly page pushed people toward the leaky pattern.
457///
458/// So: if the body is a JSON object carrying a message, use that message. Anything else
459/// (plain text, an empty body, non-JSON) passes through untouched.
460fn humanize_error_body(raw: &str) -> String {
461 let trimmed = raw.trim();
462 if !trimmed.starts_with('{') {
463 return raw.to_string();
464 }
465 let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) else {
466 return raw.to_string();
467 };
468 // `error` is ApiError's message key; `detail` is the throttling / DRF-style one.
469 for key in ["error", "detail", "message"] {
470 if let Some(msg) = v.get(key).and_then(|m| m.as_str()) {
471 if !msg.is_empty() {
472 return msg.to_string();
473 }
474 }
475 }
476 // A validation failure has no single message — flatten the field errors into
477 // something a human can act on rather than showing them raw JSON.
478 if let Some(fields) = v.get("field_errors").and_then(|f| f.as_object()) {
479 let mut parts: Vec<String> = fields
480 .iter()
481 .map(|(field, msgs)| {
482 let joined = msgs
483 .as_array()
484 .map(|a| {
485 a.iter()
486 .filter_map(|m| m.as_str())
487 .collect::<Vec<_>>()
488 .join(" ")
489 })
490 .unwrap_or_default();
491 format!("{field}: {joined}")
492 })
493 .collect();
494 parts.sort();
495 if !parts.is_empty() {
496 return parts.join("; ");
497 }
498 }
499 raw.to_string()
500}
501
502/// Middleware that intercepts plain-text 500 responses and re-renders them
503/// through the configured `server_error_template` (or the embedded default
504/// when enabled). Already-HTML 500 responses pass through untouched — those
505/// were rendered by `CatchPanicLayer` (panics) or by the handler itself.
506///
507/// This closes the gap where a handler returning
508/// `Err((StatusCode::INTERNAL_SERVER_ERROR, msg))` previously produced a
509/// raw plain-text response instead of the configured 500 page. The
510/// `on_server_error` hook also fires for these paths, with the response
511/// body bytes as the error message and the request URI as the path.
512pub async fn render_500_middleware(
513 axum::extract::State(state): axum::extract::State<Render500State>,
514 req: axum::extract::Request,
515 next: axum::middleware::Next,
516) -> Response<Body> {
517 let path = req.uri().path().to_string();
518 let resp = next.run(req).await;
519
520 if resp.status() != StatusCode::INTERNAL_SERVER_ERROR {
521 return resp;
522 }
523
524 // Already-rendered HTML 500s (from CatchPanicLayer or a custom handler)
525 // pass through. Only the raw text/plain or no-content-type 500s get
526 // re-rendered.
527 let ct = resp
528 .headers()
529 .get(header::CONTENT_TYPE)
530 .and_then(|v| v.to_str().ok())
531 .unwrap_or("");
532 if ct.starts_with("text/html") {
533 return resp;
534 }
535 // A JSON 500 (the framework's own `ApiError::into_response`, or any
536 // REST/JSON handler) must NOT be re-rendered as an HTML page — an API
537 // client that asked for JSON would get an unparseable HTML body. The
538 // sibling `render_error_middleware` guards the same way on `Accept:
539 // application/json`; here the response Content-Type is the tell.
540 if ct.starts_with("application/json") {
541 return resp;
542 }
543
544 // Capture the body to extract the error message for the hook + dev
545 // context. 64KB cap: error messages don't need more, and we don't
546 // want a malicious upstream to OOM us.
547 let (_parts, body) = resp.into_parts();
548 let bytes = axum::body::to_bytes(body, 64 * 1024)
549 .await
550 .unwrap_or_default();
551 let error_msg = humanize_error_body(&String::from_utf8_lossy(&bytes));
552
553 tracing::error!(
554 error = %error_msg,
555 path = %path,
556 "handler returned 500; rendering server-error template",
557 );
558
559 fire_server_error_hook(&state.hook, &error_msg, &path);
560
561 let dev = is_dev_mode();
562 let chain = vec![error_msg.clone()];
563 let ctx = build_500_context(&error_msg, &chain, &path, dev);
564 let (body_str, content_type) = render_500(state.template.as_deref(), &ctx);
565
566 (
567 StatusCode::INTERNAL_SERVER_ERROR,
568 [(header::CONTENT_TYPE, content_type)],
569 body_str,
570 )
571 .into_response()
572}
573
574// ─── General error pages (any status code) ──────────────────────────────────
575
576/// State for the general error-page middleware: a status → template-name map.
577/// Cloned per request (an `Arc`, cheap).
578#[derive(Clone)]
579pub struct RenderErrorState {
580 pub templates: std::sync::Arc<std::collections::HashMap<StatusCode, String>>,
581}
582
583/// Middleware that styles error responses for ANY registered status code
584/// (e.g. 429, 403, 410) the way [`render_500_middleware`] does for 500. After
585/// the handler runs, if the response status has a registered template and the
586/// body isn't already HTML, the body text is captured as the `message` and the
587/// template is rendered in its place — preserving the original status code.
588///
589/// Registered via `App::builder().error_template(status, "name.html")`. 404
590/// and 500 keep their dedicated paths (`not_found_template` /
591/// `server_error_template`); this covers everything else a handler returns as
592/// `Err((status, message))`.
593pub async fn render_error_middleware(
594 axum::extract::State(state): axum::extract::State<RenderErrorState>,
595 req: axum::extract::Request,
596 next: axum::middleware::Next,
597) -> Response<Body> {
598 let path = req.uri().path().to_string();
599 // API / AJAX clients (Accept: application/json) keep the raw status +
600 // message body so they can read it programmatically; only browser
601 // navigations (Accept: text/html, the default) get the styled HTML page.
602 let wants_json = req
603 .headers()
604 .get(header::ACCEPT)
605 .and_then(|v| v.to_str().ok())
606 .map(|a| a.contains("application/json"))
607 .unwrap_or(false);
608 let resp = next.run(req).await;
609
610 let status = resp.status();
611 let Some(template) = state.templates.get(&status).cloned() else {
612 return resp;
613 };
614 if wants_json {
615 return resp;
616 }
617
618 // Already-HTML error responses (a handler that rendered its own page) pass
619 // through untouched — only bare text/plain (or no content-type) errors get
620 // the styled template.
621 let ct = resp
622 .headers()
623 .get(header::CONTENT_TYPE)
624 .and_then(|v| v.to_str().ok())
625 .unwrap_or("");
626 if ct.starts_with("text/html") {
627 return resp;
628 }
629
630 // Capture the body (the handler's message). 64KB cap, same as the 500 path.
631 let (_parts, body) = resp.into_parts();
632 let bytes = axum::body::to_bytes(body, 64 * 1024)
633 .await
634 .unwrap_or_default();
635 let message = humanize_error_body(&String::from_utf8_lossy(&bytes));
636
637 let ctx = error_context(status, &message, &path, is_dev_mode());
638 let (body_str, content_type) = render_error_page(&template, status, &ctx);
639
640 (status, [(header::CONTENT_TYPE, content_type)], body_str).into_response()
641}
642
643/// Template context for a general error page: `{ status, status_text, message,
644/// request_path, dev_mode }`.
645fn error_context(status: StatusCode, message: &str, path: &str, dev: bool) -> minijinja::Value {
646 // WEB-5 safe-by-default: outside dev, blank the handler body + request
647 // path for SERVER errors (5xx) — a hand-rolled 500 body can carry raw
648 // error text (table/column/SQL fragments), and leaking it to the client
649 // in prod is exactly what the dedicated 500 path (`build_500_context`)
650 // guards against. 4xx client errors keep their (intentional) message.
651 let expose = dev || !status.is_server_error();
652 minijinja::context! {
653 status => status.as_u16(),
654 status_text => status.canonical_reason().unwrap_or(""),
655 message => if expose { message } else { "" },
656 request_path => if expose { path } else { "" },
657 dev_mode => dev,
658 }
659}
660
661/// Render `template` for an error page, falling back to the status' canonical
662/// reason phrase as plain text if the template can't render. Mirrors the
663/// loud-fail posture of [`render_500`].
664fn render_error_page(
665 template: &str,
666 status: StatusCode,
667 ctx: &minijinja::Value,
668) -> (String, &'static str) {
669 match crate::templates::render(template, ctx) {
670 Ok(html) => (html, "text/html; charset=utf-8"),
671 Err(secondary) => {
672 tracing::error!(
673 template = %template,
674 status = %status.as_u16(),
675 error = %secondary,
676 "render_error_page: the configured error template failed to render; \
677 falling back to plain text",
678 );
679 let reason = status.canonical_reason().unwrap_or("Error");
680 (reason.to_string(), "text/plain; charset=utf-8")
681 }
682 }
683}
684
685// ─── Tests ──────────────────────────────────────────────────────────────────
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn error_context_carries_status_reason_message_and_path() {
693 let ctx = error_context(
694 StatusCode::TOO_MANY_REQUESTS,
695 "slow down",
696 "/p/notes",
697 false,
698 );
699 let mut env = minijinja::Environment::new();
700 env.add_template(
701 "t",
702 "{{ status }}|{{ status_text }}|{{ message }}|{{ request_path }}|{{ dev_mode }}",
703 )
704 .unwrap();
705 let out = env.get_template("t").unwrap().render(ctx).unwrap();
706 assert_eq!(out, "429|Too Many Requests|slow down|/p/notes|false");
707 }
708
709 #[test]
710 fn render_error_page_falls_back_to_plain_text_when_template_cant_render() {
711 // No ambient template engine in this unit test, so `render()` errors
712 // and we land on the canonical-reason plain-text fallback.
713 let ctx = error_context(StatusCode::TOO_MANY_REQUESTS, "msg", "/x", false);
714 let (body, ct) = render_error_page("nonexistent.html", StatusCode::TOO_MANY_REQUESTS, &ctx);
715 assert!(ct.starts_with("text/plain"), "content-type: {ct}");
716 assert_eq!(body, "Too Many Requests");
717 }
718
719 #[test]
720 fn render_not_found_returns_plain_text_when_no_template() {
721 let resp = render_not_found(None, "/missing");
722 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
723 let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
724 assert!(ct.to_str().unwrap().starts_with("text/plain"));
725 }
726
727 #[test]
728 fn render_not_found_falls_back_to_plain_text_when_template_missing() {
729 // No templates engine initialised in this test — render() errors
730 // out, so we should land on the plain-text fallback even though
731 // a template name was provided.
732 let resp = render_not_found(Some("nonexistent.html"), "/x");
733 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
734 }
735
736 #[test]
737 fn default_404_renders_route_panel_when_dev_mode_and_registry_populated() {
738 // Render the embedded default template through a fresh
739 // minijinja environment so the test doesn't depend on the
740 // (OnceLock-published) global engine state. We feed the same
741 // ctx shape `render_not_found` builds in dev mode and assert
742 // the route list lands in the output.
743 let mut env = minijinja::Environment::new();
744 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
745 env.add_template("default_404.html", DEFAULT_404_HTML)
746 .unwrap();
747
748 let ctx = minijinja::context! {
749 path => "/typo",
750 dev_mode => true,
751 routes_by_plugin => serde_json::json!([
752 {
753 "plugin": "app",
754 "routes": [
755 { "path": "/", "methods": ["GET"], "method_label": "GET" },
756 { "path": "/articles", "methods": ["GET","POST"], "method_label": "GET·POST" },
757 ],
758 },
759 {
760 "plugin": "admin",
761 "routes": [
762 { "path": "/admin/", "methods": ["GET"], "method_label": "GET" },
763 { "path": "/admin/login", "methods": ["GET","POST"], "method_label": "GET·POST" },
764 ],
765 },
766 ]),
767 };
768 let out = env
769 .get_template("default_404.html")
770 .unwrap()
771 .render(&ctx)
772 .unwrap();
773
774 // minijinja's HTML autoescape encodes `/` as `/` inside
775 // text nodes — the assertion checks the escaped form (which is
776 // what the browser will then unescape and display verbatim).
777 assert!(
778 out.contains("Dev only"),
779 "dev-mode panel header should be in the output"
780 );
781 assert!(
782 out.contains("/admin/login"),
783 "admin route should be listed: {out}"
784 );
785 assert!(
786 out.contains("/articles"),
787 "app route should be listed: {out}"
788 );
789 // Method badges land in the markup.
790 assert!(
791 out.contains("GET·POST"),
792 "composite-method badge label should render: {out}"
793 );
794 // GET-coloured badge applied to the bare-GET row.
795 assert!(
796 out.contains("emerald"),
797 "GET badge should carry the emerald tint class"
798 );
799 }
800
801 #[test]
802 fn default_404_copy_button_does_not_interpolate_path_into_js() {
803 // Regression for the reflected-XSS finding: the request path is
804 // fully attacker-controlled and must never cross into a
805 // JavaScript-string context. HTML autoescape only guards the
806 // HTML-text/attribute context; the browser HTML-decodes an
807 // `onclick` attribute before the JS parser sees it, so an escaped
808 // quote still closes a JS string literal. The copy button must
809 // therefore read the path from the inert DOM text node rather
810 // than have it interpolated into `writeText('...')`.
811 let mut env = minijinja::Environment::new();
812 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
813 env.add_template("default_404.html", DEFAULT_404_HTML)
814 .unwrap();
815
816 // A path crafted to break out of `writeText('…')` and execute JS.
817 let ctx = minijinja::context! {
818 path => "/x');alert(document.domain);('",
819 dev_mode => false,
820 routes_by_plugin => Vec::<minijinja::Value>::new(),
821 };
822 let out = env
823 .get_template("default_404.html")
824 .unwrap()
825 .render(&ctx)
826 .unwrap();
827
828 // No path data may be interpolated into a JS string literal.
829 // The literal `writeText('` prefix only exists in the vulnerable
830 // form where `{{ path }}` was inlined between the quotes; the
831 // fixed handler reads `writeText(document…textContent)`.
832 assert!(
833 !out.contains("writeText('"),
834 "copy button must not interpolate the path into a JS string literal: {out}"
835 );
836 // The raw (un-encoded) payload must not appear anywhere in the
837 // output — the safe contexts (title/text node) HTML-encode it.
838 assert!(
839 !out.contains("');alert(document.domain);('"),
840 "un-encoded JS-breakout payload leaked into the output: {out}"
841 );
842 }
843
844 #[test]
845 fn default_500_copy_button_does_not_interpolate_path_into_js() {
846 // Same JS-string-context guard as the 404 test. `request_path` is
847 // blanked in production, but dev/staging servers are frequently
848 // exposed, so the copy button must never inline the path into JS.
849 let mut env = minijinja::Environment::new();
850 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
851 env.add_template("default_500.html", DEFAULT_500_HTML)
852 .unwrap();
853
854 let ctx = minijinja::context! {
855 dev_mode => true,
856 error_display => "boom",
857 error_chain => vec!["boom".to_owned()],
858 request_path => "/x');alert(document.domain);('",
859 };
860 let out = env
861 .get_template("default_500.html")
862 .unwrap()
863 .render(&ctx)
864 .unwrap();
865
866 assert!(
867 !out.contains("writeText('"),
868 "copy button must not interpolate request_path into a JS string literal: {out}"
869 );
870 assert!(
871 !out.contains("');alert(document.domain);('"),
872 "un-encoded JS-breakout payload leaked into the output: {out}"
873 );
874 }
875
876 #[test]
877 fn default_404_omits_route_panel_when_dev_mode_is_off() {
878 // Same template, but `dev_mode = false` — the panel block must
879 // collapse to nothing. The page should still render the path
880 // and the action buttons (those are outside the gated block).
881 let mut env = minijinja::Environment::new();
882 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
883 env.add_template("default_404.html", DEFAULT_404_HTML)
884 .unwrap();
885
886 let ctx = minijinja::context! {
887 path => "/typo",
888 dev_mode => false,
889 routes_by_plugin => Vec::<minijinja::Value>::new(),
890 };
891 let out = env
892 .get_template("default_404.html")
893 .unwrap()
894 .render(&ctx)
895 .unwrap();
896
897 assert!(
898 !out.contains("Dev only"),
899 "production response must not surface the route registry"
900 );
901 }
902
903 #[test]
904 fn collect_error_chain_single_level() {
905 let chain = collect_error_chain("top error", None);
906 assert_eq!(chain, vec!["top error"]);
907 }
908
909 #[test]
910 fn build_500_context_prod_mode_has_empty_fields() {
911 let ctx = build_500_context("boom", &["boom".to_owned()], "/path", false);
912 // Serialize to JSON and inspect: prod mode has dev_mode=false and
913 // empty error_display.
914 let json = serde_json::to_value(&ctx).expect("context serialises");
915 assert_eq!(json["dev_mode"], serde_json::Value::Bool(false));
916 assert_eq!(
917 json["error_display"],
918 serde_json::Value::String("".to_string())
919 );
920 }
921
922 #[test]
923 fn build_500_context_dev_mode_has_error_info() {
924 let chain = vec!["cause one".to_owned(), "cause two".to_owned()];
925 let ctx = build_500_context("top error", &chain, "/api/items", true);
926 let json = serde_json::to_value(&ctx).expect("context serialises");
927 assert_eq!(json["dev_mode"], serde_json::Value::Bool(true));
928 assert_eq!(
929 json["error_display"],
930 serde_json::Value::String("top error".to_string())
931 );
932 // error_chain should be a two-element array
933 let arr = json["error_chain"]
934 .as_array()
935 .expect("error_chain is array");
936 assert_eq!(arr.len(), 2);
937 }
938}