forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
10.5 KiB
1//! Serves the compiled stylesheet (build.rs output) at `/assets/main.css`, and the syntax
2//! highlighting classes at `/assets/highlight.css`.
3
4use std::sync::OnceLock;
5
6use topcoat::Result;
7use topcoat::router::{HeaderMap, StatusCode, header, route};
8
9use crate::render::highlight::highlight_css;
10
11const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/main.css"));
12
13/// Highlight classes, built once on first use.
14static HIGHLIGHT_CSS: OnceLock<String> = OnceLock::new();
15
16/// The highlight classes, shared by the route and the page links (which hash them).
17pub fn highlight_css_text() -> &'static str {
18 HIGHLIGHT_CSS.get_or_init(highlight_css).as_str()
19}
20
21/// Immutable: the URL carries a content hash, so a changed file gets a new URL.
22#[route(GET "/assets/main.css")]
23async 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/// The soft-navigation script (`assets/nav.js`). Served from the same origin, so the CSP's
41/// `script-src 'self'` covers it; the page loads it with `defer`, and without it pages still work.
42const NAV_JS: &str = include_str!("../../assets/nav.js");
43
44/// Immutable like `main.css`: the URL carries a content hash.
45#[route(GET "/assets/nav.js")]
46async 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/// Immutable like `main.css`: the URL carries a content hash.
64#[route(GET "/assets/highlight.css")]
65async 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// Bundled fonts (style/fonts/). The version is in each file name, so the immutable cache is
83// safe: a new font version gets a new URL. Licenses sit beside them under /assets/fonts/.
84const FIGTREE_LATIN: &[u8] =
85 include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-wght.woff2");
86const FIGTREE_LATIN_EXT: &[u8] =
87 include_bytes!("../../../../style/fonts/figtree-5.3.0-latin-ext-wght.woff2");
88const PLEX_MONO_400: &[u8] =
89 include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400.woff2");
90const PLEX_MONO_500: &[u8] =
91 include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-500.woff2");
92const PLEX_MONO_600: &[u8] =
93 include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-600.woff2");
94const PLEX_MONO_400_ITALIC: &[u8] =
95 include_bytes!("../../../../style/fonts/ibm-plex-mono-2.5.0-400-italic.woff2");
96const FIGTREE_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/Figtree-OFL.txt");
97const PLEX_MONO_LICENSE: &[u8] = include_bytes!("../../../../style/fonts/IBMPlexMono-OFL.txt");
98
99type Bytes = (StatusCode, HeaderMap, &'static [u8]);
100
101fn 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
114const FONT_CACHE: &str = "public, max-age=31536000, immutable";
115const LICENSE_CACHE: &str = "public, max-age=86400";
116
117#[route(GET "/assets/fonts/figtree-5.3.0-latin-wght.woff2")]
118async 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")]
123async 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")]
128async 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")]
133async 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")]
138async 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")]
143async 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")]
152async 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")]
161async 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)]
170mod 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}