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("<script>")); assert!(html.contains("a & b")); assert!(html.contains(""quoted"")); } /// 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}" ); } } }
GitRust