use std::collections::HashMap;
use std::sync::OnceLock;
#[cfg(feature = "embed-assets")]
use std::sync::RwLock;
#[cfg(any(not(debug_assertions), feature = "embed-assets"))]
const ASSET_MANIFEST_FILE: &str = ".autumn-manifest.json";
#[cfg(any(not(debug_assertions), feature = "embed-assets"))]
#[derive(Debug, serde::Deserialize)]
struct AssetManifest {
files: HashMap<String, String>,
}
#[cfg(not(debug_assertions))]
static ASSET_MANIFEST: OnceLock<Option<AssetManifest>> = OnceLock::new();
#[cfg(not(debug_assertions))]
fn load_manifest() -> &'static Option<AssetManifest> {
ASSET_MANIFEST.get_or_init(|| {
let manifest_path =
crate::app::project_dir("static", &crate::config::OsEnv).join(ASSET_MANIFEST_FILE);
let contents = std::fs::read_to_string(manifest_path).ok()?;
serde_json::from_str(&contents).ok()
})
}
pub(crate) const VENDOR_MANIFEST_FILE: &str = ".autumn-assets.json";
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct VendorAsset {
pub version: String,
pub source: String,
pub file: String,
pub integrity: String,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct VendorManifest {
pub version: String,
pub assets: HashMap<String, VendorAsset>,
}
static VENDOR_MANIFEST: OnceLock<Option<VendorManifest>> = OnceLock::new();
fn load_vendor_manifest() -> Option<&'static VendorManifest> {
VENDOR_MANIFEST
.get_or_init(|| {
#[cfg(feature = "embed-assets")]
if embedded_static_dir().is_some() {
return load_embedded_vendor_manifest();
}
let manifest_path =
crate::app::project_dir("static", &crate::config::OsEnv).join(VENDOR_MANIFEST_FILE);
let contents = std::fs::read_to_string(manifest_path).ok()?;
serde_json::from_str(&contents).ok()
})
.as_ref()
}
pub(crate) fn htmx_is_vendored() -> bool {
load_vendor_manifest().is_some_and(|m| m.assets.contains_key("htmx"))
}
#[cfg(feature = "embed-assets")]
fn load_embedded_vendor_manifest() -> Option<VendorManifest> {
EMBEDDED_STATIC
.get()?
.0
.get_file(VENDOR_MANIFEST_FILE)
.and_then(include_dir::File::contents_utf8)
.and_then(|s| serde_json::from_str(s).ok())
}
#[cfg(feature = "maud")]
fn render_javascript_tag(asset: &VendorAsset) -> maud::Markup {
maud::html! {
script
src=(asset_url(&asset.file))
integrity=(asset.integrity)
crossorigin="anonymous"
{}
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::option_if_let_else)] pub fn javascript_include_tag(name: &str) -> maud::Markup {
if let Some(asset) = load_vendor_manifest().and_then(|m| m.assets.get(name)) {
render_javascript_tag(asset)
} else {
tracing::warn!(
asset = name,
"javascript_include_tag: asset not found in {}; \
run `autumn assets add {name}@<version>` to pin it",
VENDOR_MANIFEST_FILE,
);
missing_asset_markup(name)
}
}
#[cfg(feature = "maud")]
fn missing_asset_markup(name: &str) -> maud::Markup {
let comment = format!(
"<!-- autumn: asset '{name}' not found in {VENDOR_MANIFEST_FILE}; \
run `autumn assets add {name}@<version>` -->"
);
if cfg!(debug_assertions) {
let console_err = format!(
"console.error('[autumn] asset not found: {name} \
\u{2014} run autumn assets add {name}@<version>');"
);
maud::html! {
(maud::PreEscaped(comment))
script { (maud::PreEscaped(console_err)) }
}
} else {
maud::html! { (maud::PreEscaped(comment)) }
}
}
#[cfg(feature = "embed-assets")]
#[derive(Clone, Copy)]
pub struct EmbeddedStaticDir(pub &'static include_dir::Dir<'static>);
#[cfg(feature = "embed-assets")]
impl std::fmt::Debug for EmbeddedStaticDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmbeddedStaticDir")
.field("path", &self.0.path())
.finish()
}
}
#[cfg(feature = "embed-assets")]
static EMBEDDED_STATIC: OnceLock<EmbeddedStaticDir> = OnceLock::new();
#[cfg(feature = "embed-assets")]
static EMBEDDED_MANIFEST: OnceLock<Option<AssetManifest>> = OnceLock::new();
#[cfg(feature = "embed-assets")]
pub fn register_embedded_static(dir: EmbeddedStaticDir) {
let _ = EMBEDDED_STATIC.set(dir);
let _ = EMBEDDED_MANIFEST.set(
dir.0
.get_file(ASSET_MANIFEST_FILE)
.and_then(include_dir::File::contents_utf8)
.and_then(|s| serde_json::from_str::<AssetManifest>(s).ok()),
);
}
#[cfg(feature = "embed-assets")]
#[must_use]
pub fn embedded_static_dir() -> Option<EmbeddedStaticDir> {
EMBEDDED_STATIC.get().copied()
}
#[cfg(feature = "embed-assets")]
fn embedded_manifest_lookup(path: &str) -> Option<String> {
EMBEDDED_MANIFEST.get()?.as_ref()?.files.get(path).cloned()
}
#[cfg(feature = "embed-assets")]
fn embedded_is_manifest_asset(rel_path: &str) -> bool {
EMBEDDED_MANIFEST
.get()
.and_then(Option::as_ref)
.is_some_and(|m| m.files.values().any(|v| v == rel_path))
}
#[must_use]
pub fn asset_url(path: &str) -> String {
#[cfg(feature = "embed-assets")]
{
if embedded_static_dir().is_some() {
return embedded_manifest_lookup(path)
.map_or_else(|| format!("/static/{path}"), |fp| format!("/static/{fp}"));
}
}
#[cfg(debug_assertions)]
{
format!("/static/{path}")
}
#[cfg(not(debug_assertions))]
{
if let Some(manifest) = load_manifest() {
if let Some(fingerprinted) = manifest.files.get(path) {
return format!("/static/{fingerprinted}");
}
}
format!("/static/{path}")
}
}
#[allow(clippy::missing_const_for_fn)]
pub(crate) fn is_manifest_asset(rel_path: &str) -> bool {
#[cfg(feature = "embed-assets")]
{
if embedded_static_dir().is_some() {
return embedded_is_manifest_asset(rel_path);
}
}
#[cfg(not(debug_assertions))]
{
load_manifest()
.as_ref()
.is_some_and(|m| m.files.values().any(|v| v == rel_path))
}
#[cfg(debug_assertions)]
{
let _ = rel_path;
false
}
}
#[cfg(test)]
pub(crate) fn is_fingerprinted_path(uri_path: &str) -> bool {
let filename = uri_path.rsplit('/').next().unwrap_or("");
let parts: Vec<&str> = filename.split('.').collect();
if parts.len() < 3 {
return false;
}
let hash_candidate = parts[parts.len() - 2];
hash_candidate.len() == 8
&& hash_candidate
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub async fn asset_cache_control(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let path = req.uri().path().to_owned();
let mut resp = next.run(req).await;
if path.starts_with("/static/") && resp.status().is_success() {
let is_immutable = path.strip_prefix("/static/").is_some_and(is_manifest_asset);
let header = if is_immutable {
"public, max-age=31536000, immutable"
} else {
"public, max-age=0, must-revalidate"
};
resp.headers_mut().insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static(header),
);
}
resp
}
#[must_use]
pub(crate) fn content_type_for(path: &str) -> &'static str {
content_type_for_opt(path).unwrap_or("application/octet-stream")
}
#[must_use]
pub(crate) fn content_type_for_opt(path: &str) -> Option<&'static str> {
let raw = path
.rsplit('/')
.next()
.unwrap_or("")
.rsplit_once('.')
.map_or("", |(_, e)| e);
let mut buf = [0u8; 8];
let ext = if raw.is_ascii() && raw.len() <= buf.len() {
buf[..raw.len()].copy_from_slice(raw.as_bytes());
buf[..raw.len()].make_ascii_lowercase();
std::str::from_utf8(&buf[..raw.len()]).unwrap_or("")
} else {
"" };
let mime = match ext {
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" | "map" => "application/json",
"html" | "htm" => "text/html; charset=utf-8",
"txt" => "text/plain; charset=utf-8",
"xml" => "application/xml",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"wasm" => "application/wasm",
_ => return None,
};
Some(mime)
}
#[cfg(feature = "embed-assets")]
static EMBEDDED_ETAG_CACHE: OnceLock<RwLock<HashMap<usize, crate::etag::ETag>>> = OnceLock::new();
#[cfg(feature = "embed-assets")]
fn embedded_etag_cached(bytes: &[u8]) -> crate::etag::ETag {
let key = bytes.as_ptr() as usize;
let cache = EMBEDDED_ETAG_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
if let Some(etag) = cache.read().unwrap().get(&key) {
return etag.clone();
}
let etag = embedded_etag(bytes);
cache.write().unwrap().insert(key, etag.clone());
etag
}
#[cfg(feature = "embed-assets")]
fn embedded_etag(bytes: &[u8]) -> crate::etag::ETag {
use sha2::{Digest as _, Sha256};
const HEX: &[u8; 16] = b"0123456789abcdef";
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for b in digest {
hex.push(HEX[(b >> 4) as usize] as char);
hex.push(HEX[(b & 0x0f) as usize] as char);
}
crate::etag::ETag::strong(hex)
}
#[cfg(feature = "embed-assets")]
fn embedded_response(rel_path: &str, req_headers: &http::HeaderMap) -> axum::response::Response {
use axum::response::IntoResponse;
let is_traversal = rel_path
.split('/')
.any(|seg| seg.is_empty() || seg == "." || seg == "..");
let is_manifest = rel_path
.rsplit('/')
.next()
.is_some_and(|name| name == ASSET_MANIFEST_FILE || name == VENDOR_MANIFEST_FILE);
if is_traversal || is_manifest {
return http::StatusCode::NOT_FOUND.into_response();
}
let Some(dir) = embedded_static_dir() else {
return http::StatusCode::NOT_FOUND.into_response();
};
dir.0.get_file(rel_path).map_or_else(
|| http::StatusCode::NOT_FOUND.into_response(),
|file| {
let bytes = bytes::Bytes::from_static(file.contents());
let total = bytes.len() as u64;
let etag = embedded_etag_cached(&bytes);
let validator = crate::range::Validator::new().with_etag(&etag);
let resolution = crate::range::resolve(req_headers, total, Some(validator));
let mut response = crate::range::partial_bytes_response(&resolution, bytes);
let headers = response.headers_mut();
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static(content_type_for(rel_path)),
);
headers.insert(http::header::ETAG, etag.header_value());
response
},
)
}
#[cfg(feature = "embed-assets")]
pub(crate) async fn serve_embedded(
axum::extract::Path(path): axum::extract::Path<String>,
headers: http::HeaderMap,
) -> axum::response::Response {
embedded_response(&path, &headers)
}
#[cfg(feature = "embed-assets")]
pub fn embedded_static_router() -> axum::Router {
axum::Router::new()
.route("/static/{*path}", axum::routing::get(serve_embedded))
.layer(axum::middleware::from_fn(asset_cache_control))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_url_returns_static_prefix() {
let url = asset_url("css/autumn.css");
assert!(
url.starts_with("/static/"),
"url must have /static/ prefix: {url}"
);
assert!(
url.contains("autumn.css"),
"url must contain asset name: {url}"
);
}
#[test]
fn fingerprinted_path_detected() {
assert!(is_fingerprinted_path("/static/css/autumn.a1b2c3d4.css"));
assert!(is_fingerprinted_path("/static/js/app.00000000.js"));
assert!(is_fingerprinted_path("/static/img/logo.deadbeef.png"));
}
#[test]
fn non_fingerprinted_paths_rejected() {
assert!(!is_fingerprinted_path("/static/css/autumn.css"));
assert!(!is_fingerprinted_path("/static/js/htmx.min.js"));
assert!(!is_fingerprinted_path("/static/img/logo.png"));
assert!(!is_fingerprinted_path("/static/css/autumn.abc.css"));
assert!(!is_fingerprinted_path("/static/css/autumn.a1b2c3d4e5.css"));
assert!(!is_fingerprinted_path("/static/css/autumn.A1B2C3D4.css"));
assert!(!is_fingerprinted_path("/static/file.a1b2c3d4"));
}
#[cfg(feature = "maud")]
#[test]
fn vendor_manifest_parses_json() {
let json = r#"{
"version": "1",
"assets": {
"htmx": {
"version": "2.0.4",
"source": "https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js",
"file": "js/htmx.min.js",
"integrity": "sha384-testintegrityhash"
}
}
}"#;
let manifest: VendorManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.version, "1");
let htmx = manifest.assets.get("htmx").unwrap();
assert_eq!(htmx.version, "2.0.4");
assert_eq!(htmx.file, "js/htmx.min.js");
assert_eq!(htmx.integrity, "sha384-testintegrityhash");
}
#[cfg(feature = "maud")]
#[test]
fn render_javascript_tag_has_src_integrity_crossorigin() {
let asset = VendorAsset {
version: "2.0.4".into(),
source: "https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js".into(),
file: "js/htmx.min.js".into(),
integrity: "sha384-abc123".into(),
};
let markup = render_javascript_tag(&asset);
let html = markup.into_string();
assert!(
html.contains("src=\"/static/js/htmx.min.js\""),
"missing src: {html}"
);
assert!(
html.contains("integrity=\"sha384-abc123\""),
"missing integrity: {html}"
);
assert!(
html.contains("crossorigin=\"anonymous\""),
"missing crossorigin: {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn javascript_include_tag_unknown_name_returns_diagnostic_comment() {
let markup = javascript_include_tag("__nonexistent_pkg__");
let html = markup.into_string();
assert!(
html.contains("<!-- autumn: asset '__nonexistent_pkg__' not found"),
"expected HTML comment diagnostic for unknown asset, got: {html}"
);
}
#[cfg(feature = "embed-assets")]
#[test]
fn content_type_covers_common_assets() {
assert_eq!(content_type_for("css/app.css"), "text/css; charset=utf-8");
assert_eq!(
content_type_for("js/app.js"),
"text/javascript; charset=utf-8"
);
assert_eq!(content_type_for("img/logo.svg"), "image/svg+xml");
assert_eq!(content_type_for("img/logo.png"), "image/png");
assert_eq!(content_type_for("fonts/inter.woff2"), "font/woff2");
assert_eq!(content_type_for("favicon.ico"), "image/x-icon");
assert_eq!(content_type_for("data.bin"), "application/octet-stream");
assert_eq!(content_type_for("LICENSE"), "application/octet-stream");
}
}