| 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::Bytes; |
| 12 | use axum::extract::{Path, State}; |
| 13 | use axum::http::{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 and |
| 131 | /// 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(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. |
| 167 | pub 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)] |
| 175 | mod 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 | } |