Skip to main content

teamcraft/
assets.rs

1//! Static files embedded into the binary – stylesheets, scripts, logos and
2//! fonts – and how they are served.
3//!
4//! Two sources: the explicit list below (`static/`, `media/`: files of the web
5//! UI) and `shared/`, the files the web UI shares with the desktop app.
6//! `build.rs` walks `shared/` and writes its table into `OUT_DIR`; every file
7//! there is served at `/static/shared/<path>` (README, tests and test vectors
8//! are skipped – the same rule as in `desktop/build.rs`).
9//!
10//! Cache busting: pages link every asset with `?v=<assets version>`, a hash
11//! over all embedded files. A request with the current version may be cached
12//! for a year (`immutable`); any other request – an outdated link, a font
13//! referenced from CSS – has to be revalidated (`no-cache` + `ETag`). So a
14//! browser never shows an old stylesheet or logo after the server was updated.
15//!
16//! Shared scripts are linked through a versioned path instead,
17//! `/static/shared/v/<assets version>/js/…`: ES modules import each other with
18//! relative URLs, which drop a `?v=` query but stay inside the versioned
19//! directory. The same rules apply – the current version is `immutable`, any
20//! other version is still served, with `no-cache`, so an outdated page works.
21
22use std::sync::OnceLock;
23
24use axum::{
25    http::{header, HeaderMap, HeaderValue, StatusCode},
26    response::{IntoResponse, Response},
27};
28use sha2::{Digest, Sha256};
29
30const CSS: &str = "text/css; charset=utf-8";
31const JS: &str = "text/javascript; charset=utf-8";
32const SVG: &str = "image/svg+xml";
33const WOFF2: &str = "font/woff2";
34const TXT: &str = "text/plain; charset=utf-8";
35
36/// URL prefix of the files from `shared/`.
37pub const SHARED_PREFIX: &str = "/static/shared/";
38
39/// One embedded file and the URL path it is served at.
40pub struct Asset {
41    pub url: &'static str,
42    pub content_type: &'static str,
43    pub body: &'static [u8],
44}
45
46macro_rules! assets {
47    ($( $url:literal => $file:literal, $ct:expr; )*) => {
48        &[$( Asset { url: $url, content_type: $ct, body: include_bytes!(concat!("../", $file)) } ),*]
49    };
50}
51
52/// Files of the web UI only.
53static LISTED: &[Asset] = assets![
54    "/static/style.css" => "static/style.css", CSS;
55    "/static/app.js" => "static/app.js", JS;
56    "/static/media/logos/TeamCraft_Icon.svg" => "media/logos/TeamCraft_Icon.svg", SVG;
57    "/static/media/logos/TeamCraft_Logo_Blue.svg" => "media/logos/TeamCraft_Logo_Blue.svg", SVG;
58    "/static/media/logos/TeamCraft_Logo_White.svg" => "media/logos/TeamCraft_Logo_White.svg", SVG;
59    "/static/media/icons/Green_tick.svg" => "media/icons/Green_tick.svg", SVG;
60    "/static/media/icons/Red_cross.svg" => "media/icons/Red_cross.svg", SVG;
61    "/static/media/icons/White_plus.svg" => "media/icons/White_plus.svg", SVG;
62    "/static/media/icons/Yellow_circle.svg" => "media/icons/Yellow_circle.svg", SVG;
63];
64
65/// Files from `shared/`, listed by `build.rs`.
66const SHARED: &[Asset] = include!(concat!(env!("OUT_DIR"), "/shared_assets.rs"));
67const _: () = assert!(!SHARED.is_empty(), "build.rs found no files in shared/");
68
69/// Every embedded asset.
70pub fn all() -> impl Iterator<Item = &'static Asset> {
71    LISTED.iter().chain(SHARED.iter())
72}
73
74/// URL of the favicon (the TeamCraft icon).
75pub const FAVICON: &str = "/static/media/logos/TeamCraft_Icon.svg";
76
77fn hashes() -> &'static (String, Vec<String>) {
78    static HASHES: OnceLock<(String, Vec<String>)> = OnceLock::new();
79    HASHES.get_or_init(|| {
80        let mut whole = Sha256::new();
81        let per_asset = all()
82            .map(|a| {
83                whole.update(a.url.as_bytes());
84                whole.update(a.body);
85                hex::encode(&Sha256::digest(a.body)[..8])
86            })
87            .collect();
88        (hex::encode(&whole.finalize()[..6]), per_asset)
89    })
90}
91
92/// Version of all embedded assets (12 hex digits), used as `?v=` in links
93/// and in the versioned path of shared scripts.
94pub fn version() -> &'static str {
95    &hashes().0
96}
97
98pub fn find(url: &str) -> Option<&'static Asset> {
99    all().find(|a| a.url == url)
100}
101
102/// The asset for a request path, and whether the request names the current
103/// version (`?v=<version>`, or the path `/static/shared/v/<version>/…`).
104pub fn resolve(path: &str, query: Option<&str>) -> Option<(&'static Asset, bool)> {
105    let versioned = path
106        .strip_prefix(SHARED_PREFIX)
107        .and_then(|rest| rest.strip_prefix("v/"))
108        .and_then(|rest| rest.split_once('/'));
109    if let Some((v, rest)) = versioned {
110        let asset = find(&format!("{SHARED_PREFIX}{rest}"))?;
111        return Some((asset, v == version()));
112    }
113    let current = query.is_some_and(|q| q.split('&').any(|p| p == format!("v={}", version())));
114    find(path).map(|asset| (asset, current))
115}
116
117/// The asset as a response, honouring `If-None-Match` and the cache rules
118/// described at the top of this module (`current`: see [`resolve`]).
119pub fn respond(asset: &'static Asset, current: bool, headers: &HeaderMap) -> Response {
120    let index = all().position(|a| std::ptr::eq(a, asset)).unwrap_or(0);
121    let etag = format!("\"{}\"", hashes().1[index]);
122    let cache = if current {
123        "public, max-age=31536000, immutable"
124    } else {
125        "no-cache"
126    };
127    let not_modified = headers
128        .get(header::IF_NONE_MATCH)
129        .and_then(|v| v.to_str().ok())
130        .is_some_and(|v| v.split(',').any(|t| t.trim() == etag || t.trim() == "*"));
131    let mut response = if not_modified {
132        StatusCode::NOT_MODIFIED.into_response()
133    } else {
134        (
135            [(
136                header::CONTENT_TYPE,
137                HeaderValue::from_static(asset.content_type),
138            )],
139            asset.body,
140        )
141            .into_response()
142    };
143    let h = response.headers_mut();
144    h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
145    if let Ok(v) = HeaderValue::from_str(&etag) {
146        h.insert(header::ETAG, v);
147    }
148    response
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn versions_and_lookup() {
157        assert_eq!(version().len(), 12);
158        assert!(version().chars().all(|c| c.is_ascii_hexdigit()));
159        let mut urls: Vec<_> = all().map(|a| a.url).collect();
160        let count = urls.len();
161        urls.sort();
162        urls.dedup();
163        assert_eq!(urls.len(), count, "unique URLs");
164        assert!(find("/static/style.css").is_some());
165        assert!(find("/static/shared/css/skins.css").is_some());
166        assert!(find("/static/shared/js/viewer.js").is_some());
167        assert!(find(FAVICON).is_some());
168        assert!(find("/static/../Cargo.toml").is_none());
169        assert!(find("/static/shared/../../Cargo.toml").is_none());
170        // every skin logo is embedded
171        for skin in crate::domain::SKINS {
172            assert!(
173                find(&format!("/static/media/{}", skin.logo)).is_some(),
174                "{}",
175                skin.logo
176            );
177        }
178    }
179
180    #[test]
181    fn cache_rules() {
182        let (asset, current) =
183            resolve("/static/style.css", Some(&format!("v={}", version()))).unwrap();
184        assert!(current);
185        let fresh = respond(asset, current, &HeaderMap::new());
186        assert_eq!(fresh.status(), StatusCode::OK);
187        assert!(fresh.headers()[header::CACHE_CONTROL]
188            .to_str()
189            .unwrap()
190            .contains("immutable"));
191        let (asset, current) = resolve("/static/style.css", Some("v=0.1.0")).unwrap();
192        assert!(!current);
193        let old = respond(asset, current, &HeaderMap::new());
194        assert_eq!(old.headers()[header::CACHE_CONTROL], "no-cache");
195        let etag = old.headers()[header::ETAG].clone();
196        let mut headers = HeaderMap::new();
197        headers.insert(header::IF_NONE_MATCH, etag);
198        let again = respond(asset, false, &headers);
199        assert_eq!(again.status(), StatusCode::NOT_MODIFIED);
200    }
201
202    /// Shared scripts under `/static/shared/v/<version>/…`: the current
203    /// version is immutable, any other version is served for revalidation.
204    #[test]
205    fn versioned_shared_paths() {
206        let viewer = find("/static/shared/js/viewer.js").unwrap();
207        let path = format!("/static/shared/v/{}/js/viewer.js", version());
208        let (asset, current) = resolve(&path, None).unwrap();
209        assert!(std::ptr::eq(asset, viewer) && current);
210        let (asset, current) = resolve("/static/shared/v/000000000000/js/viewer.js", None).unwrap();
211        assert!(std::ptr::eq(asset, viewer) && !current);
212        let (_, current) = resolve("/static/shared/js/viewer.js", None).unwrap();
213        assert!(!current);
214        assert!(resolve("/static/shared/v/x/js/missing.js", None).is_none());
215        assert!(resolve("/static/shared/v/x/../../style.css", None).is_none());
216        // only shared files have a versioned path
217        assert!(resolve(
218            &format!("/static/shared/v/{}/../style.css", version()),
219            None
220        )
221        .is_none());
222        assert_eq!(viewer.content_type, JS);
223    }
224
225    /// Every file of a directory below the repository root, recursively,
226    /// as `/`-separated paths relative to it.
227    fn files_below(dir: &str) -> Vec<String> {
228        fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec<String>) {
229            for entry in std::fs::read_dir(dir).unwrap() {
230                let path = entry.unwrap().path();
231                if path.is_dir() {
232                    walk(root, &path, out);
233                } else {
234                    let rel = path.strip_prefix(root).unwrap().to_str().unwrap();
235                    out.push(rel.replace('\\', "/"));
236                }
237            }
238        }
239        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(dir);
240        let mut out = Vec::new();
241        walk(&root, &root, &mut out);
242        out.sort();
243        out
244    }
245
246    /// The README, tests and test vectors of `shared/` stay in the repository
247    /// (same rule as `build.rs` and `desktop/build.rs`).
248    fn shared_servable(rel: &str) -> bool {
249        let name = rel.rsplit('/').next().unwrap_or(rel);
250        !(rel.starts_with("test-vectors/")
251            || name == "README.md"
252            || name.ends_with(".test.js")
253            || name.starts_with('.'))
254    }
255
256    /// The server embeds exactly the servable files of `shared/`, byte for
257    /// byte, at `/static/shared/<path>` (the desktop app has the mirror test
258    /// for its copy in `desktop/src/lib.rs`).
259    #[test]
260    fn shared_files_are_served() {
261        let expected: Vec<String> = files_below("shared")
262            .into_iter()
263            .filter(|rel| shared_servable(rel))
264            .collect();
265        assert!(expected.contains(&"css/skins.css".to_string()));
266        let mut served: Vec<String> = all()
267            .filter_map(|a| a.url.strip_prefix(SHARED_PREFIX))
268            .map(str::to_string)
269            .collect();
270        served.sort();
271        assert_eq!(served, expected, "shared/ and the embedded table differ");
272        for rel in &expected {
273            let disk =
274                std::fs::read(format!("{}/shared/{rel}", env!("CARGO_MANIFEST_DIR"))).unwrap();
275            assert_eq!(
276                find(&format!("{SHARED_PREFIX}{rel}")).unwrap().body,
277                disk,
278                "{rel}"
279            );
280        }
281    }
282
283    /// Files in `shared/` are copied into another program's bundle, so every
284    /// stylesheet, script and SVG names its licence in the first line.
285    #[test]
286    fn shared_files_carry_spdx_header() {
287        let mut checked = 0;
288        for rel in files_below("shared") {
289            if !(rel.ends_with(".css") || rel.ends_with(".js") || rel.ends_with(".svg")) {
290                continue;
291            }
292            let text =
293                std::fs::read_to_string(format!("{}/shared/{rel}", env!("CARGO_MANIFEST_DIR")))
294                    .unwrap();
295            let first = text.lines().next().unwrap_or("");
296            let ok = if rel.ends_with(".svg") {
297                first.contains("<!-- SPDX-License-Identifier: MPL-2.0 -->")
298            } else {
299                first == "/* SPDX-License-Identifier: MPL-2.0 */"
300            };
301            assert!(
302                ok,
303                "shared/{rel}: the first line must be the SPDX MPL-2.0 header"
304            );
305            checked += 1;
306        }
307        assert!(checked >= 2);
308    }
309
310    /// Every design token the stylesheets use without a fallback is declared
311    /// somewhere (skins.css, style.css, or the desktop app.css that loads
312    /// skins.css): an undefined `var(--x)` silently drops the declaration.
313    #[test]
314    fn css_tokens_are_defined() {
315        let sheets = [
316            (
317                "shared/css/skins.css",
318                include_str!("../shared/css/skins.css"),
319            ),
320            ("static/style.css", include_str!("../static/style.css")),
321            ("desktop/ui/app.css", include_str!("../desktop/ui/app.css")),
322        ];
323        let declared: std::collections::HashSet<&str> = sheets
324            .iter()
325            .flat_map(|(_, css)| css.match_indices("--").map(|(start, _)| &css[start..]))
326            .filter_map(|s| {
327                let (name, rest) = custom_property(s)?;
328                rest.trim_start().starts_with(':').then_some(name)
329            })
330            .collect();
331        assert!(declared.contains("--radius-sm") && declared.contains("--primary-from"));
332        for (file, css) in sheets {
333            for (start, _) in css.match_indices("var(") {
334                let after = &css[start + 4..];
335                let Some((name, rest)) = custom_property(after) else {
336                    continue;
337                };
338                if rest.trim_start().starts_with(',') {
339                    continue; // has a fallback
340                }
341                assert!(declared.contains(name), "{file}: {name} is not declared");
342            }
343        }
344    }
345
346    /// One set of density tokens (shared/css/skins.css): the desktop app
347    /// selects the compact set instead of declaring its own control and row
348    /// heights or body text size.
349    #[test]
350    fn one_density_token_set() {
351        let skins = include_str!("../shared/css/skins.css");
352        let compact = skins
353            .split(":root[data-density=\"compact\"] {")
354            .nth(1)
355            .and_then(|s| s.split('}').next())
356            .expect("compact density block");
357        for token in [
358            "--fs-body",
359            "--lh-body",
360            "--ctl-h:",
361            "--ctl-h-sm",
362            "--row-h:",
363        ] {
364            assert!(compact.contains(token), "compact density sets {token}");
365        }
366        let app_css = include_str!("../desktop/ui/app.css");
367        for own in ["--d-ctl", "--d-row", "--ctl-h:", "--row-h:", "--fs-body:"] {
368            assert!(!app_css.contains(own), "desktop/ui/app.css declares {own}");
369        }
370        let index = include_str!("../desktop/ui/index.html");
371        assert!(
372            index.contains("<html lang=\"en\" data-skin=\"teamcraft\" data-density=\"compact\">")
373        );
374    }
375
376    /// `--name` at the start of `s` and the text after it.
377    fn custom_property(s: &str) -> Option<(&str, &str)> {
378        let body = s.strip_prefix("--")?;
379        let len = body
380            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
381            .unwrap_or(body.len());
382        (len > 0).then(|| (&s[..len + 2], &body[len..]))
383    }
384
385    /// SVGs must scale (viewBox) and must not contain active content.
386    #[test]
387    fn media_svgs_scale_and_are_inert() {
388        for asset in all().filter(|a| a.content_type == SVG) {
389            let svg = std::str::from_utf8(asset.body).expect("SVG is UTF-8");
390            let root = svg
391                .find("<svg")
392                .map(|start| &svg[start..])
393                .and_then(|s| s.split_once('>'))
394                .map(|(tag, _)| tag)
395                .unwrap_or_else(|| panic!("{} has no <svg> element", asset.url));
396            assert!(
397                root.contains("viewBox"),
398                "{}: without a viewBox the icon does not scale in every browser",
399                asset.url
400            );
401            let lower = svg.to_ascii_lowercase();
402            for active in [
403                "<script",
404                "javascript:",
405                "onload=",
406                "onclick=",
407                "<foreignobject",
408            ] {
409                assert!(!lower.contains(active), "{} contains {active}", asset.url);
410            }
411        }
412    }
413}