Skip to main content

didbot_brand/
page.rs

1//! The page a browser lands on at each step of a sign-in: the server's
2//! authorize and browser pages, the operator dashboard's sign-in, and the
3//! loopback listener a command line waits on.
4//!
5//! Everything it shows is inline, so one page works from a server and from
6//! a listener that answers once and closes. Its one stylesheet is the
7//! dashboard's, and [`content_security_policy`] admits that stylesheet by
8//! hash and nothing else.
9
10use std::sync::LazyLock;
11
12use base64::Engine as _;
13use sha2::Digest as _;
14
15/// The dashboard's stylesheet, then what a landing page adds to it.
16pub const STYLE: &str = concat!(
17    include_str!("../../didbot-serve/assets/dashboard/dashboard.css"),
18    include_str!("landing.css"),
19);
20
21/// The colour a page's outcome box is drawn in.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Tone {
24    /// The step finished.
25    Done,
26    /// The step waits on something else.
27    Waiting,
28    /// The step ended without what it was for.
29    Refused,
30}
31
32impl Tone {
33    fn as_str(self) -> &'static str {
34        match self {
35            Tone::Done => "done",
36            Tone::Waiting => "waiting",
37            Tone::Refused => "refused",
38        }
39    }
40}
41
42/// One landing page.
43#[derive(Debug, Clone, Copy)]
44pub struct Landing<'a> {
45    /// The page's heading: a placeholder, per the copywriting rule.
46    pub placeholder: &'a str,
47    /// The outcome box's colour.
48    pub tone: Tone,
49    /// What happened, in a few words. Escaped here.
50    pub status: &'a str,
51    /// The outcome box's contents, as markup. Values in it are the
52    /// caller's to [`escape`].
53    pub body: &'a str,
54    /// Reload the page this often, in seconds, with no script.
55    pub reload: Option<u64>,
56}
57
58impl Landing<'_> {
59    /// The whole document.
60    pub fn render(&self) -> String {
61        let status = escape(self.status);
62        let refresh = self.reload.map_or_else(String::new, |seconds| {
63            format!("\n  <meta http-equiv=\"refresh\" content=\"{seconds}\">")
64        });
65        let favicon = base64::engine::general_purpose::STANDARD.encode(crate::FAVICON_SVG);
66        format!(
67            r#"<!doctype html>
68<html lang="en">
69<head>
70  <meta charset="utf-8" />
71  <meta name="viewport" content="width=device-width, initial-scale=1" />{refresh}
72  <title>{status} · did.bot</title>
73  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,{favicon}" />
74  <style>{STYLE}</style>
75</head>
76<body class="landing">
77  <header class="col">
78    <div class="wordmark" aria-hidden="true">{wordmark}</div>
79    <h1 data-placeholder="{placeholder}">{placeholder}</h1>
80  </header>
81  <main>
82    <section class="readout" data-tone="{tone}" aria-labelledby="landing-status">
83      <h2 id="landing-status">{status}</h2>
84      {body}
85    </section>
86  </main>
87</body>
88</html>
89"#,
90            wordmark = crate::WORDMARK_SVG,
91            placeholder = escape(self.placeholder),
92            tone = self.tone.as_str(),
93            body = self.body,
94        )
95    }
96}
97
98/// `value`, safe in an HTML attribute or in text.
99pub fn escape(value: &str) -> String {
100    html_escape::encode_quoted_attribute(value).into_owned()
101}
102
103/// The `Content-Security-Policy` a landing page is served under: its own
104/// stylesheet and its inline icon, and no script, fetch, form or frame.
105pub fn content_security_policy() -> &'static str {
106    static POLICY: LazyLock<String> = LazyLock::new(|| {
107        let digest = sha2::Sha256::digest(STYLE.as_bytes());
108        let hash = base64::engine::general_purpose::STANDARD.encode(digest);
109        format!(
110            "default-src 'none'; style-src 'sha256-{hash}'; img-src data:; \
111             base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
112        )
113    });
114    &POLICY
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    /// The policy's hash is of the exact text inside the page's `<style>`,
122    /// which is what a browser hashes.
123    #[test]
124    fn the_policy_admits_the_style_the_page_carries() {
125        let page = Landing {
126            placeholder: "⟦test-landing-hollyhock⟧",
127            tone: Tone::Done,
128            status: "signed in",
129            body: "<p>done</p>",
130            reload: None,
131        }
132        .render();
133        let style = page
134            .split_once("<style>")
135            .and_then(|(_, rest)| rest.split_once("</style>"))
136            .map(|(style, _)| style)
137            .expect("the page has a style element");
138        let hash = base64::engine::general_purpose::STANDARD
139            .encode(sha2::Sha256::digest(style.as_bytes()));
140        assert!(content_security_policy().contains(&format!("'sha256-{hash}'")));
141        assert_eq!(page.matches("<style").count(), 1, "one style element");
142        assert!(!page.contains(" style=\""), "no inline style attribute");
143        assert!(!page.contains("<script"));
144    }
145}