//! Serves the compiled stylesheet (build.rs output) at `/assets/main.css`, and the syntax //! highlighting classes at `/assets/highlight.css`. use std::sync::OnceLock; use topcoat::Result; use topcoat::router::{HeaderMap, StatusCode, header, route}; use crate::render::highlight::highlight_css; const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/main.css")); /// Highlight classes, built once on first use. static HIGHLIGHT_CSS: OnceLock = OnceLock::new(); /// The highlight classes, shared by the route and the page links (which hash them). pub fn highlight_css_text() -> &'static str { HIGHLIGHT_CSS.get_or_init(highlight_css).as_str() } /// Immutable: the URL carries a content hash, so a changed file gets a new URL. #[route(GET "/assets/main.css")] async fn main_css() -> Result<(StatusCode, HeaderMap, &'static str)> { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, "text/css; charset=utf-8" .parse() .expect("static header value"), ); headers.insert( header::CACHE_CONTROL, "public, max-age=31536000, immutable" .parse() .expect("static header value"), ); Ok((StatusCode::OK, headers, CSS)) } /// The soft-navigation script (`assets/nav.js`). Served from the same origin, so the CSP's /// `script-src 'self'` covers it; the page loads it with `defer`, and without it pages still work. const NAV_JS: &str = include_str!("../../assets/nav.js"); /// Immutable like `main.css`: the URL carries a content hash. #[route(GET "/assets/nav.js")] async fn nav_js() -> Result<(StatusCode, HeaderMap, &'static str)> { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, "text/javascript; charset=utf-8" .parse() .expect("static header value"), ); headers.insert( header::CACHE_CONTROL, "public, max-age=31536000, immutable" .parse() .expect("static header value"), ); Ok((StatusCode::OK, headers, NAV_JS)) } /// Immutable like `main.css`: the URL carries a content hash. #[route(GET "/assets/highlight.css")] async fn highlight_css_route() -> Result<(StatusCode, HeaderMap, &'static str)> { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, "text/css; charset=utf-8" .parse() .expect("static header value"), ); headers.insert( header::CACHE_CONTROL, "public, max-age=31536000, immutable" .parse() .expect("static header value"), ); Ok((StatusCode::OK, headers, highlight_css_text())) } // Bundled fonts (style/fonts/). The version is in each file name, so the immutable cache is // safe: a new font version gets a new URL. Licenses sit beside them under /assets/fonts/. const FIGTREE_LATIN: &[u8] = include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-wght.woff2"); const FIGTREE_LATIN_EXT: &[u8] = include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-ext-wght.woff2"); const PLEX_MONO_400: &[u8] = include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400.woff2"); const PLEX_MONO_500: &[u8] = include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-500.woff2"); const PLEX_MONO_600: &[u8] = include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-600.woff2"); const PLEX_MONO_400_ITALIC: &[u8] = include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400-italic.woff2"); const FIGTREE_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/Figtree-OFL.txt"); const PLEX_MONO_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/IBMPlexMono-OFL.txt"); type Bytes = (StatusCode, HeaderMap, &'static [u8]); fn bytes_response(body: &'static [u8], content_type: &'static str, cache: &'static str) -> Bytes { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, content_type.parse().expect("static header value"), ); headers.insert( header::CACHE_CONTROL, cache.parse().expect("static header value"), ); (StatusCode::OK, headers, body) } const FONT_CACHE: &str = "public, max-age=31536000, immutable"; const LICENSE_CACHE: &str = "public, max-age=86400"; #[route(GET "/assets/fonts/figtree-5.3.0-latin-wght.woff2")] async fn figtree_latin() -> Result { Ok(bytes_response(FIGTREE_LATIN, "font/woff2", FONT_CACHE)) } #[route(GET "/assets/fonts/figtree-5.3.0-latin-ext-wght.woff2")] async fn figtree_latin_ext() -> Result { Ok(bytes_response(FIGTREE_LATIN_EXT, "font/woff2", FONT_CACHE)) } #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-400.woff2")] async fn plex_mono_400() -> Result { Ok(bytes_response(PLEX_MONO_400, "font/woff2", FONT_CACHE)) } #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-500.woff2")] async fn plex_mono_500() -> Result { Ok(bytes_response(PLEX_MONO_500, "font/woff2", FONT_CACHE)) } #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-600.woff2")] async fn plex_mono_600() -> Result { Ok(bytes_response(PLEX_MONO_600, "font/woff2", FONT_CACHE)) } #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-400-italic.woff2")] async fn plex_mono_400_italic() -> Result { Ok(bytes_response( PLEX_MONO_400_ITALIC, "font/woff2", FONT_CACHE, )) } #[route(GET "/assets/fonts/Figtree-OFL.txt")] async fn figtree_license() -> Result { Ok(bytes_response( FIGTREE_LICENSE, "text/plain; charset=utf-8", LICENSE_CACHE, )) } #[route(GET "/assets/fonts/IBMPlexMono-OFL.txt")] async fn plex_mono_license() -> Result { Ok(bytes_response( PLEX_MONO_LICENSE, "text/plain; charset=utf-8", LICENSE_CACHE, )) } #[cfg(test)] mod tests { use topcoat::cookie::RouterBuilderCookieExt; use topcoat::router::request::Request; use topcoat::router::{Body, Router, RouterBuilderDiscoverExt, to_bytes}; #[tokio::test] async fn bundled_fonts_are_served_as_immutable_woff2() { let router: Router = Router::builder().discover().cookies().build(); for uri in [ "/assets/fonts/figtree-5.3.0-latin-wght.woff2", "/assets/fonts/figtree-5.3.0-latin-ext-wght.woff2", "/assets/fonts/ibm-plex-mono-2.5.0-400.woff2", "/assets/fonts/ibm-plex-mono-2.5.0-500.woff2", "/assets/fonts/ibm-plex-mono-2.5.0-600.woff2", "/assets/fonts/ibm-plex-mono-2.5.0-400-italic.woff2", ] { let req = Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200, "{uri}"); assert_eq!(res.headers()["content-type"], "font/woff2", "{uri}"); assert_eq!( res.headers()["cache-control"], "public, max-age=31536000, immutable", "{uri}" ); let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!(body.starts_with(b"wOF2"), "{uri}"); } } #[tokio::test] async fn font_licenses_are_served_as_text() { let router: Router = Router::builder().discover().cookies().build(); for uri in [ "/assets/fonts/Figtree-OFL.txt", "/assets/fonts/IBMPlexMono-OFL.txt", ] { let req = Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200, "{uri}"); assert_eq!(res.headers()["content-type"], "text/plain; charset=utf-8"); let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!( String::from_utf8_lossy(&body).contains("SIL Open Font License"), "{uri}" ); } } #[tokio::test] async fn stylesheet_is_served_with_headers_from_the_security_layer() { let router: Router = Router::builder().discover().cookies().build(); let req = Request::builder() .method("GET") .uri("/assets/main.css") .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200); assert_eq!(res.headers()["content-type"], "text/css; charset=utf-8"); assert!( res.headers()["cache-control"] .to_str() .unwrap() .contains("immutable") ); assert_eq!(res.headers()["x-frame-options"], "DENY"); assert_eq!(res.headers()["x-content-type-options"], "nosniff"); assert_eq!(res.headers()["referrer-policy"], "same-origin"); let csp = res.headers()["content-security-policy"].to_str().unwrap(); assert!(csp.contains("default-src 'self'") && csp.contains("frame-ancestors 'none'")); let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!(!body.is_empty()); } #[tokio::test] async fn nav_script_is_served_as_immutable_javascript() { let router: Router = Router::builder().discover().cookies().build(); let req = Request::builder() .method("GET") .uri(crate::views::NAV_SCRIPT_HREF) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200); assert_eq!(res.headers()["content-type"], "text/javascript; charset=utf-8"); assert!( res.headers()["cache-control"] .to_str() .unwrap() .contains("immutable") ); let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!(String::from_utf8_lossy(&body).contains("__forgeSoft")); } #[tokio::test] async fn highlight_css_is_served_with_long_cache() { let router: Router = Router::builder().discover().cookies().build(); let req = Request::builder() .method("GET") .uri(crate::views::highlight_href()) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; assert_eq!(res.status(), 200); assert_eq!(res.headers()["content-type"], "text/css; charset=utf-8"); assert!( res.headers()["cache-control"] .to_str() .unwrap() .contains("immutable") ); let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!(!body.is_empty()); } }