| 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 | |
| 8 | use std::hash::{Hash, Hasher}; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | use axum::body::{Body, Bytes}; |
| 12 | use axum::extract::{Path, State}; |
| 13 | use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; |
| 14 | use axum::response::{IntoResponse, Response}; |
| 15 | use rust_embed::RustEmbed; |
| 16 | |
| 17 | use crate::state::AppState; |
| 18 | |
| 19 | #[derive(RustEmbed)] |
| 20 | #[folder = "assets/"] |
| 21 | struct Embedded; |
| 22 | |
| 23 | /// Parts concatenated (in order) into the virtual `css/app.css`. |
| 24 | const 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 | ]; |
| 32 | const CSS_BUNDLE_PATH: &str = "css/app.css"; |
| 33 | |
| 34 | const IMMUTABLE: &str = "public, max-age=31536000, immutable"; |
| 35 | const NO_CACHE: &str = "no-cache"; |
| 36 | |
| 37 | pub struct Assets { |
| 38 | hash: String, |
| 39 | /// Prebuilt bundle (release builds only; debug rebuilds per request). |
| 40 | bundle: Option<Bytes>, |
| 41 | } |
| 42 | |
| 43 | impl 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 | |
| 78 | impl Default for Assets { |
| 79 | fn default() -> Self { |
| 80 | Self::new() |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | fn 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 | |
| 94 | pub 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}` |
| 106 | pub 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`]. |
| 133 | pub 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 | |
| 151 | pub 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`. |
| 175 | pub 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`. |
| 180 | pub async fn logo(State(state): State<Arc<AppState>>) -> Response { |
| 181 | site_image(state.config.load().custom.logo.as_ref()) |
| 182 | } |
| 183 | |
| 184 | fn 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. |
| 202 | const 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. |
| 209 | pub 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. |
| 275 | fn 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. |
| 284 | pub 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)] |
| 292 | mod 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 | } |