1use std::sync::LazyLock;
11
12use base64::Engine as _;
13use sha2::Digest as _;
14
15pub const STYLE: &str = concat!(
17 include_str!("../../didbot-serve/assets/dashboard/dashboard.css"),
18 include_str!("landing.css"),
19);
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Tone {
24 Done,
26 Waiting,
28 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#[derive(Debug, Clone, Copy)]
44pub struct Landing<'a> {
45 pub placeholder: &'a str,
47 pub tone: Tone,
49 pub status: &'a str,
51 pub body: &'a str,
54 pub reload: Option<u64>,
56}
57
58impl Landing<'_> {
59 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
98pub fn escape(value: &str) -> String {
100 html_escape::encode_quoted_attribute(value).into_owned()
101}
102
103pub 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 #[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}