Skip to main content

rustlavel_http/
error_page.rs

1//! The development error page.
2//!
3//! When something throws, a developer should see what happened, where, and what
4//! to do about it — not a blank 500. In production the same failure renders as
5//! a plain page with no internals, and the detail goes to the logs instead.
6
7use crate::request::Request;
8use crate::response::Response;
9use crate::status::Status;
10use rustlavel_core::{Error, Json};
11use std::sync::atomic::{AtomicBool, Ordering};
12
13static DEBUG: AtomicBool = AtomicBool::new(false);
14
15/// Enable the detailed page. Set once during boot from `app.debug`.
16pub fn set_debug(enabled: bool) {
17    DEBUG.store(enabled, Ordering::Relaxed);
18}
19
20pub fn debug_enabled() -> bool {
21    DEBUG.load(Ordering::Relaxed)
22}
23
24/// Everything the page knows about a failure.
25pub struct Diagnostic {
26    pub title: String,
27    pub message: String,
28    pub hint: Option<String>,
29    pub location: Option<(String, u32)>,
30    pub status: Status,
31}
32
33impl Diagnostic {
34    pub fn from_error(error: &Error) -> Self {
35        Diagnostic {
36            title: error.title().to_string(),
37            message: error.to_string(),
38            hint: error.hint(),
39            location: None,
40            status: Status(error.status()),
41        }
42    }
43
44    pub fn from_panic(message: String, location: Option<(String, u32)>) -> Self {
45        Diagnostic {
46            title: "Unhandled Panic".to_string(),
47            message,
48            hint: Some(
49                "A handler panicked. Prefer returning `Result` so the failure is part of the \
50                 type, rather than unwrapping a value that can be absent."
51                    .to_string(),
52            ),
53            location,
54            status: Status::INTERNAL_ERROR,
55        }
56    }
57}
58
59/// Render an error into a response, honouring debug mode and content negotiation.
60pub fn response_for(error: &Error) -> Response {
61    render(&Diagnostic::from_error(error), None)
62}
63
64/// Render a diagnostic, using the request (when available) for the detail
65/// panels and to decide between HTML and JSON.
66pub fn render(diagnostic: &Diagnostic, request: Option<&Request>) -> Response {
67    let wants_json = request.is_some_and(Request::wants_json);
68
69    if !debug_enabled() {
70        rustlavel_core::log::log(
71            rustlavel_core::log::Level::Error,
72            format!("{}: {}", diagnostic.title, diagnostic.message),
73        );
74        return if wants_json {
75            Response::new(diagnostic.status).with_json(Json::object([(
76                "message",
77                Json::from("Server Error"),
78            )]))
79        } else {
80            Response::new(diagnostic.status).with_html(PRODUCTION_PAGE)
81        };
82    }
83
84    if wants_json {
85        let mut fields = vec![
86            ("message", Json::from(diagnostic.message.as_str())),
87            ("exception", Json::from(diagnostic.title.as_str())),
88        ];
89        if let Some(hint) = &diagnostic.hint {
90            fields.push(("hint", Json::from(hint.as_str())));
91        }
92        if let Some((file, line)) = &diagnostic.location {
93            fields.push(("file", Json::from(file.as_str())));
94            fields.push(("line", Json::from(*line)));
95        }
96        return Response::new(diagnostic.status).with_json(Json::object(fields));
97    }
98
99    Response::new(diagnostic.status).with_html(html(diagnostic, request))
100}
101
102fn html(diagnostic: &Diagnostic, request: Option<&Request>) -> String {
103    let mut sections = String::new();
104
105    if let Some(hint) = &diagnostic.hint {
106        sections.push_str(&format!(
107            r#"<div class="hint"><span class="hint-label">Suggestion</span><p>{}</p></div>"#,
108            escape(hint)
109        ));
110    }
111
112    if let Some((file, line)) = &diagnostic.location {
113        sections.push_str(&format!(
114            r#"<div class="location">{}<span class="line">:{}</span></div>"#,
115            escape(file),
116            line
117        ));
118        if let Some(snippet) = source_snippet(file, *line) {
119            sections.push_str(&snippet);
120        }
121    }
122
123    if let Some(request) = request {
124        sections.push_str(&request_panel(request));
125    }
126
127    format!(
128        r#"<!doctype html>
129<html lang="en">
130<head>
131<meta charset="utf-8">
132<meta name="viewport" content="width=device-width, initial-scale=1">
133<title>{title} — Rustlavel</title>
134<style>{CSS}</style>
135</head>
136<body>
137<main>
138  <header>
139    <div class="badge">{status}</div>
140    <h1>{title}</h1>
141    <p class="message">{message}</p>
142  </header>
143  {sections}
144  <footer>Rustlavel debug page — hidden automatically when <code>APP_DEBUG=false</code>.</footer>
145</main>
146</body>
147</html>"#,
148        title = escape(&diagnostic.title),
149        status = diagnostic.status.code(),
150        message = escape(&diagnostic.message),
151    )
152}
153
154/// Show the failing line with a few lines of context around it.
155fn source_snippet(file: &str, line: u32) -> Option<String> {
156    let source = std::fs::read_to_string(file).ok()?;
157    let lines: Vec<&str> = source.lines().collect();
158    let target = line as usize;
159    let start = target.saturating_sub(4).max(1);
160    let end = (target + 3).min(lines.len());
161
162    let mut rows = String::new();
163    for number in start..=end {
164        let content = lines.get(number - 1).copied().unwrap_or_default();
165        let class = if number == target { "row highlight" } else { "row" };
166        rows.push_str(&format!(
167            r#"<div class="{class}"><span class="num">{number}</span><code>{}</code></div>"#,
168            escape(content)
169        ));
170    }
171    Some(format!(r#"<div class="snippet">{rows}</div>"#))
172}
173
174fn request_panel(request: &Request) -> String {
175    let mut rows = format!(
176        r#"<tr><th>Method</th><td>{}</td></tr><tr><th>Path</th><td>{}</td></tr>"#,
177        request.method(),
178        escape(request.target())
179    );
180    if let Some(route) = request.route() {
181        rows.push_str(&format!(r#"<tr><th>Route</th><td>{}</td></tr>"#, escape(route)));
182    }
183    for (name, value) in request.headers().iter() {
184        // Never echo credentials back onto a page someone might screenshot.
185        let shown = if is_sensitive(name) { "[hidden]" } else { value };
186        rows.push_str(&format!(
187            r#"<tr><th>{}</th><td>{}</td></tr>"#,
188            escape(name),
189            escape(shown)
190        ));
191    }
192    format!(r#"<div class="panel"><h2>Request</h2><table>{rows}</table></div>"#)
193}
194
195fn is_sensitive(header: &str) -> bool {
196    matches!(header, "authorization" | "cookie" | "proxy-authorization" | "x-api-key")
197}
198
199fn escape(value: &str) -> String {
200    let mut out = String::with_capacity(value.len());
201    for ch in value.chars() {
202        match ch {
203            '&' => out.push_str("&amp;"),
204            '<' => out.push_str("&lt;"),
205            '>' => out.push_str("&gt;"),
206            '"' => out.push_str("&quot;"),
207            '\'' => out.push_str("&#39;"),
208            c => out.push(c),
209        }
210    }
211    out
212}
213
214const PRODUCTION_PAGE: &str = r#"<!doctype html>
215<html lang="en"><head><meta charset="utf-8"><title>Server Error</title>
216<style>body{font:16px/1.6 system-ui,sans-serif;display:grid;place-content:center;height:100vh;margin:0;color:#334}</style>
217</head><body><div><h1>500</h1><p>Something went wrong. Please try again.</p></div></body></html>"#;
218
219const CSS: &str = r#"
220:root { color-scheme: light dark; --bg:#faf9f7; --fg:#1c1b1a; --muted:#6b6864; --line:#e5e2dd;
221        --accent:#b4483c; --panel:#fff; --code:#f4f2ef; }
222@media (prefers-color-scheme: dark) {
223  :root { --bg:#181716; --fg:#eceae7; --muted:#9a958e; --line:#2e2c29; --accent:#e0796c;
224          --panel:#201f1d; --code:#252321; }
225}
226* { box-sizing: border-box; }
227body { margin:0; background:var(--bg); color:var(--fg);
228       font:15px/1.6 ui-sans-serif,-apple-system,'Segoe UI',sans-serif; }
229main { max-width: 900px; margin: 0 auto; padding: 48px 24px 80px; }
230header { border-left: 3px solid var(--accent); padding-left: 20px; margin-bottom: 32px; }
231.badge { display:inline-block; background:var(--accent); color:#fff; font-size:12px; font-weight:600;
232         letter-spacing:.06em; padding:3px 9px; border-radius:4px; }
233h1 { font-size: 26px; margin: 12px 0 8px; font-weight: 650; }
234.message { font-size: 17px; margin:0; color:var(--fg); }
235h2 { font-size: 13px; text-transform: uppercase; letter-spacing:.08em; color: var(--muted);
236     margin: 0 0 12px; font-weight: 600; }
237.hint { background:var(--panel); border:1px solid var(--line); border-radius:8px;
238        padding:16px 20px; margin-bottom:24px; }
239.hint-label { display:block; font-size:12px; text-transform:uppercase; letter-spacing:.08em;
240              color:var(--accent); font-weight:600; margin-bottom:4px; }
241.hint p { margin:0; }
242.location { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px;
243            color:var(--muted); margin-bottom:8px; }
244.location .line { color: var(--accent); }
245.snippet { background:var(--code); border:1px solid var(--line); border-radius:8px;
246           overflow-x:auto; margin-bottom:24px; padding:12px 0; }
247.row { display:flex; gap:16px; padding:1px 20px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
248       font-size:13px; white-space:pre; }
249.row.highlight { background:color-mix(in srgb, var(--accent) 14%, transparent); }
250.num { color:var(--muted); min-width:3ch; text-align:right; user-select:none; }
251.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
252table { width:100%; border-collapse:collapse; font-size:13px; }
253th { text-align:left; color:var(--muted); font-weight:500; width:180px; vertical-align:top;
254     padding:4px 12px 4px 0; word-break:break-word; }
255td { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; padding:4px 0; word-break:break-all; }
256footer { margin-top:40px; font-size:12px; color:var(--muted); }
257code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
258"#;
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::method::Method;
264
265    /// Debug mode is process-wide, so these tests must not run concurrently.
266    static DEBUG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
267
268    fn exclusive() -> std::sync::MutexGuard<'static, ()> {
269        DEBUG_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
270    }
271
272    #[test]
273    fn production_hides_the_details() {
274        let _guard = exclusive();
275        set_debug(false);
276        let response = response_for(&Error::msg("connection string leaked"));
277
278        assert_eq!(response.status, Status::INTERNAL_ERROR);
279        assert!(!response.body_string().contains("connection string leaked"));
280    }
281
282    #[test]
283    fn debug_shows_the_message_and_hint() {
284        let _guard = exclusive();
285        set_debug(true);
286        let error = Error::Config { file: ".env".into(), line: 3, message: "bad line".into() };
287        let response = response_for(&error);
288        let body = response.body_string();
289
290        assert!(body.contains("bad line"));
291        assert!(body.contains("Suggestion"));
292        set_debug(false);
293    }
294
295    #[test]
296    fn json_clients_get_json_errors() {
297        let _guard = exclusive();
298        set_debug(true);
299        let request = Request::new(Method::Get, "/api/users").with_header("accept", "application/json");
300        let response = render(&Diagnostic::from_error(&Error::msg("nope")), Some(&request));
301
302        assert_eq!(response.headers.content_type(), Some("application/json"));
303        assert!(response.body_string().contains("\"message\":\"nope\""));
304        set_debug(false);
305    }
306
307    #[test]
308    fn credentials_are_not_echoed_onto_the_page() {
309        let _guard = exclusive();
310        set_debug(true);
311        let request = Request::new(Method::Get, "/")
312            .with_header("authorization", "Bearer super-secret")
313            .with_header("x-trace", "abc");
314        let response = render(&Diagnostic::from_error(&Error::msg("boom")), Some(&request));
315        let body = response.body_string();
316
317        assert!(!body.contains("super-secret"));
318        assert!(body.contains("[hidden]"));
319        assert!(body.contains("abc"));
320        set_debug(false);
321    }
322
323    #[test]
324    fn markup_in_a_message_is_escaped() {
325        let _guard = exclusive();
326        set_debug(true);
327        let response = response_for(&Error::msg("<script>alert(1)</script>"));
328
329        assert!(!response.body_string().contains("<script>"));
330        assert!(response.body_string().contains("&lt;script&gt;"));
331        set_debug(false);
332    }
333}