Skip to main content

max / makenotwork

9.4 KB · 251 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 // The OFL text shipped beside each self-hosted face. Served so the
92 // licence a visitor is pointed at is readable in a browser rather than
93 // downloaded as an opaque blob.
94 Some("txt") => "text/plain; charset=utf-8",
95 _ => "application/octet-stream",
96 }
97 }
98
99 /// `GET /static/{*path}`.
100 ///
101 /// Sits outside the CSRF and session layers, as the `ServeDir` it replaced did;
102 /// the security-header and timeout layers in `main.rs` wrap it, as they did.
103 ///
104 /// Nothing here awaits — the bytes are already in memory, which is the point —
105 /// but an axum handler has to return a future, so the `async` stays.
106 #[allow(clippy::unused_async, reason = "required by axum's Handler impl")]
107 pub async fn serve(Path(path): Path<String>, headers: HeaderMap) -> Response {
108 // Lookup is against the compile-time key set, so a `..` in the request path
109 // matches nothing rather than escaping anywhere.
110 let Some(asset) = ASSETS.get(path.as_str()) else {
111 return StatusCode::NOT_FOUND.into_response();
112 };
113
114 if let Some(inm) = headers.get(header::IF_NONE_MATCH)
115 && inm.as_bytes() == asset.etag.as_bytes()
116 {
117 return (
118 [(header::ETAG, asset.etag.clone())],
119 StatusCode::NOT_MODIFIED,
120 )
121 .into_response();
122 }
123
124 (
125 [
126 (
127 header::CONTENT_TYPE,
128 HeaderValue::from_static(asset.content_type),
129 ),
130 (header::ETAG, asset.etag.clone()),
131 ],
132 asset.bytes,
133 )
134 .into_response()
135 }
136
137 #[cfg(test)]
138 mod tests {
139 use super::*;
140
141 #[test]
142 fn the_assets_the_templates_reference_are_embedded() {
143 // These three are named by build.rs's fingerprint set and by base.html.
144 // If one stops being embedded, every page loses its styling silently.
145 for path in ["style.css", "mt.js", "htmx.min.js"] {
146 assert!(
147 ASSETS.contains_key(path),
148 "{path} is referenced by the templates but not embedded",
149 );
150 }
151 }
152
153 #[test]
154 fn nested_directories_are_walked() {
155 assert!(
156 ASSETS.keys().any(|p| p.starts_with("fonts/")),
157 "fonts/ did not survive the recursive walk: {:?}",
158 ASSETS.keys().collect::<Vec<_>>(),
159 );
160 }
161
162 #[test]
163 fn every_embedded_asset_has_a_known_content_type() {
164 // The fallback is legitimate for something genuinely opaque, but every
165 // asset in the tree today has a real type, and a new one silently
166 // served as octet-stream is a bug (a stylesheet that arrives as
167 // octet-stream is dropped outright under nosniff).
168 let unknown: Vec<_> = ASSETS
169 .iter()
170 .filter(|(_, a)| a.content_type == "application/octet-stream")
171 .map(|(p, _)| *p)
172 .collect();
173 assert!(
174 unknown.is_empty(),
175 "add these extensions to content_type_for: {unknown:?}",
176 );
177 }
178
179 #[tokio::test]
180 async fn a_known_asset_is_served_with_its_type_and_etag() {
181 let res = serve(Path("style.css".into()), HeaderMap::new()).await;
182 assert_eq!(res.status(), StatusCode::OK);
183 assert_eq!(
184 res.headers().get(header::CONTENT_TYPE).unwrap(),
185 "text/css; charset=utf-8",
186 );
187 assert_eq!(
188 res.headers().get(header::ETAG).unwrap(),
189 &ASSETS["style.css"].etag
190 );
191 }
192
193 #[tokio::test]
194 async fn a_nested_asset_is_reachable_by_its_full_path() {
195 let font = ASSETS
196 .keys()
197 .find(|p| p.starts_with("fonts/"))
198 .expect("a font is embedded");
199 let res = serve(Path((*font).to_string()), HeaderMap::new()).await;
200 assert_eq!(res.status(), StatusCode::OK, "{font} should be served");
201 }
202
203 #[tokio::test]
204 async fn a_matching_if_none_match_is_answered_304() {
205 // What keeps `private, no-cache` from meaning "re-download 1.9M of
206 // fonts on every navigation" — the 304 path ServeDir used to give us
207 // via Last-Modified.
208 let mut headers = HeaderMap::new();
209 headers.insert(header::IF_NONE_MATCH, ASSETS["mt.js"].etag.clone());
210 let res = serve(Path("mt.js".into()), headers).await;
211 assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
212 }
213
214 #[tokio::test]
215 async fn a_stale_if_none_match_is_answered_with_the_body() {
216 let mut headers = HeaderMap::new();
217 headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"stale\""));
218 let res = serve(Path("mt.js".into()), headers).await;
219 assert_eq!(res.status(), StatusCode::OK);
220 }
221
222 #[tokio::test]
223 async fn unknown_and_traversing_paths_are_404() {
224 for path in [
225 "nope.css",
226 "../Cargo.toml",
227 "../../etc/passwd",
228 "fonts/../../Cargo.toml",
229 ] {
230 let res = serve(Path(path.to_string()), HeaderMap::new()).await;
231 assert_eq!(
232 res.status(),
233 StatusCode::NOT_FOUND,
234 "{path} must not resolve to anything",
235 );
236 }
237 }
238
239 #[test]
240 fn etags_are_content_derived() {
241 let css = &ASSETS["style.css"];
242 assert_eq!(
243 css.etag,
244 HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(css.bytes))))
245 .unwrap(),
246 );
247 // Two different assets must not share an etag.
248 assert_ne!(css.etag, ASSETS["mt.js"].etag);
249 }
250 }
251