1use 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
15pub 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
24pub 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
59pub fn response_for(error: &Error) -> Response {
61 render(&Diagnostic::from_error(error), None)
62}
63
64pub 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
154fn 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 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("&"),
204 '<' => out.push_str("<"),
205 '>' => out.push_str(">"),
206 '"' => out.push_str("""),
207 '\'' => out.push_str("'"),
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 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("<script>"));
331 set_debug(false);
332 }
333}