use headers::{AcceptRanges, ContentLength, ContentType, HeaderMapExt};
use hyper::{Method, Response, StatusCode, Uri};
use maud::{DOCTYPE, html};
use mime_guess::mime;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use crate::body::Body;
use crate::{Result, exts::http::MethodExt, helpers};
static PAGE_CACHE: OnceLock<RwLock<HashMap<PathBuf, Arc<String>>>> = OnceLock::new();
fn page_cache() -> &'static RwLock<HashMap<PathBuf, Arc<String>>> {
PAGE_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
pub fn cache_page(path: &Path) {
if path.as_os_str().is_empty() {
return;
}
if !path.is_file() {
tracing::debug!(
"error page path not found or not a regular file: {}",
path.display()
);
return;
}
let body = helpers::read_text_default(path);
if let Ok(mut guard) = page_cache().write() {
guard.insert(path.to_path_buf(), Arc::new(body));
}
}
pub fn cached_page(path: &Path) -> Option<Arc<String>> {
page_cache().read().ok()?.get(path).cloned()
}
pub(crate) fn build_html_response(
content: impl Into<bytes::Bytes>,
status: hyper::StatusCode,
method: Option<&Method>,
) -> Response<Body> {
let bytes: bytes::Bytes = content.into();
let len = bytes.len() as u64;
let is_head = method.is_some_and(|m| m.is_head());
let body = if is_head {
crate::body::empty()
} else {
crate::body::full(bytes)
};
let mut resp = Response::new(body);
*resp.status_mut() = status;
resp.headers_mut()
.typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8));
resp.headers_mut().typed_insert(ContentLength(len));
resp.headers_mut().typed_insert(AcceptRanges::bytes());
resp
}
pub fn error_response(
uri: &Uri,
method: &Method,
status_code: &StatusCode,
page404: &Path,
page50x: &Path,
) -> Result<Response<Body>> {
tracing::warn!(
method = ?method, uri = ?uri, status = status_code.as_u16(),
error = status_code.canonical_reason().unwrap_or_default()
);
let mut page_content = String::new();
let status_code = match status_code {
&StatusCode::BAD_REQUEST
| &StatusCode::UNAUTHORIZED
| &StatusCode::PAYMENT_REQUIRED
| &StatusCode::FORBIDDEN
| &StatusCode::NOT_FOUND
| &StatusCode::METHOD_NOT_ALLOWED
| &StatusCode::NOT_ACCEPTABLE
| &StatusCode::PROXY_AUTHENTICATION_REQUIRED
| &StatusCode::REQUEST_TIMEOUT
| &StatusCode::CONFLICT
| &StatusCode::GONE
| &StatusCode::LENGTH_REQUIRED
| &StatusCode::PRECONDITION_FAILED
| &StatusCode::PAYLOAD_TOO_LARGE
| &StatusCode::URI_TOO_LONG
| &StatusCode::UNSUPPORTED_MEDIA_TYPE
| &StatusCode::RANGE_NOT_SATISFIABLE
| &StatusCode::EXPECTATION_FAILED => {
if status_code == &StatusCode::NOT_FOUND {
if let Some(cached) = cached_page(page404) {
page_content = cached.as_str().to_owned();
} else if page404.is_file() {
cache_page(page404);
helpers::read_text_default(page404).clone_into(&mut page_content);
} else {
tracing::debug!(
"page404 file path not found or not a regular file: {}",
page404.display()
);
}
}
status_code
}
&StatusCode::INTERNAL_SERVER_ERROR
| &StatusCode::NOT_IMPLEMENTED
| &StatusCode::BAD_GATEWAY
| &StatusCode::SERVICE_UNAVAILABLE
| &StatusCode::GATEWAY_TIMEOUT
| &StatusCode::HTTP_VERSION_NOT_SUPPORTED
| &StatusCode::VARIANT_ALSO_NEGOTIATES
| &StatusCode::INSUFFICIENT_STORAGE
| &StatusCode::LOOP_DETECTED => {
if let Some(cached) = cached_page(page50x) {
page_content = cached.as_str().to_owned();
} else if page50x.is_file() {
cache_page(page50x);
helpers::read_text_default(page50x).clone_into(&mut page_content);
} else {
tracing::debug!(
"page50x file path not found or not a regular file: {}",
page50x.display()
);
}
status_code
}
_ => status_code,
};
if page_content.is_empty() {
let reason = status_code.canonical_reason().unwrap_or_default();
let title = [status_code.as_str(), " ", reason].concat();
page_content = html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1";
title {
(title)
}
style {
"html { color-scheme: light dark; } body { font-family: sans-serif; text-align: center; }"
}
}
body {
h1 {
(title)
}
}
}
}.into();
}
Ok(build_html_response(
page_content,
*status_code,
Some(method),
))
}
#[cfg(test)]
mod tests {
use headers::{ContentLength, ContentType, HeaderMapExt};
use hyper::{Method, StatusCode};
use std::path::Path;
use super::{build_html_response, error_response};
#[test]
fn build_html_response_get_includes_body() {
let resp = build_html_response("hello", StatusCode::OK, Some(&Method::GET));
assert_eq!(resp.status(), StatusCode::OK);
let ct: ContentType = resp.headers().typed_get().unwrap();
assert_eq!(ct, ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
let cl: ContentLength = resp.headers().typed_get().unwrap();
assert_eq!(cl.0, 5);
}
#[test]
fn build_html_response_head_omits_body_but_keeps_length() {
let content = "hello";
let resp = build_html_response(content, StatusCode::NOT_FOUND, Some(&Method::HEAD));
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let cl: ContentLength = resp.headers().typed_get().unwrap();
assert_eq!(cl.0, content.len() as u64);
}
#[test]
fn build_html_response_none_method_always_includes_body() {
let resp = build_html_response("body content", StatusCode::INTERNAL_SERVER_ERROR, None);
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let cl: ContentLength = resp.headers().typed_get().unwrap();
assert_eq!(cl.0, "body content".len() as u64);
}
#[test]
fn error_response_404_no_custom_page() {
let uri = "/missing".parse().unwrap();
let resp = error_response(
&uri,
&Method::GET,
&StatusCode::NOT_FOUND,
Path::new("/nonexistent/404.html"),
Path::new("/nonexistent/50x.html"),
)
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn error_response_404_with_custom_page() {
let page404 = std::env::temp_dir().join("sws_error_page_404_test.html");
std::fs::write(&page404, b"<h1>Not Found</h1>").unwrap();
let uri = "/missing".parse().unwrap();
let resp = error_response(
&uri,
&Method::GET,
&StatusCode::NOT_FOUND,
&page404,
Path::new("/nonexistent/50x.html"),
)
.unwrap();
std::fs::remove_file(&page404).ok();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn error_response_500_no_custom_page() {
let uri = "/crash".parse().unwrap();
let resp = error_response(
&uri,
&Method::GET,
&StatusCode::INTERNAL_SERVER_ERROR,
Path::new("/nonexistent/404.html"),
Path::new("/nonexistent/50x.html"),
)
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn error_response_head_omits_body() {
let uri = "/missing".parse().unwrap();
let resp = error_response(
&uri,
&Method::HEAD,
&StatusCode::NOT_FOUND,
Path::new("/nonexistent/404.html"),
Path::new("/nonexistent/50x.html"),
)
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let cl: ContentLength = resp.headers().typed_get().unwrap();
assert!(
cl.0 > 0,
"Content-Length should reflect body size even for HEAD"
);
}
#[test]
fn cache_page_round_trip() {
use std::io::Write;
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("sws-error-page-cache-{pid}-{nanos}.html"));
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "<html>cached</html>").unwrap();
drop(f);
super::cache_page(&path);
let cached = super::cached_page(&path).expect("page must be cached");
assert!(cached.contains("cached"));
std::fs::remove_file(&path).unwrap();
let still_cached = super::cached_page(&path).expect("cache survives file deletion");
assert!(still_cached.contains("cached"));
}
#[test]
fn cache_page_skips_missing_file() {
let path = Path::new("/this/does/not/exist/sws-test-404.html");
super::cache_page(path);
assert!(super::cached_page(path).is_none());
}
#[test]
fn cache_page_skips_empty_path() {
super::cache_page(Path::new(""));
assert!(super::cached_page(Path::new("")).is_none());
}
}