axum_embed_hashed_asset/
lib.rs1use axum::body::Body;
2use axum::extract::Path;
3use axum::http::{header, StatusCode};
4use axum::response::{IntoResponse, Response};
5use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
6
7const HASH_LEN: usize = 8;
8
9pub fn path<Asset: rust_embed::RustEmbed>(prefix: &str, file_path: &str) -> Option<String> {
10 Asset::get(file_path).map(|f| {
11 let mut p = String::from(prefix);
12 if !prefix.ends_with('/') {
13 p.push('/');
14 }
15 BASE64_URL_SAFE_NO_PAD.encode_string(&f.metadata.sha256_hash()[..HASH_LEN], &mut p);
16 p.push('/');
17 p.push_str(file_path);
18 p
19 })
20}
21
22pub async fn handle<Asset: rust_embed::RustEmbed>(
23 Path(path): Path<String>,
24) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
25 let (hash_b64, file_path) = path
26 .split_once('/')
27 .ok_or((StatusCode::BAD_REQUEST, "invalid asset url"))?;
28 let file = Asset::get(file_path).ok_or((StatusCode::NOT_FOUND, "asset not found"))?;
29 let hash = BASE64_URL_SAFE_NO_PAD
30 .decode(hash_b64)
31 .map_err(|_| (StatusCode::BAD_REQUEST, "hash invalid format"))?;
32 if hash.len() != HASH_LEN {
33 return Err((StatusCode::BAD_REQUEST, "hash invalid length"));
34 }
35 if !file.metadata.sha256_hash().starts_with(&hash) {
36 return Err((StatusCode::BAD_REQUEST, "hash mismatch"));
37 }
38 Ok(Response::builder()
39 .header(header::CONTENT_TYPE, file.metadata.mimetype())
40 .header(header::CACHE_CONTROL, "public,max-age=31536000,immutable")
41 .body(Body::from(file.data))
42 .map_err(|_| {
43 (
44 StatusCode::INTERNAL_SERVER_ERROR,
45 "failed to build response",
46 )
47 }))
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use axum::routing::get;
54 use axum::Router;
55 use rust_embed::RustEmbed;
56
57 #[derive(RustEmbed)]
58 #[folder = "src/"]
59 struct Asset;
60
61 #[test]
62 fn test_is_valid_handler() {
63 let _ = Router::<()>::new().route("/", get(handle::<Asset>));
64 }
65}