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}