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&#39;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}");
    }
}