Krolyxon
Commit 8f664fb

ferreniumsrcserverassets.rs

200 lines · 6.2 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::Bytes;
12use axum::extract::{Path, State};
13use axum::http::{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 and
131/// 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(custom) = &config.custom.css {
161 css.push_str(custom);
162 }
163 (!css.is_empty()).then_some(css)
164}
165
166/// Short content hash for the `?v=` query of the site stylesheet link.
167pub fn site_css_version(config: &crate::config::Config) -> Option<String> {
168 let body = site_css_body(config)?;
169 let mut hasher = std::hash::DefaultHasher::new();
170 body.hash(&mut hasher);
171 Some(format!("{:08x}", hasher.finish() as u32))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn bundle_contains_all_parts_and_hash_is_stable() {
180 let a = Assets::new();
181 let b = Assets::new();
182 assert_eq!(a.hash(), b.hash());
183 assert_eq!(a.hash().len(), 12);
184 let bundle = a.get(CSS_BUNDLE_PATH).unwrap();
185 let text = std::str::from_utf8(&bundle).unwrap();
186 assert!(text.contains("@font-face"));
187 assert!(text.contains("--text-display"));
188 }
189
190 #[test]
191 fn content_types() {
192 assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8");
193 assert_eq!(
194 content_type("js/theme.js"),
195 "text/javascript; charset=utf-8"
196 );
197 assert_eq!(content_type("fonts/x.woff2"), "font/woff2");
198 assert_eq!(content_type("icons/favicon.svg"), "image/svg+xml");
199 }
200}