Skip to main content

max / makenotwork

9.1 KB · 246 lines History Blame Raw
1 //! `/static` served from the binary rather than from disk.
2 //!
3 //! Sando's companion mechanism ships exactly one file, so keeping the assets in
4 //! a directory beside the binary would mean shipping mt's binary against
5 //! whatever CSS and JS happens to already be on the box.
6 //!
7 //! That is a worse failure than ordinary staleness. `build.rs` content-hashes
8 //! `style.css`, `mt.js` and `static/dist/` into the `?v=` cache-buster it writes
9 //! into the template partials, and those partials are compiled into the binary.
10 //! A binary-only deploy would emit markup asking for `style.css?v=<new hash>`
11 //! and be served bytes that hash to something else: new templates against old
12 //! styles, no error anywhere, because the cache-buster still does its job.
13 //!
14 //! Embedding removes the question. Binary and assets are one artifact, so they
15 //! cannot disagree, and mt is the single file the companion mechanism already
16 //! knows how to ship. See wiki `sando-mt-pom-pipelines`.
17 //!
18 //! Cache semantics: no `Cache-Control` is set here, so the app-wide `if_not_present` layer in `main.rs` applies `private,
19 //! no-cache`, and a strong `ETag` answers the revalidation that implies with a
20 //! 304 instead of a re-download.
21
22 use axum::extract::Path;
23 use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
24 use axum::response::{IntoResponse, Response};
25 use include_dir::{Dir, include_dir};
26 use sha2::{Digest, Sha256};
27 use std::collections::HashMap;
28 use std::sync::LazyLock;
29
30 /// Everything under `static/` at compile time.
31 ///
32 /// `build.rs` owns the freshness half of this: it emits `rerun-if-changed` for
33 /// each hand-maintained asset and for `frontend/src`, whose build emits
34 /// `static/dist/`. Adding an asset that neither covers means adding a watch
35 /// there too, or the embed goes stale without saying so.
36 static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static");
37
38 /// One embedded asset, with the two header values precomputed.
39 struct Asset {
40 bytes: &'static [u8],
41 content_type: &'static str,
42 etag: HeaderValue,
43 }
44
45 /// Built once, on the first request. Hashing at startup would pay for assets
46 /// nobody asks for; hashing per request would pay again on every hit.
47 static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(build_index);
48
49 fn build_index() -> HashMap<&'static str, Asset> {
50 let mut files = Vec::new();
51 collect(&STATIC_DIR, &mut files);
52 files
53 .into_iter()
54 .map(|f| {
55 let path = f.path().to_str().expect("static asset path is not UTF-8");
56 let etag = format!("\"{}\"", hex::encode(Sha256::digest(f.contents())));
57 let asset = Asset {
58 bytes: f.contents(),
59 content_type: content_type_for(path),
60 // Hex of a sha256 plus quotes: always a valid header value.
61 etag: HeaderValue::from_str(&etag).expect("etag is hex"),
62 };
63 (path, asset)
64 })
65 .collect()
66 }
67
68 fn collect<'a>(dir: &'a Dir<'a>, out: &mut Vec<&'a include_dir::File<'a>>) {
69 out.extend(dir.files());
70 for sub in dir.dirs() {
71 collect(sub, out);
72 }
73 }
74
75 /// Content type by extension. A `mime_guess` dependency would cover a thousand
76 /// types to serve the five that exist here; the assertion in the tests below is
77 /// what keeps this list honest as assets are added.
78 fn content_type_for(path: &str) -> &'static str {
79 match path.rsplit_once('.').map(|(_, ext)| ext) {
80 Some("css") => "text/css; charset=utf-8",
81 Some("js") => "text/javascript; charset=utf-8",
82 // Source maps are JSON, and only ever fetched by devtools.
83 Some("map") => "application/json; charset=utf-8",
84 Some("woff2") => "font/woff2",
85 Some("ttf") => "font/ttf",
86 // The OFL text shipped beside each self-hosted face. Served so the
87 // licence a visitor is pointed at is readable in a browser rather than
88 // downloaded as an opaque blob.
89 Some("txt") => "text/plain; charset=utf-8",
90 _ => "application/octet-stream",
91 }
92 }
93
94 /// `GET /static/{*path}`.
95 ///
96 /// Sits outside the CSRF and session layers, as the `ServeDir` it replaced did;
97 /// the security-header and timeout layers in `main.rs` wrap it, as they did.
98 ///
99 /// Nothing here awaits — the bytes are already in memory, which is the point —
100 /// but an axum handler has to return a future, so the `async` stays.
101 #[allow(clippy::unused_async, reason = "required by axum's Handler impl")]
102 pub async fn serve(Path(path): Path<String>, headers: HeaderMap) -> Response {
103 // Lookup is against the compile-time key set, so a `..` in the request path
104 // matches nothing rather than escaping anywhere.
105 let Some(asset) = ASSETS.get(path.as_str()) else {
106 return StatusCode::NOT_FOUND.into_response();
107 };
108
109 if let Some(inm) = headers.get(header::IF_NONE_MATCH)
110 && inm.as_bytes() == asset.etag.as_bytes()
111 {
112 return (
113 [(header::ETAG, asset.etag.clone())],
114 StatusCode::NOT_MODIFIED,
115 )
116 .into_response();
117 }
118
119 (
120 [
121 (
122 header::CONTENT_TYPE,
123 HeaderValue::from_static(asset.content_type),
124 ),
125 (header::ETAG, asset.etag.clone()),
126 ],
127 asset.bytes,
128 )
129 .into_response()
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 #[test]
137 fn the_assets_the_templates_reference_are_embedded() {
138 // These three are named by build.rs's fingerprint set and by base.html.
139 // If one stops being embedded, every page loses its styling silently.
140 for path in ["style.css", "mt.js", "htmx.min.js"] {
141 assert!(
142 ASSETS.contains_key(path),
143 "{path} is referenced by the templates but not embedded",
144 );
145 }
146 }
147
148 #[test]
149 fn nested_directories_are_walked() {
150 assert!(
151 ASSETS.keys().any(|p| p.starts_with("fonts/")),
152 "fonts/ did not survive the recursive walk: {:?}",
153 ASSETS.keys().collect::<Vec<_>>(),
154 );
155 }
156
157 #[test]
158 fn every_embedded_asset_has_a_known_content_type() {
159 // The fallback is legitimate for something genuinely opaque, but every
160 // asset in the tree today has a real type, and a new one silently
161 // served as octet-stream is a bug (a stylesheet that arrives as
162 // octet-stream is dropped outright under nosniff).
163 let unknown: Vec<_> = ASSETS
164 .iter()
165 .filter(|(_, a)| a.content_type == "application/octet-stream")
166 .map(|(p, _)| *p)
167 .collect();
168 assert!(
169 unknown.is_empty(),
170 "add these extensions to content_type_for: {unknown:?}",
171 );
172 }
173
174 #[tokio::test]
175 async fn a_known_asset_is_served_with_its_type_and_etag() {
176 let res = serve(Path("style.css".into()), HeaderMap::new()).await;
177 assert_eq!(res.status(), StatusCode::OK);
178 assert_eq!(
179 res.headers().get(header::CONTENT_TYPE).unwrap(),
180 "text/css; charset=utf-8",
181 );
182 assert_eq!(
183 res.headers().get(header::ETAG).unwrap(),
184 &ASSETS["style.css"].etag
185 );
186 }
187
188 #[tokio::test]
189 async fn a_nested_asset_is_reachable_by_its_full_path() {
190 let font = ASSETS
191 .keys()
192 .find(|p| p.starts_with("fonts/"))
193 .expect("a font is embedded");
194 let res = serve(Path((*font).to_string()), HeaderMap::new()).await;
195 assert_eq!(res.status(), StatusCode::OK, "{font} should be served");
196 }
197
198 #[tokio::test]
199 async fn a_matching_if_none_match_is_answered_304() {
200 // What keeps `private, no-cache` from meaning "re-download 1.9M of
201 // fonts on every navigation" — the 304 path ServeDir used to give us
202 // via Last-Modified.
203 let mut headers = HeaderMap::new();
204 headers.insert(header::IF_NONE_MATCH, ASSETS["mt.js"].etag.clone());
205 let res = serve(Path("mt.js".into()), headers).await;
206 assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
207 }
208
209 #[tokio::test]
210 async fn a_stale_if_none_match_is_answered_with_the_body() {
211 let mut headers = HeaderMap::new();
212 headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"stale\""));
213 let res = serve(Path("mt.js".into()), headers).await;
214 assert_eq!(res.status(), StatusCode::OK);
215 }
216
217 #[tokio::test]
218 async fn unknown_and_traversing_paths_are_404() {
219 for path in [
220 "nope.css",
221 "../Cargo.toml",
222 "../../etc/passwd",
223 "fonts/../../Cargo.toml",
224 ] {
225 let res = serve(Path(path.to_string()), HeaderMap::new()).await;
226 assert_eq!(
227 res.status(),
228 StatusCode::NOT_FOUND,
229 "{path} must not resolve to anything",
230 );
231 }
232 }
233
234 #[test]
235 fn etags_are_content_derived() {
236 let css = &ASSETS["style.css"];
237 assert_eq!(
238 css.etag,
239 HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(css.bytes))))
240 .unwrap(),
241 );
242 // Two different assets must not share an etag.
243 assert_ne!(css.etag, ASSETS["mt.js"].etag);
244 }
245 }
246