Skip to main content

max / makenotwork

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