| 1 | |
| 2 | |
| 3 | |
| 4 | use std::sync::OnceLock; |
| 5 | |
| 6 | use topcoat::Result; |
| 7 | use topcoat::router::{HeaderMap, StatusCode, header, route}; |
| 8 | |
| 9 | use crate::render::highlight::highlight_css; |
| 10 | |
| 11 | const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/main.css")); |
| 12 | |
| 13 | |
| 14 | static HIGHLIGHT_CSS: OnceLock<String> = OnceLock::new(); |
| 15 | |
| 16 | |
| 17 | pub fn highlight_css_text() -> &'static str { |
| 18 | HIGHLIGHT_CSS.get_or_init(highlight_css).as_str() |
| 19 | } |
| 20 | |
| 21 | |
| 22 | #[route(GET "/assets/main.css")] |
| 23 | async fn main_css() -> Result<(StatusCode, HeaderMap, &'static str)> { |
| 24 | let mut headers = HeaderMap::new(); |
| 25 | headers.insert( |
| 26 | header::CONTENT_TYPE, |
| 27 | "text/css; charset=utf-8" |
| 28 | .parse() |
| 29 | .expect("static header value"), |
| 30 | ); |
| 31 | headers.insert( |
| 32 | header::CACHE_CONTROL, |
| 33 | "public, max-age=31536000, immutable" |
| 34 | .parse() |
| 35 | .expect("static header value"), |
| 36 | ); |
| 37 | Ok((StatusCode::OK, headers, CSS)) |
| 38 | } |
| 39 | |
| 40 | |
| 41 | |
| 42 | const NAV_JS: &str = include_str!("../../assets/nav.js"); |
| 43 | |
| 44 | |
| 45 | #[route(GET "/assets/nav.js")] |
| 46 | async fn nav_js() -> Result<(StatusCode, HeaderMap, &'static str)> { |
| 47 | let mut headers = HeaderMap::new(); |
| 48 | headers.insert( |
| 49 | header::CONTENT_TYPE, |
| 50 | "text/javascript; charset=utf-8" |
| 51 | .parse() |
| 52 | .expect("static header value"), |
| 53 | ); |
| 54 | headers.insert( |
| 55 | header::CACHE_CONTROL, |
| 56 | "public, max-age=31536000, immutable" |
| 57 | .parse() |
| 58 | .expect("static header value"), |
| 59 | ); |
| 60 | Ok((StatusCode::OK, headers, NAV_JS)) |
| 61 | } |
| 62 | |
| 63 | |
| 64 | #[route(GET "/assets/highlight.css")] |
| 65 | async fn highlight_css_route() -> Result<(StatusCode, HeaderMap, &'static str)> { |
| 66 | let mut headers = HeaderMap::new(); |
| 67 | headers.insert( |
| 68 | header::CONTENT_TYPE, |
| 69 | "text/css; charset=utf-8" |
| 70 | .parse() |
| 71 | .expect("static header value"), |
| 72 | ); |
| 73 | headers.insert( |
| 74 | header::CACHE_CONTROL, |
| 75 | "public, max-age=31536000, immutable" |
| 76 | .parse() |
| 77 | .expect("static header value"), |
| 78 | ); |
| 79 | Ok((StatusCode::OK, headers, highlight_css_text())) |
| 80 | } |
| 81 | |
| 82 | |
| 83 | |
| 84 | const FIGTREE_LATIN: &[u8] = |
| 85 | include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-wght.woff2"); |
| 86 | const FIGTREE_LATIN_EXT: &[u8] = |
| 87 | include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-ext-wght.woff2"); |
| 88 | const PLEX_MONO_400: &[u8] = |
| 89 | include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400.woff2"); |
| 90 | const PLEX_MONO_500: &[u8] = |
| 91 | include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-500.woff2"); |
| 92 | const PLEX_MONO_600: &[u8] = |
| 93 | include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-600.woff2"); |
| 94 | const PLEX_MONO_400_ITALIC: &[u8] = |
| 95 | include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400-italic.woff2"); |
| 96 | const FIGTREE_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/Figtree-OFL.txt"); |
| 97 | const PLEX_MONO_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/IBMPlexMono-OFL.txt"); |
| 98 | |
| 99 | type Bytes = (StatusCode, HeaderMap, &'static [u8]); |
| 100 | |
| 101 | fn bytes_response(body: &'static [u8], content_type: &'static str, cache: &'static str) -> Bytes { |
| 102 | let mut headers = HeaderMap::new(); |
| 103 | headers.insert( |
| 104 | header::CONTENT_TYPE, |
| 105 | content_type.parse().expect("static header value"), |
| 106 | ); |
| 107 | headers.insert( |
| 108 | header::CACHE_CONTROL, |
| 109 | cache.parse().expect("static header value"), |
| 110 | ); |
| 111 | (StatusCode::OK, headers, body) |
| 112 | } |
| 113 | |
| 114 | const FONT_CACHE: &str = "public, max-age=31536000, immutable"; |
| 115 | const LICENSE_CACHE: &str = "public, max-age=86400"; |
| 116 | |
| 117 | #[route(GET "/assets/fonts/figtree-5.3.0-latin-wght.woff2")] |
| 118 | async fn figtree_latin() -> Result<Bytes> { |
| 119 | Ok(bytes_response(FIGTREE_LATIN, "font/woff2", FONT_CACHE)) |
| 120 | } |
| 121 | |
| 122 | #[route(GET "/assets/fonts/figtree-5.3.0-latin-ext-wght.woff2")] |
| 123 | async fn figtree_latin_ext() -> Result<Bytes> { |
| 124 | Ok(bytes_response(FIGTREE_LATIN_EXT, "font/woff2", FONT_CACHE)) |
| 125 | } |
| 126 | |
| 127 | #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-400.woff2")] |
| 128 | async fn plex_mono_400() -> Result<Bytes> { |
| 129 | Ok(bytes_response(PLEX_MONO_400, "font/woff2", FONT_CACHE)) |
| 130 | } |
| 131 | |
| 132 | #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-500.woff2")] |
| 133 | async fn plex_mono_500() -> Result<Bytes> { |
| 134 | Ok(bytes_response(PLEX_MONO_500, "font/woff2", FONT_CACHE)) |
| 135 | } |
| 136 | |
| 137 | #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-600.woff2")] |
| 138 | async fn plex_mono_600() -> Result<Bytes> { |
| 139 | Ok(bytes_response(PLEX_MONO_600, "font/woff2", FONT_CACHE)) |
| 140 | } |
| 141 | |
| 142 | #[route(GET "/assets/fonts/ibm-plex-mono-2.5.0-400-italic.woff2")] |
| 143 | async fn plex_mono_400_italic() -> Result<Bytes> { |
| 144 | Ok(bytes_response( |
| 145 | PLEX_MONO_400_ITALIC, |
| 146 | "font/woff2", |
| 147 | FONT_CACHE, |
| 148 | )) |
| 149 | } |
| 150 | |
| 151 | #[route(GET "/assets/fonts/Figtree-OFL.txt")] |
| 152 | async fn figtree_license() -> Result<Bytes> { |
| 153 | Ok(bytes_response( |
| 154 | FIGTREE_LICENSE, |
| 155 | "text/plain; charset=utf-8", |
| 156 | LICENSE_CACHE, |
| 157 | )) |
| 158 | } |
| 159 | |
| 160 | #[route(GET "/assets/fonts/IBMPlexMono-OFL.txt")] |
| 161 | async fn plex_mono_license() -> Result<Bytes> { |
| 162 | Ok(bytes_response( |
| 163 | PLEX_MONO_LICENSE, |
| 164 | "text/plain; charset=utf-8", |
| 165 | LICENSE_CACHE, |
| 166 | )) |
| 167 | } |
| 168 | |
| 169 | #[cfg(test)] |
| 170 | mod tests { |
| 171 | use topcoat::cookie::RouterBuilderCookieExt; |
| 172 | use topcoat::router::request::Request; |
| 173 | use topcoat::router::{Body, Router, RouterBuilderDiscoverExt, to_bytes}; |
| 174 | |
| 175 | #[tokio::test] |
| 176 | async fn bundled_fonts_are_served_as_immutable_woff2() { |
| 177 | let router: Router = Router::builder().discover().cookies().build(); |
| 178 | for uri in [ |
| 179 | "/assets/fonts/figtree-5.3.0-latin-wght.woff2", |
| 180 | "/assets/fonts/figtree-5.3.0-latin-ext-wght.woff2", |
| 181 | "/assets/fonts/ibm-plex-mono-2.5.0-400.woff2", |
| 182 | "/assets/fonts/ibm-plex-mono-2.5.0-500.woff2", |
| 183 | "/assets/fonts/ibm-plex-mono-2.5.0-600.woff2", |
| 184 | "/assets/fonts/ibm-plex-mono-2.5.0-400-italic.woff2", |
| 185 | ] { |
| 186 | let req = Request::builder() |
| 187 | .method("GET") |
| 188 | .uri(uri) |
| 189 | .body(Body::empty()) |
| 190 | .unwrap(); |
| 191 | let res = router.handle(req).await; |
| 192 | assert_eq!(res.status(), 200, "{uri}"); |
| 193 | assert_eq!(res.headers()["content-type"], "font/woff2", "{uri}"); |
| 194 | assert_eq!( |
| 195 | res.headers()["cache-control"], |
| 196 | "public, max-age=31536000, immutable", |
| 197 | "{uri}" |
| 198 | ); |
| 199 | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 200 | assert!(body.starts_with(b"wOF2"), "{uri}"); |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | #[tokio::test] |
| 205 | async fn font_licenses_are_served_as_text() { |
| 206 | let router: Router = Router::builder().discover().cookies().build(); |
| 207 | for uri in [ |
| 208 | "/assets/fonts/Figtree-OFL.txt", |
| 209 | "/assets/fonts/IBMPlexMono-OFL.txt", |
| 210 | ] { |
| 211 | let req = Request::builder() |
| 212 | .method("GET") |
| 213 | .uri(uri) |
| 214 | .body(Body::empty()) |
| 215 | .unwrap(); |
| 216 | let res = router.handle(req).await; |
| 217 | assert_eq!(res.status(), 200, "{uri}"); |
| 218 | assert_eq!(res.headers()["content-type"], "text/plain; charset=utf-8"); |
| 219 | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 220 | assert!( |
| 221 | String::from_utf8_lossy(&body).contains("SIL Open Font License"), |
| 222 | "{uri}" |
| 223 | ); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | #[tokio::test] |
| 228 | async fn stylesheet_is_served_with_headers_from_the_security_layer() { |
| 229 | let router: Router = Router::builder().discover().cookies().build(); |
| 230 | let req = Request::builder() |
| 231 | .method("GET") |
| 232 | .uri("/assets/main.css") |
| 233 | .body(Body::empty()) |
| 234 | .unwrap(); |
| 235 | let res = router.handle(req).await; |
| 236 | assert_eq!(res.status(), 200); |
| 237 | assert_eq!(res.headers()["content-type"], "text/css; charset=utf-8"); |
| 238 | assert!( |
| 239 | res.headers()["cache-control"] |
| 240 | .to_str() |
| 241 | .unwrap() |
| 242 | .contains("immutable") |
| 243 | ); |
| 244 | assert_eq!(res.headers()["x-frame-options"], "DENY"); |
| 245 | assert_eq!(res.headers()["x-content-type-options"], "nosniff"); |
| 246 | assert_eq!(res.headers()["referrer-policy"], "same-origin"); |
| 247 | let csp = res.headers()["content-security-policy"].to_str().unwrap(); |
| 248 | assert!(csp.contains("default-src 'self'") && csp.contains("frame-ancestors 'none'")); |
| 249 | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 250 | assert!(!body.is_empty()); |
| 251 | } |
| 252 | |
| 253 | #[tokio::test] |
| 254 | async fn nav_script_is_served_as_immutable_javascript() { |
| 255 | let router: Router = Router::builder().discover().cookies().build(); |
| 256 | let req = Request::builder() |
| 257 | .method("GET") |
| 258 | .uri(crate::views::NAV_SCRIPT_HREF) |
| 259 | .body(Body::empty()) |
| 260 | .unwrap(); |
| 261 | let res = router.handle(req).await; |
| 262 | assert_eq!(res.status(), 200); |
| 263 | assert_eq!(res.headers()["content-type"], "text/javascript; charset=utf-8"); |
| 264 | assert!( |
| 265 | res.headers()["cache-control"] |
| 266 | .to_str() |
| 267 | .unwrap() |
| 268 | .contains("immutable") |
| 269 | ); |
| 270 | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 271 | assert!(String::from_utf8_lossy(&body).contains("__forgeSoft")); |
| 272 | } |
| 273 | |
| 274 | #[tokio::test] |
| 275 | async fn highlight_css_is_served_with_long_cache() { |
| 276 | let router: Router = Router::builder().discover().cookies().build(); |
| 277 | let req = Request::builder() |
| 278 | .method("GET") |
| 279 | .uri(crate::views::highlight_href()) |
| 280 | .body(Body::empty()) |
| 281 | .unwrap(); |
| 282 | let res = router.handle(req).await; |
| 283 | assert_eq!(res.status(), 200); |
| 284 | assert_eq!(res.headers()["content-type"], "text/css; charset=utf-8"); |
| 285 | assert!( |
| 286 | res.headers()["cache-control"] |
| 287 | .to_str() |
| 288 | .unwrap() |
| 289 | .contains("immutable") |
| 290 | ); |
| 291 | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 292 | assert!(!body.is_empty()); |
| 293 | } |
| 294 | } |