error_page.rs
239 lignes · 7338 octets
//! HTML 404 page for unmatched browser routes. //! //! API paths and JSON-only clients still receive a JSON body. use askama::Template; use axum::body::Body; use axum::extract::Request; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; use axum::response::{Html, IntoResponse, Response}; use std::sync::Arc; use rustwarden_core::i18n::extractor::Locale; use rustwarden_core::i18n::service::I18nService; use rustwarden_core::templates::LayoutContext; const SUPPORTED_LOCALES: [&str; 6] = ["fr", "en", "de", "es", "it", "pt"]; /// Unmatched-route page. Copy is filled from i18n when the app has loaded it. #[derive(Template)] #[template(path = "errors/404.html")] struct NotFoundTemplate { fragment: bool, locale: String, app_name: String, page_title: String, code_label: String, title: String, message: String, path: String, home_label: String, login_label: String, } /// Fallback for routes that match nothing on the UI router. /// /// Browsers and HTMX receive the HTML page (status 404). Paths under `/api/` /// and clients that ask only for `application/json` receive JSON. pub async fn not_found(request: Request<Body>) -> Response { let path = request.uri().path(); let headers = request.headers(); if wants_json(path, headers) { return ( StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "error": "Not found", "status": 404, })), ) .into_response(); } let locale = locale_from_request(&request); let i18n = request.extensions().get::<Arc<I18nService>>().cloned(); let fragment = headers .get("hx-request") .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.eq_ignore_ascii_case("true")); let title = tr( i18n.as_deref(), "http_errors.not_found_title", &locale, "Page introuvable", ) .await; let message = tr( i18n.as_deref(), "http_errors.not_found_message", &locale, "Cette adresse ne correspond à aucune page.", ) .await; let code_label = tr( i18n.as_deref(), "http_errors.code_label", &locale, "Erreur 404", ) .await; let home_label = tr( i18n.as_deref(), "http_errors.home", &locale, "Retour à l'accueil", ) .await; let login_label = tr( i18n.as_deref(), "http_errors.login", &locale, "Se connecter", ) .await; let layout = LayoutContext::new("", ""); let page_title = format!("404 — {title} — {}", layout.app_name); let shown_path: String = path.chars().take(200).collect(); let template = NotFoundTemplate { fragment, locale, app_name: layout.app_name, page_title, code_label, title, message, path: shown_path, home_label, login_label, }; let html = match template.render() { Ok(html) => html, Err(err) => { tracing::error!("404 template render failed: {err}"); return (StatusCode::NOT_FOUND, Html(FALLBACK_HTML.to_string())).into_response(); } }; let mut response = (StatusCode::NOT_FOUND, Html(html)).into_response(); if fragment { let headers = response.headers_mut(); headers.insert( HeaderName::from_static("hx-retarget"), HeaderValue::from_static("body"), ); headers.insert( HeaderName::from_static("hx-reswap"), HeaderValue::from_static("innerHTML"), ); } response } fn wants_json(path: &str, headers: &HeaderMap) -> bool { if path.starts_with("/api/") { return true; } let accept = headers .get(header::ACCEPT) .and_then(|value| value.to_str().ok()) .unwrap_or(""); accept.contains("application/json") && !accept.contains("text/html") } fn locale_from_request(request: &Request<Body>) -> String { if let Some(Locale(value)) = request.extensions().get::<Locale>() { let code = value.split(['_', '-']).next().unwrap_or(value); if SUPPORTED_LOCALES.contains(&code) { return code.to_string(); } } let header = request .headers() .get(header::ACCEPT_LANGUAGE) .and_then(|value| value.to_str().ok()) .unwrap_or(""); for part in header.split(',') { let token = part.split(';').next().unwrap_or("").trim(); let code = token.split(['_', '-']).next().unwrap_or(""); if SUPPORTED_LOCALES.contains(&code) { return code.to_string(); } } "fr".to_string() } async fn tr(i18n: Option<&I18nService>, key: &str, locale: &str, fallback: &str) -> String { let Some(i18n) = i18n else { return fallback.to_string(); }; let value = i18n.translate(key, locale).await; if value == key { fallback.to_string() } else { value } } const FALLBACK_HTML: &str = r#"<!DOCTYPE html> <html lang="fr"><head><meta charset="utf-8"><title>404</title></head> <body><h1>Page introuvable</h1><p><a href="/">Retour à l'accueil</a></p></body></html>"#; #[cfg(test)] mod tests { use super::*; use axum::body::to_bytes; #[tokio::test] async fn browser_gets_html_404() { let request = Request::builder() .uri("/cette-page-nexiste-pas") .header(header::ACCEPT, "text/html") .body(Body::empty()) .expect("request"); let response = not_found(request).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); let bytes = to_bytes(response.into_body(), 64 * 1024) .await .expect("body"); let html = String::from_utf8_lossy(&bytes); assert!(html.contains("Page introuvable"), "{html}"); assert!(html.contains("/cette-page-nexiste-pas"), "{html}"); assert!(html.contains("l'accueil"), "{html}"); } #[tokio::test] async fn api_path_stays_json() { let request = Request::builder() .uri("/api/v1/does-not-exist") .header(header::ACCEPT, "text/html") .body(Body::empty()) .expect("request"); let response = not_found(request).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); let bytes = to_bytes(response.into_body(), 8 * 1024) .await .expect("body"); let body = String::from_utf8_lossy(&bytes); assert!(body.contains("\"status\":404"), "{body}"); assert!(!body.contains("<html"), "{body}"); } #[tokio::test] async fn json_accept_stays_json() { let request = Request::builder() .uri("/nope") .header(header::ACCEPT, "application/json") .body(Body::empty()) .expect("request"); let response = not_found(request).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "{content_type}"); } }
GitRust