| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 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 |
|
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); |
| 37 |
|
| 38 |
|
| 39 |
struct Asset { |
| 40 |
bytes: &'static [u8], |
| 41 |
content_type: &'static str, |
| 42 |
etag: HeaderValue, |
| 43 |
} |
| 44 |
|
| 45 |
|
| 46 |
|
| 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 |
|
| 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 |
|
| 76 |
|
| 77 |
|
| 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 |
|
| 83 |
Some("map") => "application/json; charset=utf-8", |
| 84 |
Some("woff2") => "font/woff2", |
| 85 |
Some("ttf") => "font/ttf", |
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
Some("txt") => "text/plain; charset=utf-8", |
| 90 |
_ => "application/octet-stream", |
| 91 |
} |
| 92 |
} |
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
|
| 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 |
|
| 104 |
|
| 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 |
|
| 139 |
|
| 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 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 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 |
|
| 201 |
|
| 202 |
|
| 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 |
|
| 243 |
assert_ne!(css.etag, ASSETS["mt.js"].etag); |
| 244 |
} |
| 245 |
} |
| 246 |
|