1use std::collections::HashMap;
5use std::sync::Arc;
6
7use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
8use axum::http::{StatusCode, Uri};
9use axum::response::{IntoResponse, Response};
10
11use crate::Error;
12
13#[derive(Debug, Clone, Copy, Default)]
16pub struct Embedded {
17 pub views: &'static [(&'static str, &'static str)],
19 pub lang: &'static [(&'static str, &'static str)],
21 pub public: &'static [(&'static str, &'static [u8])],
23}
24
25pub(crate) fn content_type(path: &str) -> &'static str {
27 let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
28 match ext.as_deref() {
29 Some("css") => "text/css; charset=utf-8",
30 Some("js" | "mjs") => "text/javascript; charset=utf-8",
31 Some("html" | "htm") => "text/html; charset=utf-8",
32 Some("json" | "map") => "application/json",
33 Some("txt") => "text/plain; charset=utf-8",
34 Some("svg") => "image/svg+xml",
35 Some("png") => "image/png",
36 Some("jpg" | "jpeg") => "image/jpeg",
37 Some("gif") => "image/gif",
38 Some("webp") => "image/webp",
39 Some("ico") => "image/x-icon",
40 Some("woff") => "font/woff",
41 Some("woff2") => "font/woff2",
42 Some("pdf") => "application/pdf",
43 Some("xml") => "application/xml",
44 _ => "application/octet-stream",
45 }
46}
47
48pub(crate) type PublicFiles = Arc<HashMap<&'static str, &'static [u8]>>;
49
50pub(crate) fn public_map(files: &'static [(&'static str, &'static [u8])]) -> PublicFiles {
51 Arc::new(files.iter().copied().collect())
52}
53
54pub(crate) fn serve(files: &PublicFiles, uri: &Uri) -> Response {
56 let path = uri.path().trim_start_matches('/');
57 let path = if path.is_empty() { "index.html" } else { path };
58 match files.get(path) {
59 Some(bytes) => (
60 StatusCode::OK,
61 [
62 (CONTENT_TYPE, content_type(path)),
63 (CACHE_CONTROL, cache_control(uri)),
64 ],
65 *bytes,
66 )
67 .into_response(),
68 None => Error::NotFound.into_response(),
69 }
70}
71
72pub(crate) enum AssetVersions {
75 Embedded(HashMap<&'static str, String>),
77 Disk {
80 root: std::path::PathBuf,
81 seen: std::sync::Mutex<HashMap<String, (std::time::SystemTime, String)>>,
82 },
83}
84
85fn short_hash(bytes: &[u8]) -> String {
86 crate::webhook::sha256_hex(bytes)[..8].to_owned()
87}
88
89impl AssetVersions {
90 pub(crate) fn new(
91 public: &std::path::Path,
92 embedded: Option<&'static [(&'static str, &'static [u8])]>,
93 ) -> Self {
94 match embedded {
95 Some(files) => Self::Embedded(
96 files
97 .iter()
98 .map(|(path, bytes)| (*path, short_hash(bytes)))
99 .collect(),
100 ),
101 None => Self::Disk {
102 root: public.to_path_buf(),
103 seen: std::sync::Mutex::default(),
104 },
105 }
106 }
107
108 pub(crate) fn version(&self, path: &str) -> Option<String> {
110 let path = path.trim_start_matches('/');
111 match self {
112 Self::Embedded(files) => files.get(path).cloned(),
113 Self::Disk { root, seen } => {
114 let file = root.join(path);
115 if !file.starts_with(root) || path.split('/').any(|part| part == "..") {
116 return None;
117 }
118 let modified = std::fs::metadata(&file).and_then(|m| m.modified()).ok()?;
119 let mut seen = seen.lock().unwrap_or_else(|e| e.into_inner());
120 if let Some((at, hash)) = seen.get(path)
121 && *at == modified
122 {
123 return Some(hash.clone());
124 }
125 let hash = short_hash(&std::fs::read(&file).ok()?);
126 seen.insert(path.to_owned(), (modified, hash.clone()));
127 Some(hash)
128 }
129 }
130 }
131}
132
133pub(crate) fn is_versioned(uri: &Uri) -> bool {
136 uri.query()
137 .is_some_and(|query| query.split('&').any(|pair| pair.starts_with("v=")))
138}
139
140pub(crate) fn cache_control(uri: &Uri) -> &'static str {
142 if is_versioned(uri) {
143 "public, max-age=31536000, immutable"
144 } else {
145 "public, max-age=3600"
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn public_files_get_their_content_type() {
155 for (path, expected) in [
156 ("logo.PNG", "image/png"),
157 ("a.jpg", "image/jpeg"),
158 ("a.jpeg", "image/jpeg"),
159 ("a.gif", "image/gif"),
160 ("a.webp", "image/webp"),
161 ("favicon.ico", "image/x-icon"),
162 ("f.woff", "font/woff"),
163 ("f.woff2", "font/woff2"),
164 ("terms.pdf", "application/pdf"),
165 ("sitemap.xml", "application/xml"),
166 ("archive.zip", "application/octet-stream"),
167 ("README", "application/octet-stream"),
168 ] {
169 assert_eq!(content_type(path), expected, "{path}");
170 }
171 }
172
173 #[test]
176 fn disk_versions_are_cached_and_stay_in_the_public_folder() {
177 let dir = tempfile::tempdir().unwrap();
178 std::fs::write(dir.path().join("app.css"), "a{}").unwrap();
179 let versions = AssetVersions::new(dir.path(), None);
180 let first = versions.version("/app.css").expect("a version");
181 assert_eq!(versions.version("app.css"), Some(first));
182 assert_eq!(versions.version("../secret.txt"), None);
183 assert_eq!(versions.version("missing.css"), None);
184 }
185}