//! `/static` served from the binary rather than from disk. //! //! Sando's companion mechanism ships exactly one file, so keeping the assets in //! a directory beside the binary would mean shipping mt's binary against //! whatever CSS and JS happens to already be on the box. //! //! That is a worse failure than ordinary staleness. `build.rs` content-hashes //! `style.css`, `mt.js` and `static/dist/` into the `?v=` cache-buster it writes //! into the template partials, and those partials are compiled into the binary. //! A binary-only deploy would emit markup asking for `style.css?v=` //! and be served bytes that hash to something else: new templates against old //! styles, no error anywhere, because the cache-buster still does its job. //! //! Embedding removes the question. Binary and assets are one artifact, so they //! cannot disagree, and mt is the single file the companion mechanism already //! knows how to ship. See wiki `sando-mt-pom-pipelines`. //! //! Cache semantics: no `Cache-Control` is set here, so the app-wide `if_not_present` layer in `main.rs` applies `private, //! no-cache`, and a strong `ETag` answers the revalidation that implies with a //! 304 instead of a re-download. use axum::extract::Path; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use include_dir::{Dir, include_dir}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::LazyLock; /// Everything under `static/` at compile time. /// /// `build.rs` owns the freshness half of this: it emits `rerun-if-changed` for /// each hand-maintained asset and for `frontend/src`, whose build emits /// `static/dist/`. Adding an asset that neither covers means adding a watch /// there too, or the embed goes stale without saying so. static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); /// One embedded asset, with the two header values precomputed. struct Asset { bytes: &'static [u8], content_type: &'static str, etag: HeaderValue, } /// Built once, on the first request. Hashing at startup would pay for assets /// nobody asks for; hashing per request would pay again on every hit. static ASSETS: LazyLock> = LazyLock::new(build_index); fn build_index() -> HashMap<&'static str, Asset> { let mut files = Vec::new(); collect(&STATIC_DIR, &mut files); files .into_iter() .map(|f| { let path = f.path().to_str().expect("static asset path is not UTF-8"); let etag = format!("\"{}\"", hex::encode(Sha256::digest(f.contents()))); let asset = Asset { bytes: f.contents(), content_type: content_type_for(path), // Hex of a sha256 plus quotes: always a valid header value. etag: HeaderValue::from_str(&etag).expect("etag is hex"), }; (path, asset) }) .collect() } fn collect<'a>(dir: &'a Dir<'a>, out: &mut Vec<&'a include_dir::File<'a>>) { out.extend(dir.files()); for sub in dir.dirs() { collect(sub, out); } } /// Content type by extension. A `mime_guess` dependency would cover a thousand /// types to serve the five that exist here; the assertion in the tests below is /// what keeps this list honest as assets are added. fn content_type_for(path: &str) -> &'static str { match path.rsplit_once('.').map(|(_, ext)| ext) { Some("css") => "text/css; charset=utf-8", Some("js") => "text/javascript; charset=utf-8", // Source maps are JSON, and only ever fetched by devtools. Some("map") => "application/json; charset=utf-8", Some("woff2") => "font/woff2", Some("ttf") => "font/ttf", // The OFL text shipped beside each self-hosted face. Served so the // licence a visitor is pointed at is readable in a browser rather than // downloaded as an opaque blob. Some("txt") => "text/plain; charset=utf-8", _ => "application/octet-stream", } } /// `GET /static/{*path}`. /// /// Sits outside the CSRF and session layers, as the `ServeDir` it replaced did; /// the security-header and timeout layers in `main.rs` wrap it, as they did. /// /// Nothing here awaits — the bytes are already in memory, which is the point — /// but an axum handler has to return a future, so the `async` stays. #[allow(clippy::unused_async, reason = "required by axum's Handler impl")] pub async fn serve(Path(path): Path, headers: HeaderMap) -> Response { // Lookup is against the compile-time key set, so a `..` in the request path // matches nothing rather than escaping anywhere. let Some(asset) = ASSETS.get(path.as_str()) else { return StatusCode::NOT_FOUND.into_response(); }; if let Some(inm) = headers.get(header::IF_NONE_MATCH) && inm.as_bytes() == asset.etag.as_bytes() { return ( [(header::ETAG, asset.etag.clone())], StatusCode::NOT_MODIFIED, ) .into_response(); } ( [ ( header::CONTENT_TYPE, HeaderValue::from_static(asset.content_type), ), (header::ETAG, asset.etag.clone()), ], asset.bytes, ) .into_response() } #[cfg(test)] mod tests { use super::*; #[test] fn the_assets_the_templates_reference_are_embedded() { // These three are named by build.rs's fingerprint set and by base.html. // If one stops being embedded, every page loses its styling silently. for path in ["style.css", "mt.js", "htmx.min.js"] { assert!( ASSETS.contains_key(path), "{path} is referenced by the templates but not embedded", ); } } #[test] fn nested_directories_are_walked() { assert!( ASSETS.keys().any(|p| p.starts_with("fonts/")), "fonts/ did not survive the recursive walk: {:?}", ASSETS.keys().collect::>(), ); } #[test] fn every_embedded_asset_has_a_known_content_type() { // The fallback is legitimate for something genuinely opaque, but every // asset in the tree today has a real type, and a new one silently // served as octet-stream is a bug (a stylesheet that arrives as // octet-stream is dropped outright under nosniff). let unknown: Vec<_> = ASSETS .iter() .filter(|(_, a)| a.content_type == "application/octet-stream") .map(|(p, _)| *p) .collect(); assert!( unknown.is_empty(), "add these extensions to content_type_for: {unknown:?}", ); } #[tokio::test] async fn a_known_asset_is_served_with_its_type_and_etag() { let res = serve(Path("style.css".into()), HeaderMap::new()).await; assert_eq!(res.status(), StatusCode::OK); assert_eq!( res.headers().get(header::CONTENT_TYPE).unwrap(), "text/css; charset=utf-8", ); assert_eq!( res.headers().get(header::ETAG).unwrap(), &ASSETS["style.css"].etag ); } #[tokio::test] async fn a_nested_asset_is_reachable_by_its_full_path() { let font = ASSETS .keys() .find(|p| p.starts_with("fonts/")) .expect("a font is embedded"); let res = serve(Path((*font).to_string()), HeaderMap::new()).await; assert_eq!(res.status(), StatusCode::OK, "{font} should be served"); } #[tokio::test] async fn a_matching_if_none_match_is_answered_304() { // What keeps `private, no-cache` from meaning "re-download 1.9M of // fonts on every navigation" — the 304 path ServeDir used to give us // via Last-Modified. let mut headers = HeaderMap::new(); headers.insert(header::IF_NONE_MATCH, ASSETS["mt.js"].etag.clone()); let res = serve(Path("mt.js".into()), headers).await; assert_eq!(res.status(), StatusCode::NOT_MODIFIED); } #[tokio::test] async fn a_stale_if_none_match_is_answered_with_the_body() { let mut headers = HeaderMap::new(); headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"stale\"")); let res = serve(Path("mt.js".into()), headers).await; assert_eq!(res.status(), StatusCode::OK); } #[tokio::test] async fn unknown_and_traversing_paths_are_404() { for path in [ "nope.css", "../Cargo.toml", "../../etc/passwd", "fonts/../../Cargo.toml", ] { let res = serve(Path(path.to_string()), HeaderMap::new()).await; assert_eq!( res.status(), StatusCode::NOT_FOUND, "{path} must not resolve to anything", ); } } #[test] fn etags_are_content_derived() { let css = &ASSETS["style.css"]; assert_eq!( css.etag, HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(css.bytes)))) .unwrap(), ); // Two different assets must not share an etag. assert_ne!(css.etag, ASSETS["mt.js"].etag); } }