Skip to main content

static_web_server/
error_page.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Error page module to compose an HTML page response.
7//!
8
9use 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
20/// Process-wide cache of pre-loaded error/maintenance page bodies, keyed by
21/// the configured filesystem path. Populated at startup by [`cache_page`];
22/// callers (`error_response`, `maintenance_mode::get_response`) look up by
23/// path to avoid touching disk on every error response.
24///
25/// SECURITY: Reading the page body on every error/maintenance response was
26/// a slowloris-style amplifier \u2014 a stream of 404s could pin runtime worker
27/// threads on blocking I/O. Pre-loading at startup eliminates that hot-path
28/// disk I/O entirely.
29static 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
35/// Pre-load the given page file into the in-memory cache. Missing files are
36/// silently skipped (the default HTML body will be served).
37pub 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
54/// Returns the cached body for `path`, or `None` if no entry exists.
55pub fn cached_page(path: &Path) -> Option<Arc<String>> {
56    page_cache().read().ok()?.get(path).cloned()
57}
58
59/// Build an `text/html` response with the correct `Content-Length` and
60/// `Accept-Ranges` headers.
61///
62/// When `method` is `HEAD` the body is omitted but the `Content-Length`
63/// still reflects the full content length, exactly as for a normal `GET`.
64/// Pass `method = None` to always include the body (e.g. fallback pages
65/// where the caller never receives `HEAD` requests at this point).
66pub(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
88/// It returns a HTTP error response which also handles available `404` or `50x` HTML content.
89pub 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    // Check for 4xx/50x status codes and handle their corresponding HTML content
102    let mut page_content = String::new();
103    let status_code = match status_code {
104        // 4xx
105        &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            // Extra check for 404 status code and its HTML content
124            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 miss \u2014 read disk once and remember.
129                    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        // 50x
141        &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            // HTML content check for status codes 50x
151            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        // other status codes
165        _ => 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    /// PERF/SECURITY: `cache_page` must populate `PAGE_CACHE` so that
301    /// subsequent `error_response` calls never touch disk again.
302    #[test]
303    fn cache_page_round_trip() {
304        use std::io::Write;
305        // Unique temp file to avoid colliding with other tests.
306        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        // Delete the file: a cached lookup must still succeed (proves we
321        // are not touching disk).
322        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}