1use axum::{
2 extract::Path,
3 http::{header, StatusCode},
4 response::{IntoResponse, Response},
5 routing::get,
6 Router,
7};
8use include_dir::{include_dir, Dir};
9
10static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/static/sf");
11
12pub fn routes() -> Router {
13 Router::new().route("/sf/{*path}", get(serve_asset))
14}
15
16async fn serve_asset(Path(path): Path<String>) -> Response {
17 let Some(file) = ASSETS.get_file(&path) else {
18 return StatusCode::NOT_FOUND.into_response();
19 };
20
21 let mime = mime_from_path(&path);
22 let cache = if is_immutable(&path) {
23 "public, max-age=31536000, immutable"
24 } else {
25 "public, max-age=3600"
26 };
27
28 (
29 StatusCode::OK,
30 [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
31 file.contents(),
32 )
33 .into_response()
34}
35
36fn mime_from_path(path: &str) -> &'static str {
37 match path.rsplit('.').next() {
38 Some("css") => "text/css; charset=utf-8",
39 Some("js") => "application/javascript; charset=utf-8",
40 Some("svg") => "image/svg+xml",
41 Some("woff2") => "font/woff2",
42 Some("woff") => "font/woff",
43 Some("ttf") => "font/ttf",
44 Some("eot") => "application/vnd.ms-fontobject",
45 Some("png") => "image/png",
46 Some("jpg" | "jpeg") => "image/jpeg",
47 Some("ico") => "image/x-icon",
48 Some("json") => "application/json",
49 Some("html") => "text/html; charset=utf-8",
50 Some("map") => "application/json",
51 _ => "application/octet-stream",
52 }
53}
54
55fn is_immutable(path: &str) -> bool {
56 path.starts_with("fonts/")
57 || path.starts_with("vendor/")
58 || path.starts_with("img/")
59 || is_versioned_bundle(path)
60}
61
62fn is_versioned_bundle(path: &str) -> bool {
63 path.strip_prefix("sf.")
64 .and_then(|rest| rest.rsplit_once('.'))
65 .map(|(version, ext)| {
66 !version.is_empty()
67 && version.chars().all(|ch| {
68 ch.is_ascii_digit()
69 || ch == '.'
70 || ch == '-'
71 || ch == '+'
72 || ch.is_ascii_alphabetic()
73 })
74 && matches!(ext, "css" | "js")
75 })
76 .unwrap_or(false)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use axum::{
83 body::{to_bytes, Body},
84 http::{Method, Request, StatusCode},
85 };
86 use tower::util::ServiceExt;
87
88 #[test]
89 fn versioned_bundles_are_detected() {
90 assert!(is_versioned_bundle("sf.0.3.0.css"));
91 assert!(is_versioned_bundle("sf.0.3.0.js"));
92 assert!(is_versioned_bundle("sf.0.3.0-beta.1.js"));
93 assert!(is_versioned_bundle("sf.0.3.0+build.7.css"));
94 assert!(!is_versioned_bundle("sf.css"));
95 assert!(!is_versioned_bundle("sf.js"));
96 assert!(!is_versioned_bundle("vendor/sf.0.3.0.js"));
97 }
98
99 #[test]
100 fn caches_paths_are_predicted_correctly() {
101 assert_eq!(mime_from_path("styles/sf.css"), "text/css; charset=utf-8");
102 assert_eq!(
103 mime_from_path("scripts/sf.js"),
104 "application/javascript; charset=utf-8"
105 );
106 assert_eq!(mime_from_path("img/logo.svg"), "image/svg+xml");
107 assert_eq!(mime_from_path("font.woff2"), "font/woff2");
108
109 assert!(is_immutable("fonts/jetbrains-mono.woff2"));
110 assert!(is_immutable("vendor/leaflet/leaflet.js"));
111 assert!(is_immutable("img/solverforge-logo.svg"));
112 assert!(is_immutable("sf.0.3.0.css"));
113 assert!(is_immutable("sf.0.3.0+build.7.js"));
114 assert!(!is_immutable("sf.css"));
115 }
116
117 #[test]
118 fn mime_detection_still_works_for_versioned_assets() {
119 assert_eq!(mime_from_path("sf.0.3.0.css"), "text/css; charset=utf-8");
120 assert_eq!(
121 mime_from_path("sf.0.3.0+build.7.js"),
122 "application/javascript; charset=utf-8"
123 );
124 }
125
126 #[tokio::test]
127 async fn serves_assets_with_expected_headers() {
128 let app = routes();
129
130 let immutable_resp = app
131 .clone()
132 .oneshot(
133 Request::builder()
134 .method(Method::GET)
135 .uri("/sf/fonts/jetbrains-mono.woff2")
136 .body(Body::empty())
137 .unwrap(),
138 )
139 .await
140 .unwrap();
141
142 assert_eq!(immutable_resp.status(), StatusCode::OK);
143 assert_eq!(
144 immutable_resp.headers().get("cache-control").unwrap(),
145 "public, max-age=31536000, immutable"
146 );
147 assert_eq!(
148 immutable_resp.headers().get("content-type").unwrap(),
149 "font/woff2"
150 );
151 assert!(!to_bytes(immutable_resp.into_body(), 16_000_000)
152 .await
153 .unwrap()
154 .is_empty());
155
156 let mutable_resp = app
157 .clone()
158 .oneshot(
159 Request::builder()
160 .method(Method::GET)
161 .uri("/sf/sf.css")
162 .body(Body::empty())
163 .unwrap(),
164 )
165 .await
166 .unwrap();
167
168 assert_eq!(mutable_resp.status(), StatusCode::OK);
169 assert_eq!(
170 mutable_resp.headers().get("cache-control").unwrap(),
171 "public, max-age=3600"
172 );
173 assert_eq!(
174 mutable_resp.headers().get("content-type").unwrap(),
175 "text/css; charset=utf-8"
176 );
177 assert!(!to_bytes(mutable_resp.into_body(), 16_000_000)
178 .await
179 .unwrap()
180 .is_empty());
181
182 let missing_resp = app
183 .oneshot(
184 Request::builder()
185 .method(Method::GET)
186 .uri("/sf/does-not-exist")
187 .body(Body::empty())
188 .unwrap(),
189 )
190 .await
191 .unwrap();
192
193 assert_eq!(missing_resp.status(), StatusCode::NOT_FOUND);
194 }
195
196 #[tokio::test]
197 async fn serves_top_level_assets_with_short_cache_and_expected_mime() {
198 let response = routes()
199 .oneshot(
200 Request::builder()
201 .uri("/sf/sf.css")
202 .body(Body::empty())
203 .unwrap(),
204 )
205 .await
206 .unwrap();
207
208 assert_eq!(response.status(), StatusCode::OK);
209 assert_eq!(
210 response.headers().get(header::CONTENT_TYPE).unwrap(),
211 "text/css; charset=utf-8"
212 );
213 assert_eq!(
214 response.headers().get(header::CACHE_CONTROL).unwrap(),
215 "public, max-age=3600"
216 );
217
218 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
219 let css = String::from_utf8(body.to_vec()).unwrap();
220 assert!(css.contains("--sf-emerald-50"));
221 assert!(css.contains(".sf-gantt-split"));
222 }
223
224 #[tokio::test]
225 async fn serves_immutable_assets_with_long_cache_and_expected_mime() {
226 let image = routes()
227 .oneshot(
228 Request::builder()
229 .method(Method::GET)
230 .uri("/sf/img/ouroboros.svg")
231 .body(Body::empty())
232 .unwrap(),
233 )
234 .await
235 .unwrap();
236
237 assert_eq!(image.status(), StatusCode::OK);
238 assert_eq!(
239 image.headers().get(header::CONTENT_TYPE).unwrap(),
240 "image/svg+xml"
241 );
242 assert_eq!(
243 image.headers().get(header::CACHE_CONTROL).unwrap(),
244 "public, max-age=31536000, immutable"
245 );
246
247 let vendor = routes()
248 .oneshot(
249 Request::builder()
250 .method(Method::GET)
251 .uri("/sf/vendor/frappe-gantt/frappe-gantt.min.js")
252 .body(Body::empty())
253 .unwrap(),
254 )
255 .await
256 .unwrap();
257
258 assert_eq!(vendor.status(), StatusCode::OK);
259 assert_eq!(
260 vendor.headers().get(header::CONTENT_TYPE).unwrap(),
261 "application/javascript; charset=utf-8"
262 );
263 assert_eq!(
264 vendor.headers().get(header::CACHE_CONTROL).unwrap(),
265 "public, max-age=31536000, immutable"
266 );
267 }
268
269 #[tokio::test]
270 async fn returns_not_found_for_missing_assets() {
271 let response = routes()
272 .oneshot(
273 Request::builder()
274 .method(Method::GET)
275 .uri("/sf/does-not-exist.js")
276 .body(Body::empty())
277 .unwrap(),
278 )
279 .await
280 .unwrap();
281
282 assert_eq!(response.status(), StatusCode::NOT_FOUND);
283 }
284}