static_web_server/
error_page.rs1use headers::{AcceptRanges, ContentLength, ContentType, HeaderMapExt};
10use hyper::{Method, Response, StatusCode, Uri};
11use maud::{DOCTYPE, html};
12use mime_guess::mime;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, OnceLock, RwLock};
16
17use crate::body::Body;
18use crate::{Result, exts::http::MethodExt, helpers};
19
20static PAGE_CACHE: OnceLock<RwLock<HashMap<PathBuf, Arc<String>>>> = OnceLock::new();
30
31fn page_cache() -> &'static RwLock<HashMap<PathBuf, Arc<String>>> {
32 PAGE_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
33}
34
35pub fn cache_page(path: &Path) {
38 if path.as_os_str().is_empty() {
39 return;
40 }
41 if !path.is_file() {
42 tracing::debug!(
43 "error page path not found or not a regular file: {}",
44 path.display()
45 );
46 return;
47 }
48 let body = helpers::read_text_default(path);
49 if let Ok(mut guard) = page_cache().write() {
50 guard.insert(path.to_path_buf(), Arc::new(body));
51 }
52}
53
54pub fn cached_page(path: &Path) -> Option<Arc<String>> {
56 page_cache().read().ok()?.get(path).cloned()
57}
58
59pub(crate) fn build_html_response(
67 content: impl Into<bytes::Bytes>,
68 status: hyper::StatusCode,
69 method: Option<&Method>,
70) -> Response<Body> {
71 let bytes: bytes::Bytes = content.into();
72 let len = bytes.len() as u64;
73 let is_head = method.is_some_and(|m| m.is_head());
74 let body = if is_head {
75 crate::body::empty()
76 } else {
77 crate::body::full(bytes)
78 };
79 let mut resp = Response::new(body);
80 *resp.status_mut() = status;
81 resp.headers_mut()
82 .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
83 resp.headers_mut().typed_insert(ContentLength(len));
84 resp.headers_mut().typed_insert(AcceptRanges::bytes());
85 resp
86}
87
88pub fn error_response(
90 uri: &Uri,
91 method: &Method,
92 status_code: &StatusCode,
93 page404: &Path,
94 page50x: &Path,
95) -> Result<Response<Body>> {
96 tracing::warn!(
97 method = ?method, uri = ?uri, status = status_code.as_u16(),
98 error = status_code.canonical_reason().unwrap_or_default()
99 );
100
101 let mut page_content = String::new();
103 let status_code = match status_code {
104 &StatusCode::BAD_REQUEST
106 | &StatusCode::UNAUTHORIZED
107 | &StatusCode::PAYMENT_REQUIRED
108 | &StatusCode::FORBIDDEN
109 | &StatusCode::NOT_FOUND
110 | &StatusCode::METHOD_NOT_ALLOWED
111 | &StatusCode::NOT_ACCEPTABLE
112 | &StatusCode::PROXY_AUTHENTICATION_REQUIRED
113 | &StatusCode::REQUEST_TIMEOUT
114 | &StatusCode::CONFLICT
115 | &StatusCode::GONE
116 | &StatusCode::LENGTH_REQUIRED
117 | &StatusCode::PRECONDITION_FAILED
118 | &StatusCode::PAYLOAD_TOO_LARGE
119 | &StatusCode::URI_TOO_LONG
120 | &StatusCode::UNSUPPORTED_MEDIA_TYPE
121 | &StatusCode::RANGE_NOT_SATISFIABLE
122 | &StatusCode::EXPECTATION_FAILED => {
123 if status_code == &StatusCode::NOT_FOUND {
125 if let Some(cached) = cached_page(page404) {
126 page_content = cached.as_str().to_owned();
127 } else if page404.is_file() {
128 cache_page(page404);
130 helpers::read_text_default(page404).clone_into(&mut page_content);
131 } else {
132 tracing::debug!(
133 "page404 file path not found or not a regular file: {}",
134 page404.display()
135 );
136 }
137 }
138 status_code
139 }
140 &StatusCode::INTERNAL_SERVER_ERROR
142 | &StatusCode::NOT_IMPLEMENTED
143 | &StatusCode::BAD_GATEWAY
144 | &StatusCode::SERVICE_UNAVAILABLE
145 | &StatusCode::GATEWAY_TIMEOUT
146 | &StatusCode::HTTP_VERSION_NOT_SUPPORTED
147 | &StatusCode::VARIANT_ALSO_NEGOTIATES
148 | &StatusCode::INSUFFICIENT_STORAGE
149 | &StatusCode::LOOP_DETECTED => {
150 if let Some(cached) = cached_page(page50x) {
152 page_content = cached.as_str().to_owned();
153 } else if page50x.is_file() {
154 cache_page(page50x);
155 helpers::read_text_default(page50x).clone_into(&mut page_content);
156 } else {
157 tracing::debug!(
158 "page50x file path not found or not a regular file: {}",
159 page50x.display()
160 );
161 }
162 status_code
163 }
164 _ => status_code,
166 };
167
168 if page_content.is_empty() {
169 let reason = status_code.canonical_reason().unwrap_or_default();
170 let title = [status_code.as_str(), " ", reason].concat();
171
172 page_content = html! {
173 (DOCTYPE)
174 html {
175 head {
176 meta charset="utf-8";
177 meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1";
178 title {
179 (title)
180 }
181 style {
182 "html { color-scheme: light dark; } body { font-family: sans-serif; text-align: center; }"
183 }
184 }
185 body {
186 h1 {
187 (title)
188 }
189 }
190 }
191 }.into();
192 }
193
194 Ok(build_html_response(
195 page_content,
196 *status_code,
197 Some(method),
198 ))
199}
200
201#[cfg(test)]
202mod tests {
203 use headers::{ContentLength, ContentType, HeaderMapExt};
204 use hyper::{Method, StatusCode};
205 use std::path::Path;
206
207 use super::{build_html_response, error_response};
208
209 #[test]
210 fn build_html_response_get_includes_body() {
211 let resp = build_html_response("hello", StatusCode::OK, Some(&Method::GET));
212 assert_eq!(resp.status(), StatusCode::OK);
213 let ct: ContentType = resp.headers().typed_get().unwrap();
214 assert_eq!(ct, ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
215 let cl: ContentLength = resp.headers().typed_get().unwrap();
216 assert_eq!(cl.0, 5);
217 }
218
219 #[test]
220 fn build_html_response_head_omits_body_but_keeps_length() {
221 let content = "hello";
222 let resp = build_html_response(content, StatusCode::NOT_FOUND, Some(&Method::HEAD));
223 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
224 let cl: ContentLength = resp.headers().typed_get().unwrap();
225 assert_eq!(cl.0, content.len() as u64);
226 }
227
228 #[test]
229 fn build_html_response_none_method_always_includes_body() {
230 let resp = build_html_response("body content", StatusCode::INTERNAL_SERVER_ERROR, None);
231 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
232 let cl: ContentLength = resp.headers().typed_get().unwrap();
233 assert_eq!(cl.0, "body content".len() as u64);
234 }
235
236 #[test]
237 fn error_response_404_no_custom_page() {
238 let uri = "/missing".parse().unwrap();
239 let resp = error_response(
240 &uri,
241 &Method::GET,
242 &StatusCode::NOT_FOUND,
243 Path::new("/nonexistent/404.html"),
244 Path::new("/nonexistent/50x.html"),
245 )
246 .unwrap();
247 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
248 }
249
250 #[test]
251 fn error_response_404_with_custom_page() {
252 let page404 = std::env::temp_dir().join("sws_error_page_404_test.html");
253 std::fs::write(&page404, b"<h1>Not Found</h1>").unwrap();
254 let uri = "/missing".parse().unwrap();
255 let resp = error_response(
256 &uri,
257 &Method::GET,
258 &StatusCode::NOT_FOUND,
259 &page404,
260 Path::new("/nonexistent/50x.html"),
261 )
262 .unwrap();
263 std::fs::remove_file(&page404).ok();
264 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
265 }
266
267 #[test]
268 fn error_response_500_no_custom_page() {
269 let uri = "/crash".parse().unwrap();
270 let resp = error_response(
271 &uri,
272 &Method::GET,
273 &StatusCode::INTERNAL_SERVER_ERROR,
274 Path::new("/nonexistent/404.html"),
275 Path::new("/nonexistent/50x.html"),
276 )
277 .unwrap();
278 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
279 }
280
281 #[test]
282 fn error_response_head_omits_body() {
283 let uri = "/missing".parse().unwrap();
284 let resp = error_response(
285 &uri,
286 &Method::HEAD,
287 &StatusCode::NOT_FOUND,
288 Path::new("/nonexistent/404.html"),
289 Path::new("/nonexistent/50x.html"),
290 )
291 .unwrap();
292 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
293 let cl: ContentLength = resp.headers().typed_get().unwrap();
294 assert!(
295 cl.0 > 0,
296 "Content-Length should reflect body size even for HEAD"
297 );
298 }
299
300 #[test]
303 fn cache_page_round_trip() {
304 use std::io::Write;
305 let pid = std::process::id();
307 let nanos = std::time::SystemTime::now()
308 .duration_since(std::time::UNIX_EPOCH)
309 .map(|d| d.as_nanos())
310 .unwrap_or(0);
311 let path = std::env::temp_dir().join(format!("sws-error-page-cache-{pid}-{nanos}.html"));
312 let mut f = std::fs::File::create(&path).unwrap();
313 write!(f, "<html>cached</html>").unwrap();
314 drop(f);
315
316 super::cache_page(&path);
317 let cached = super::cached_page(&path).expect("page must be cached");
318 assert!(cached.contains("cached"));
319
320 std::fs::remove_file(&path).unwrap();
323 let still_cached = super::cached_page(&path).expect("cache survives file deletion");
324 assert!(still_cached.contains("cached"));
325 }
326
327 #[test]
328 fn cache_page_skips_missing_file() {
329 let path = Path::new("/this/does/not/exist/sws-test-404.html");
330 super::cache_page(path);
331 assert!(super::cached_page(path).is_none());
332 }
333
334 #[test]
335 fn cache_page_skips_empty_path() {
336 super::cache_page(Path::new(""));
337 assert!(super::cached_page(Path::new("")).is_none());
338 }
339}