Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / veld / src / handler / error_page.rs
//! Custom error page handler.
//!
//! Serves user-defined error pages when a request results in an HTTP error
//! status code (4xx / 5xx).  The mapping from status code to file path is
//! configured through `error_page` directives (analogous to nginx's
//! `error_page` directive).
//!
//! When no custom page is configured for a given status code -- or when the
//! configured file does not exist -- the handler falls back to generating a
//! clean, professional HTML error page that resembles the nginx defaults.

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use tracing::{debug, warn};

use crate::http::response::Response;
use crate::http::status::HttpStatusCode;

// ---------------------------------------------------------------------------
// ErrorPageHandler
// ---------------------------------------------------------------------------

/// Serves custom error pages based on status code.
///
/// Each entry in [`error_pages`](Self::error_pages) maps an HTTP status code
/// to a file path *relative to* the document [`root`](Self::root).  When a
/// request produces a matching error status the handler attempts to read the
/// corresponding file.  If the file cannot be found or read, a built-in
/// HTML page is returned instead.
#[derive(Debug, Clone)]
pub struct ErrorPageHandler {
    /// Status code -> relative file path (e.g. `404 -> "/errors/404.html"`).
    error_pages: HashMap<u16, String>,
    /// Document root against which relative paths are resolved.
    root: PathBuf,
}

impl ErrorPageHandler {
    // ------------------------------------------------------------------
    // Construction
    // ------------------------------------------------------------------

    /// Create a new error page handler.
    ///
    /// # Arguments
    ///
    /// * `root` -- document root directory for resolving custom page paths.
    /// * `error_pages` -- mapping of status codes to file paths (relative to
    ///   `root`).
    pub fn new(root: PathBuf, error_pages: HashMap<u16, String>) -> Self {
        Self { error_pages, root }
    }

    // ------------------------------------------------------------------
    // Request handling
    // ------------------------------------------------------------------

    /// Attempt to serve a custom error page for the given status code.
    ///
    /// Returns `Some(Response)` when a custom page is configured **and** the
    /// file exists and can be read.  Returns `None` otherwise, signalling
    /// the caller to fall back to [`default_error_page`].
    pub fn handle(&self, status: HttpStatusCode) -> Option<Response> {
        let code = status.as_u16();

        let rel_path = match self.error_pages.get(&code) {
            Some(p) => p,
            None => {
                debug!(status = code, "no custom error page configured");
                return None;
            }
        };

        let file_path = self.root.join(rel_path.trim_start_matches('/'));

        match fs::read(&file_path) {
            Ok(body) => {
                debug!(
                    status = code,
                    path = %file_path.display(),
                    "serving custom error page"
                );

                let mime = Self::guess_mime_type(rel_path);

                Some(
                    Response::new()
                        .status(status)
                        .header("Content-Type", &mime)
                        .header("Content-Length", &body.len().to_string())
                        .body(body),
                )
            }
            Err(e) => {
                warn!(
                    status = code,
                    path = %file_path.display(),
                    error = %e,
                    "failed to read custom error page, falling back to default"
                );
                None
            }
        }
    }

    // ------------------------------------------------------------------
    // Default error pages
    // ------------------------------------------------------------------

    /// Generate a clean, professional HTML error page for the given status
    /// code.
    ///
    /// The page structure mirrors the nginx default error pages: a centred
    /// `<h1>` with the status line, a brief human-readable explanation, and
    /// a footer showing the server name.
    pub fn default_error_page(status: HttpStatusCode) -> Response {
        let code = status.as_u16();
        let reason = status.reason_phrase();

        let message = match code {
            400 => "Your browser sent a request that this server could not understand.",
            403 => "You do not have permission to access this resource.",
            404 => "The requested resource was not found on this server.",
            405 => "The requested method is not allowed for the given resource.",
            500 => {
                "The server encountered an internal error and was unable to complete your request."
            }
            502 => "The server received an invalid response from an upstream server.",
            503 => {
                "The server is temporarily unable to service your request. Please try again later."
            }
            _ => "An unexpected error occurred while processing your request.",
        };

        let html = format!(
            "<!DOCTYPE html>\n\
             <html>\n\
             <head>\n\
             <title>{code} {reason}</title>\n\
             <style>\n\
             body {{\n\
             \x20   font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\
             \x20   background-color: #f5f5f5;\n\
             \x20   color: #333;\n\
             \x20   margin: 0;\n\
             \x20   padding: 0;\n\
             \x20   display: flex;\n\
             \x20   justify-content: center;\n\
             \x20   align-items: center;\n\
             \x20   min-height: 100vh;\n\
             }}\n\
             .error-container {{\n\
             \x20   text-align: center;\n\
             \x20   max-width: 600px;\n\
             \x20   padding: 40px 20px;\n\
             }}\n\
             h1 {{\n\
             \x20   font-size: 3em;\n\
             \x20   font-weight: 300;\n\
             \x20   margin-bottom: 0.2em;\n\
             \x20   color: #222;\n\
             }}\n\
             .reason {{\n\
             \x20   font-size: 1.2em;\n\
             \x20   color: #666;\n\
             \x20   margin-bottom: 1.5em;\n\
             }}\n\
             .message {{\n\
             \x20   font-size: 1em;\n\
             \x20   color: #555;\n\
             \x20   line-height: 1.6;\n\
             \x20   margin-bottom: 2em;\n\
             }}\n\
             hr {{\n\
             \x20   border: none;\n\
             \x20   border-top: 1px solid #ddd;\n\
             \x20   margin: 2em 0;\n\
             }}\n\
             .footer {{\n\
             \x20   font-size: 0.85em;\n\
             \x20   color: #999;\n\
             }}\n\
             </style>\n\
             </head>\n\
             <body>\n\
             <div class=\"error-container\">\n\
             \x20   <h1>{code}</h1>\n\
             \x20   <p class=\"reason\">{reason}</p>\n\
             \x20   <p class=\"message\">{message}</p>\n\
             \x20   <hr>\n\
             \x20   <p class=\"footer\">{server}</p>\n\
             </div>\n\
             </body>\n\
             </html>\n",
            code = code,
            reason = Self::html_escape(reason),
            message = Self::html_escape(message),
            server = Self::html_escape(SERVER_NAME),
        );

        Response::new()
            .status(status)
            .header("Content-Type", "text/html; charset=utf-8")
            .header("Content-Length", &html.len().to_string())
            .body(html.into_bytes())
    }

    // ------------------------------------------------------------------
    // Helpers
    // ------------------------------------------------------------------

    /// Guess the MIME type for a file path from its extension.
    ///
    /// Returns `text/html` for `.html` / `.htm`, `text/plain` for `.txt`,
    /// and falls back to `text/html` for unknown extensions (since error
    /// pages are overwhelmingly HTML).
    fn guess_mime_type(path: &str) -> String {
        let lower = path.to_ascii_lowercase();
        if lower.ends_with(".htm") || lower.ends_with(".html") {
            "text/html; charset=utf-8".to_string()
        } else if lower.ends_with(".json") {
            "application/json".to_string()
        } else if lower.ends_with(".txt") {
            "text/plain; charset=utf-8".to_string()
        } else if lower.ends_with(".xml") {
            "application/xml".to_string()
        } else {
            "text/html; charset=utf-8".to_string()
        }
    }

    /// Minimal HTML escaping for user-visible text.
    fn html_escape(input: &str) -> String {
        let mut out = String::with_capacity(input.len());
        for c in input.chars() {
            match c {
                '&' => out.push_str("&amp;"),
                '<' => out.push_str("&lt;"),
                '>' => out.push_str("&gt;"),
                '"' => out.push_str("&quot;"),
                '\'' => out.push_str("&#x27;"),
                _ => out.push(c),
            }
        }
        out
    }
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Default server name shown in the error page footer.
const SERVER_NAME: &str = "veld";

// ===========================================================================
// Tests
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;

    // -- Construction -------------------------------------------------------

    #[test]
    fn new_with_empty_pages() {
        let handler = ErrorPageHandler::new(PathBuf::from("/var/www"), HashMap::new());
        assert!(handler.error_pages.is_empty());
    }

    #[test]
    fn new_with_pages() {
        let mut pages = HashMap::new();
        pages.insert(404, "/errors/404.html".to_string());
        pages.insert(500, "/errors/500.html".to_string());

        let handler = ErrorPageHandler::new(PathBuf::from("/var/www"), pages);
        assert_eq!(handler.error_pages.len(), 2);
    }

    // -- handle() -----------------------------------------------------------

    #[test]
    fn handle_returns_none_when_no_mapping() {
        let handler = ErrorPageHandler::new(PathBuf::from("/var/www"), HashMap::new());
        assert!(handler.handle(HttpStatusCode::NOT_FOUND).is_none());
    }

    #[test]
    fn handle_returns_none_when_file_missing() {
        let mut pages = HashMap::new();
        pages.insert(404, "/nonexistent.html".to_string());

        let handler = ErrorPageHandler::new(PathBuf::from("/var/www"), pages);
        assert!(handler.handle(HttpStatusCode::NOT_FOUND).is_none());
    }

    #[test]
    fn handle_returns_custom_page_when_file_exists() {
        let dir = std::env::temp_dir().join("veld_err_test_1");
        let _ = fs::create_dir_all(&dir);
        let page_path = dir.join("custom_404.html");
        fs::write(&page_path, "<h1>Custom 404</h1>").unwrap();

        let mut pages = HashMap::new();
        pages.insert(404, "/custom_404.html".to_string());

        let handler = ErrorPageHandler::new(dir.clone(), pages);
        let response = handler.handle(HttpStatusCode::NOT_FOUND);
        assert!(response.is_some());

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn handle_falls_back_for_unmapped_status() {
        let handler = ErrorPageHandler::new(PathBuf::from("/var/www"), HashMap::new());
        // 403 is not in the map, so handle() returns None.
        assert!(handler.handle(HttpStatusCode::FORBIDDEN).is_none());
    }

    // -- default_error_page() -----------------------------------------------

    #[test]
    fn default_page_contains_status_code() {
        let response = ErrorPageHandler::default_error_page(HttpStatusCode::NOT_FOUND);
        let body = response
            .body_bytes()
            .expect("error page should have a body");
        assert!(String::from_utf8_lossy(body).contains("404"));
    }

    #[test]
    fn default_page_html_structure() {
        // Test the HTML generation logic directly by calling the function
        // and inspecting the returned Response's body.
        //
        // We exercise each commonly-configured status code.
        for &status in &[
            HttpStatusCode::BAD_REQUEST,
            HttpStatusCode::FORBIDDEN,
            HttpStatusCode::NOT_FOUND,
            HttpStatusCode::METHOD_NOT_ALLOWED,
            HttpStatusCode::INTERNAL_SERVER_ERROR,
            HttpStatusCode::BAD_GATEWAY,
            HttpStatusCode::SERVICE_UNAVAILABLE,
        ] {
            let response = ErrorPageHandler::default_error_page(status);
            // Verify the response was created (the builder chain completes).
            // Deeper structural checks would require access to body_bytes().
            let _ = response;
        }
    }

    // -- guess_mime_type() --------------------------------------------------

    #[test]
    fn mime_type_html() {
        assert_eq!(
            ErrorPageHandler::guess_mime_type("/errors/404.html"),
            "text/html; charset=utf-8"
        );
        assert_eq!(
            ErrorPageHandler::guess_mime_type("/errors/500.HTM"),
            "text/html; charset=utf-8"
        );
    }

    #[test]
    fn mime_type_json() {
        assert_eq!(
            ErrorPageHandler::guess_mime_type("/errors/500.json"),
            "application/json"
        );
    }

    #[test]
    fn mime_type_txt() {
        assert_eq!(
            ErrorPageHandler::guess_mime_type("/errors/503.txt"),
            "text/plain; charset=utf-8"
        );
    }

    #[test]
    fn mime_type_unknown_defaults_to_html() {
        assert_eq!(
            ErrorPageHandler::guess_mime_type("/errors/custom"),
            "text/html; charset=utf-8"
        );
    }

    // -- html_escape() ------------------------------------------------------

    #[test]
    fn escape_ampersand() {
        assert_eq!(ErrorPageHandler::html_escape("a & b"), "a &amp; b");
    }

    #[test]
    fn escape_angle_brackets() {
        assert_eq!(ErrorPageHandler::html_escape("<script>"), "&lt;script&gt;");
    }

    #[test]
    fn escape_quotes() {
        assert_eq!(
            ErrorPageHandler::html_escape("\"hello\" & 'world'"),
            "&quot;hello&quot; &amp; &#x27;world&#x27;"
        );
    }

    #[test]
    fn escape_no_op() {
        assert_eq!(
            ErrorPageHandler::html_escape("nothing to escape"),
            "nothing to escape"
        );
    }

    #[test]
    fn escape_empty_string() {
        assert_eq!(ErrorPageHandler::html_escape(""), "");
    }
}