1use 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
36pub const SHARED_PREFIX: &str = "/static/shared/";
38
39pub 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
52static 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
65const SHARED: &[Asset] = include!(concat!(env!("OUT_DIR"), "/shared_assets.rs"));
67const _: () = assert!(!SHARED.is_empty(), "build.rs found no files in shared/");
68
69pub fn all() -> impl Iterator<Item = &'static Asset> {
71 LISTED.iter().chain(SHARED.iter())
72}
73
74pub 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
92pub 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
102pub 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
117pub 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 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 #[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 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 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 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 #[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 #[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 #[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; }
341 assert!(declared.contains(name), "{file}: {name} is not declared");
342 }
343 }
344 }
345
346 #[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 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 #[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}