email_change_pages.rs 162 lignes · 6723 octets
//! Self-contained HTML pages for the two email-change links (RFC 0041).
//!
//! `rustwarden-core` must stay usable without any UI crate, so these pages are
//! built with `format!` right here: **no Askama, no `rustwarden-ui`, no file
//! read at runtime**, therefore no `AppError::Template` can ever occur on the
//! path a user reaches from their mailbox. A non-regression test asserts this
//! module's own source imports neither.
//!
//! The rendered document carries no JavaScript, no external stylesheet and no
//! absolute URL: the form posts to `action=""`, i.e. the current URL, so the
//! pages stay correct whatever prefix the consumer mounted the routes under.
//!
//! A consumer wanting its own presentation mounts its own `GET` route on the
//! same path and renders whatever it likes, as long as the form posts to the
//! core's `POST` route. The extension point is the route, not a rendering
//! trait.

use crate::services::email_sanitization::sanitize_email_content;

/// An interstitial (with a submit button) or result (without one) page.
pub(crate) struct EmailChangePage {
    /// Value of the document `lang` attribute.
    pub(crate) lang: String,
    /// Already translated page title, also used as the `<h1>`.
    pub(crate) title: String,
    /// Already translated body message.
    pub(crate) message: String,
    /// Already translated submit label. `None` renders **no form at all**:
    /// used for results and for links that cannot be used (unknown, expired
    /// or already consumed) — there is no point inviting a `POST` that would
    /// fail.
    pub(crate) button: Option<String>,
}

/// Inline stylesheet. Deliberately tiny and embedded: a CSP that forbids
/// inline styles only costs the styling, never the function of the page.
const PAGE_STYLE: &str = "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,\
'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1f2937;background:#f4f4f4;margin:0;\
padding:40px 20px}main{max-width:560px;margin:0 auto;background:#fff;border-radius:8px;\
padding:32px;box-shadow:0 2px 4px rgba(0,0,0,.1)}h1{font-size:22px;margin:0 0 16px}\
p{margin:0 0 24px}button{display:inline-block;padding:12px 28px;background:#10b981;color:#fff;\
border:0;border-radius:6px;font-size:16px;font-weight:600;cursor:pointer}";

/// Render the page as a standalone HTML document.
///
/// Every caller-supplied string is HTML-escaped, and the confirmation or
/// report token is never written into the body: it only ever lives in the URL
/// the form posts back to.
pub(crate) fn render(page: &EmailChangePage) -> String {
    let title = sanitize_email_content(&page.title);
    let message = sanitize_email_content(&page.message);
    let form = match &page.button {
        Some(label) => format!(
            "<form method=\"post\" action=\"\"><button type=\"submit\">{}</button></form>",
            sanitize_email_content(label)
        ),
        None => String::new(),
    };

    format!(
        "<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n\
         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
         <meta name=\"robots\" content=\"noindex, nofollow\">\n\
         <title>{title}</title>\n<style>{style}</style>\n</head>\n\
         <body>\n<main>\n<h1>{title}</h1>\n<p>{message}</p>\n{form}\n</main>\n</body>\n</html>\n",
        lang = sanitize_email_content(&page.lang),
        title = title,
        message = message,
        style = PAGE_STYLE,
        form = form,
    )
}

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

    fn interstitial() -> EmailChangePage {
        EmailChangePage {
            lang: "fr".to_string(),
            title: "Confirmer votre nouvelle adresse e-mail".to_string(),
            message: "Toutes vos sessions seront fermées.".to_string(),
            button: Some("Confirmer le changement".to_string()),
        }
    }

    #[test]
    fn interstitial_carries_a_post_form_and_a_button() {
        let html = render(&interstitial());
        assert!(html.starts_with("<!DOCTYPE html>"));
        assert!(html.contains("<form"));
        assert!(html.contains("method=\"post\""));
        assert!(html.contains("<button"));
        // Relative action: correct under any mount prefix.
        assert!(html.contains("action=\"\""));
    }

    #[test]
    fn page_carries_no_script_and_no_external_resource() {
        let html = render(&interstitial());
        assert!(!html.contains("<script"), "no JavaScript on this path");
        assert!(!html.contains("http://"), "no absolute external URL");
        assert!(!html.contains("https://"), "no absolute external URL");
        assert!(!html.contains("<link"), "no external stylesheet");
    }

    #[test]
    fn result_page_has_no_form_at_all() {
        let page = EmailChangePage {
            button: None,
            ..interstitial()
        };
        let html = render(&page);
        assert!(!html.contains("<form"), "nothing left to submit");
        assert!(!html.contains("<button"));
    }

    #[test]
    fn caller_strings_are_escaped() {
        let page = EmailChangePage {
            lang: "fr".to_string(),
            title: "<script>alert('x')</script>".to_string(),
            message: "a & b".to_string(),
            button: Some("\"quoted\"".to_string()),
        };
        let html = render(&page);
        assert!(!html.contains("<script>alert"));
        assert!(html.contains("&lt;script&gt;"));
        assert!(html.contains("a &amp; b"));
        assert!(html.contains("&quot;quoted&quot;"));
    }

    /// The token must never appear in the visible body: it travels in the URL
    /// the form posts back to, nowhere else.
    #[test]
    fn token_never_reaches_the_body() {
        let token = "8f14e45f-ea16-4d1b-9c2f-6ba0e6c5a3d7";
        let html = render(&interstitial());
        assert!(!html.contains(token));
    }

    /// Non-regression on the headless contract: this module builds its HTML
    /// by hand and must not gain a templating or UI dependency.
    #[test]
    fn module_depends_on_neither_askama_nor_the_ui_crate() {
        let source = include_str!("email_change_pages.rs");
        // Skip this very test's own mentions by looking at `use` lines only.
        for line in source
            .lines()
            .filter(|l| l.trim_start().starts_with("use "))
        {
            assert!(
                !line.contains("askama"),
                "the interstitial pages must not depend on Askama: {line}"
            );
            assert!(
                !line.contains("rustwarden_ui"),
                "the interstitial pages must not depend on rustwarden-ui: {line}"
            );
        }
    }
}