Krolyxon
Branch master

ferreniumsrcserverassets.rs

340 lines · 10.7 KB · Rust

1//! Embedded static assets served under `/-/static/<hash>/…`.
2//!
3//! One hash covers the whole asset set, so files can reference each other
4//! with relative URLs (`url(../fonts/x.woff2)`) and still be cached forever.
5//! In debug builds rust-embed reads from disk; responses are then `no-cache`
6//! so CSS/JS edits show up on reload.
7
8use std::hash::{Hash, Hasher};
9use std::sync::Arc;
10
11use axum::body::{Body, Bytes};
12use axum::extract::{Path, State};
13use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use rust_embed::RustEmbed;
16
17use crate::state::AppState;
18
19#[derive(RustEmbed)]
20#[folder = "assets/"]
21struct Embedded;
22
23/// Parts concatenated (in order) into the virtual `css/app.css`.
24const CSS_BUNDLE: &[&str] = &[
25 "css/fonts.css",
26 "css/tokens.css",
27 "css/base.css",
28 "css/components.css",
29 "css/repo.css",
30 "css/syntax.css",
31];
32const CSS_BUNDLE_PATH: &str = "css/app.css";
33
34const IMMUTABLE: &str = "public, max-age=31536000, immutable";
35const NO_CACHE: &str = "no-cache";
36
37pub struct Assets {
38 hash: String,
39 /// Prebuilt bundle (release builds only; debug rebuilds per request).
40 bundle: Option<Bytes>,
41}
42
43impl Assets {
44 pub fn new() -> Self {
45 let mut paths: Vec<_> = Embedded::iter().collect();
46 paths.sort();
47 // SipHash with fixed keys: deterministic for a given binary, which is
48 // all a cache-busting token needs.
49 let mut hasher = std::hash::DefaultHasher::new();
50 for path in &paths {
51 path.hash(&mut hasher);
52 if let Some(file) = Embedded::get(path) {
53 file.metadata.sha256_hash().hash(&mut hasher);
54 }
55 }
56 let hash = format!("{:016x}", hasher.finish())[..12].to_string();
57 let bundle = (!cfg!(debug_assertions)).then(build_bundle);
58 Assets { hash, bundle }
59 }
60
61 pub fn hash(&self) -> &str {
62 &self.hash
63 }
64
65 /// Bytes for an asset path, including the virtual CSS bundle.
66 pub fn get(&self, path: &str) -> Option<Bytes> {
67 if path == CSS_BUNDLE_PATH {
68 return Some(self.bundle.clone().unwrap_or_else(build_bundle));
69 }
70 let file = Embedded::get(path)?;
71 Some(match file.data {
72 std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
73 std::borrow::Cow::Owned(v) => Bytes::from(v),
74 })
75 }
76}
77
78impl Default for Assets {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84fn build_bundle() -> Bytes {
85 let mut out = Vec::new();
86 for part in CSS_BUNDLE {
87 let file = Embedded::get(part).unwrap_or_else(|| panic!("missing embedded {part}"));
88 out.extend_from_slice(&file.data);
89 out.push(b'\n');
90 }
91 Bytes::from(out)
92}
93
94pub fn content_type(path: &str) -> HeaderValue {
95 let mime = mime_guess::from_path(path).first_or_octet_stream();
96 let value = match (mime.type_(), mime.subtype()) {
97 (mime_guess::mime::TEXT, _) | (_, mime_guess::mime::JAVASCRIPT) => {
98 format!("{mime}; charset=utf-8")
99 }
100 _ => mime.to_string(),
101 };
102 HeaderValue::from_str(&value).unwrap_or(HeaderValue::from_static("application/octet-stream"))
103}
104
105/// `GET /-/static/{hash}/{*path}`
106pub async fn serve(
107 State(state): State<Arc<AppState>>,
108 Path((hash, path)): Path<(String, String)>,
109) -> Response {
110 let Some(body) = state.assets.get(&path) else {
111 return StatusCode::NOT_FOUND.into_response();
112 };
113 // A stale hash (e.g. a page rendered before an upgrade) still gets the
114 // current file, but must not be cached under the old URL.
115 let cache = if !cfg!(debug_assertions) && hash == state.assets.hash() {
116 IMMUTABLE
117 } else {
118 NO_CACHE
119 };
120 (
121 [
122 (header::CONTENT_TYPE, content_type(&path)),
123 (header::CACHE_CONTROL, HeaderValue::from_static(cache)),
124 ],
125 body,
126 )
127 .into_response()
128}
129
130/// `GET /-/site.css?v=…` — per-site overrides: the configured code font,
131/// accent color and the contents of `site.custom_css`. Only linked when there is something to
132/// serve; versioned by [`site_css_version`].
133pub async fn site_css(State(state): State<Arc<AppState>>) -> Response {
134 let config = state.config.load();
135 let Some(css) = site_css_body(&config) else {
136 return StatusCode::NOT_FOUND.into_response();
137 };
138 (
139 [
140 (
141 header::CONTENT_TYPE,
142 HeaderValue::from_static("text/css; charset=utf-8"),
143 ),
144 (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
145 ],
146 css,
147 )
148 .into_response()
149}
150
151pub fn site_css_body(config: &crate::config::Config) -> Option<String> {
152 let mut css = String::new();
153 if config.ui.code_font != crate::config::DEFAULT_CODE_FONT {
154 // The font name was validated to contain no quotes or CSS syntax.
155 css.push_str(&format!(
156 ":root {{ --font-code: \"{}\", var(--font-mono); }}\n",
157 config.ui.code_font
158 ));
159 }
160 if let Some(accent) = &config.ui.accent {
161 // Validated as a hex color in `Config::finish`.
162 if let Some([r, g, b]) = crate::config::parse_hex_color(accent) {
163 css.push_str(&format!(
164 ":root {{ --accent: {accent}; --accent-subtle: rgba({r}, {g}, {b}, 0.15); }}\n"
165 ));
166 }
167 }
168 if let Some(custom) = &config.custom.css {
169 css.push_str(custom);
170 }
171 (!css.is_empty()).then_some(css)
172}
173
174/// `GET /-/favicon?v=…` — `site.favicon`.
175pub async fn favicon(State(state): State<Arc<AppState>>) -> Response {
176 site_image(state.config.load().custom.favicon.as_ref())
177}
178
179/// `GET /-/logo?v=…` — `site.logo`.
180pub async fn logo(State(state): State<Arc<AppState>>) -> Response {
181 site_image(state.config.load().custom.logo.as_ref())
182}
183
184fn site_image(image: Option<&crate::config::SiteImage>) -> Response {
185 let Some(image) = image else {
186 return StatusCode::NOT_FOUND.into_response();
187 };
188 (
189 [
190 (
191 header::CONTENT_TYPE,
192 HeaderValue::from_static(image.content_type),
193 ),
194 (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
195 ],
196 image.data.clone(),
197 )
198 .into_response()
199}
200
201/// File types served from `site.assets_dir`: what a stylesheet can load.
202const SITE_ASSET_TYPES: &[&str] = &[
203 "css", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "woff2", "woff", "ttf", "otf",
204];
205
206/// `GET /-/site/{*path}` — a file from `site.assets_dir`, streamed from disk.
207/// Hidden files, other types and paths (or symlinks) leading outside the
208/// directory are 404.
209pub async fn site_asset(
210 State(state): State<Arc<AppState>>,
211 Path(path): Path<String>,
212 headers: HeaderMap,
213) -> Response {
214 let config = state.config.load();
215 let Some(dir) = config.site.assets_dir.clone() else {
216 return StatusCode::NOT_FOUND.into_response();
217 };
218 let Some(rel) = site_asset_path(&path).map(str::to_owned) else {
219 return StatusCode::NOT_FOUND.into_response();
220 };
221 let opened = tokio::task::spawn_blocking(move || {
222 let full = dir.join(rel).canonicalize().ok()?;
223 if !full.starts_with(&dir) {
224 return None;
225 }
226 let file = std::fs::File::open(&full).ok()?;
227 let meta = file.metadata().ok()?;
228 meta.is_file().then_some((file, meta))
229 })
230 .await
231 .ok()
232 .flatten();
233 let Some((file, meta)) = opened else {
234 return StatusCode::NOT_FOUND.into_response();
235 };
236
237 let mut hasher = std::hash::DefaultHasher::new();
238 meta.len().hash(&mut hasher);
239 meta.modified().ok().hash(&mut hasher);
240 let etag = format!("W/\"{:016x}\"", hasher.finish());
241 let etag = HeaderValue::from_str(&etag).expect("hex ETag is a valid header");
242 let cache = [
243 (header::ETAG, etag.clone()),
244 (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
245 ];
246 let bare = |t: &[u8]| {
247 t.trim_ascii()
248 .strip_prefix(b"W/")
249 .unwrap_or(t.trim_ascii())
250 .to_vec()
251 };
252 if headers.get(header::IF_NONE_MATCH).is_some_and(|v| {
253 v.as_bytes()
254 .split(|&b| b == b',')
255 .any(|t| bare(t) == bare(etag.as_bytes()))
256 }) {
257 return (StatusCode::NOT_MODIFIED, cache).into_response();
258 }
259 let body = Body::from_stream(tokio_util::io::ReaderStream::new(
260 tokio::fs::File::from_std(file),
261 ));
262 (
263 cache,
264 [
265 (header::CONTENT_TYPE, content_type(&path)),
266 (header::CONTENT_LENGTH, HeaderValue::from(meta.len())),
267 ],
268 body,
269 )
270 .into_response()
271}
272
273/// Validate a request path for [`site_asset`]: plain segments only, no
274/// hidden files, and an allowed file type.
275fn site_asset_path(path: &str) -> Option<&str> {
276 let clean = path
277 .split('/')
278 .all(|seg| !seg.is_empty() && !seg.starts_with('.') && !seg.contains(['\\', '\0', ':']));
279 let ext = path.rsplit_once('.')?.1.to_ascii_lowercase();
280 (clean && SITE_ASSET_TYPES.contains(&ext.as_str())).then_some(path)
281}
282
283/// Short content hash for the `?v=` query of the site stylesheet link.
284pub fn site_css_version(config: &crate::config::Config) -> Option<String> {
285 let body = site_css_body(config)?;
286 let mut hasher = std::hash::DefaultHasher::new();
287 body.hash(&mut hasher);
288 Some(format!("{:08x}", hasher.finish() as u32))
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn bundle_contains_all_parts_and_hash_is_stable() {
297 let a = Assets::new();
298 let b = Assets::new();
299 assert_eq!(a.hash(), b.hash());
300 assert_eq!(a.hash().len(), 12);
301 let bundle = a.get(CSS_BUNDLE_PATH).unwrap();
302 let text = std::str::from_utf8(&bundle).unwrap();
303 assert!(text.contains("@font-face"));
304 assert!(text.contains("--text-display"));
305 }
306
307 #[test]
308 fn site_asset_paths() {
309 for ok in ["bg.jpg", "img/Bg.PNG", "fonts/x.woff2", "theme.css"] {
310 assert_eq!(site_asset_path(ok), Some(ok), "{ok}");
311 }
312 for bad in [
313 "",
314 "../x.png",
315 "a/../x.png",
316 "./x.png",
317 ".x.png",
318 "a//x.png",
319 "/x.png",
320 "x.html",
321 "x.svgz",
322 "x",
323 "a\\x.png",
324 "c:x.png",
325 ] {
326 assert_eq!(site_asset_path(bad), None, "{bad}");
327 }
328 }
329
330 #[test]
331 fn content_types() {
332 assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8");
333 assert_eq!(
334 content_type("js/theme.js"),
335 "text/javascript; charset=utf-8"
336 );
337 assert_eq!(content_type("fonts/x.woff2"), "font/woff2");
338 assert_eq!(content_type("icons/favicon.svg"), "image/svg+xml");
339 }
340}