Skip to main content

wavefunk_ui/
assets.rs

1use rust_embed::RustEmbed;
2use std::borrow::Cow;
3use std::fmt::Write as _;
4
5pub const DEFAULT_BASE_PATH: &str = "/static/wavefunk";
6pub const STYLESHEET_PATH: &str = "css/wavefunk.css";
7pub const SCRIPT_PATH: &str = "js/wavefunk.js";
8pub const HTMX_SCRIPT_PATH: &str = "js/htmx.min.js";
9pub const HTMX_SSE_SCRIPT_PATH: &str = "js/htmx-sse.js";
10pub const FONT_SANS_PATH: &str = "css/fonts/MartianGrotesk-VF.woff2";
11pub const FONT_MONO_PATH: &str = "css/fonts/MartianMono-VF.woff2";
12pub const CACHE_CONTROL: &str = "public, max-age=0, must-revalidate";
13
14#[derive(RustEmbed)]
15#[folder = "static/wavefunk"]
16struct EmbeddedAssets;
17
18#[derive(Clone, Debug)]
19pub struct Asset {
20    pub path: String,
21    pub bytes: Cow<'static, [u8]>,
22    pub content_type: &'static str,
23}
24
25pub fn get(path: &str) -> Option<Asset> {
26    let path = normalize_path(path)?;
27    EmbeddedAssets::get(path).map(|file| Asset {
28        path: path.to_owned(),
29        bytes: file.data,
30        content_type: content_type(path),
31    })
32}
33
34pub fn etag(path: &str) -> Option<String> {
35    let path = normalize_path(path)?;
36    EmbeddedAssets::get(path).map(|file| format_etag(file.metadata.sha256_hash()))
37}
38
39pub fn iter() -> impl Iterator<Item = Cow<'static, str>> {
40    EmbeddedAssets::iter()
41}
42
43pub fn content_type(path: &str) -> &'static str {
44    match path.rsplit_once('.').map(|(_, ext)| ext) {
45        Some("css") => "text/css; charset=utf-8",
46        Some("js") => "text/javascript; charset=utf-8",
47        Some("html") => "text/html; charset=utf-8",
48        Some("svg") => "image/svg+xml",
49        Some("png") => "image/png",
50        Some("jpg" | "jpeg") => "image/jpeg",
51        Some("webp") => "image/webp",
52        Some("woff2") => "font/woff2",
53        Some("txt") => "text/plain; charset=utf-8",
54        _ => "application/octet-stream",
55    }
56}
57
58pub fn normalize_path(path: &str) -> Option<&str> {
59    let path = path.strip_prefix('/').unwrap_or(path);
60    let default_base_path = DEFAULT_BASE_PATH.trim_start_matches('/');
61    let path = path
62        .strip_prefix(default_base_path)
63        .and_then(|path| path.strip_prefix('/'))
64        .unwrap_or(path);
65
66    if path.is_empty()
67        || path.starts_with('/')
68        || path.contains('\\')
69        || path
70            .split('/')
71            .any(|part| part.is_empty() || part == "." || part == "..")
72    {
73        return None;
74    }
75
76    Some(path)
77}
78
79fn format_etag(hash: [u8; 32]) -> String {
80    let mut etag = String::with_capacity(66);
81    etag.push('"');
82    for byte in hash {
83        write!(&mut etag, "{byte:02x}").expect("writing a hash to a string should not fail");
84    }
85    etag.push('"');
86    etag
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn serves_key_runtime_assets_with_content_types() {
95        let cases = [
96            (STYLESHEET_PATH, "text/css; charset=utf-8"),
97            (SCRIPT_PATH, "text/javascript; charset=utf-8"),
98            (HTMX_SCRIPT_PATH, "text/javascript; charset=utf-8"),
99            (HTMX_SSE_SCRIPT_PATH, "text/javascript; charset=utf-8"),
100            (FONT_SANS_PATH, "font/woff2"),
101            (FONT_MONO_PATH, "font/woff2"),
102        ];
103
104        for (path, content_type) in cases {
105            let asset = get(path).unwrap_or_else(|| panic!("{path} should be embedded"));
106            assert_eq!(asset.path, path);
107            assert_eq!(asset.content_type, content_type);
108            assert!(!asset.bytes.is_empty());
109        }
110    }
111
112    #[test]
113    fn accepts_default_mount_paths() {
114        let asset = get("/static/wavefunk/js/htmx.min.js").expect("public mount path should work");
115        assert_eq!(asset.path, HTMX_SCRIPT_PATH);
116    }
117
118    #[test]
119    fn lists_packaged_runtime_assets() {
120        let paths = iter().collect::<Vec<_>>();
121
122        assert!(paths.iter().any(|path| path.as_ref() == STYLESHEET_PATH));
123        assert!(paths.iter().any(|path| path.as_ref() == SCRIPT_PATH));
124        assert!(paths.iter().any(|path| path.as_ref() == HTMX_SCRIPT_PATH));
125        assert!(
126            paths
127                .iter()
128                .any(|path| path.as_ref() == HTMX_SSE_SCRIPT_PATH)
129        );
130        assert!(paths.iter().any(|path| path.as_ref() == FONT_SANS_PATH));
131        assert!(paths.iter().any(|path| path.as_ref() == FONT_MONO_PATH));
132    }
133
134    #[test]
135    fn exposes_shared_cache_policy() {
136        assert_eq!(CACHE_CONTROL, "public, max-age=0, must-revalidate");
137    }
138
139    #[test]
140    fn exposes_stable_entity_tags_for_runtime_assets() {
141        let stylesheet_etag = etag(STYLESHEET_PATH).expect("stylesheet should have an entity tag");
142
143        assert_eq!(stylesheet_etag.len(), 66);
144        assert!(stylesheet_etag.starts_with('"'));
145        assert!(stylesheet_etag.ends_with('"'));
146        assert_eq!(
147            stylesheet_etag,
148            etag(format!("{DEFAULT_BASE_PATH}/{STYLESHEET_PATH}").as_str()).unwrap()
149        );
150        assert!(etag("../Cargo.toml").is_none());
151    }
152
153    #[test]
154    fn rejects_path_traversal() {
155        assert!(get("../Cargo.toml").is_none());
156        assert!(get("css/../Cargo.toml").is_none());
157        assert!(get("css//wavefunk.css").is_none());
158        assert!(get("/static/wavefunk/../Cargo.toml").is_none());
159        assert!(get(r"css\wavefunk.css").is_none());
160    }
161}