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