use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; use axum::response::{IntoResponse, Response}; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::OnceLock; const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable"; const CACHE_NO_CACHE: &str = "no-cache"; const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate"; /// Service worker path, mirrored from `web/public/sw.js`. const SERVICE_WORKER: &str = "sw.js"; /// PWA manifest path, mirrored from `web/public/manifest.webmanifest`. const WEB_MANIFEST: &str = "manifest.webmanifest"; fn etags() -> &'static HashMap<&'static str, HeaderValue> { static ETAGS: OnceLock> = OnceLock::new(); ETAGS.get_or_init(|| { let mut m = HashMap::new(); let index = web::index(); m.insert(index.path, etag_of(index.content)); for p in web::paths() { if let Some(a) = web::file(p) { m.insert(a.path, etag_of(a.content)); } } m }) } fn etag_of(content: &[u8]) -> HeaderValue { let mut h = std::collections::hash_map::DefaultHasher::new(); content.hash(&mut h); h.write_u64(content.len() as u64); HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", h.finish()).into_bytes()) .expect("valid etag header") } fn cache_control(path: &str) -> &'static str { if path.starts_with("assets/") { CACHE_IMMUTABLE } else if path == "index.html" || path == SERVICE_WORKER || path == WEB_MANIFEST { // The service worker is the push receiver: it must never be served from // an hour-long cache, or a fixed push handler keeps running old logic. // The manifest is what makes iOS treat the site as installable, so it // rides along on the same rule. CACHE_NO_CACHE } else { CACHE_REVALIDATE } } pub async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response { if !matches!(method, Method::GET | Method::HEAD) { return StatusCode::METHOD_NOT_ALLOWED.into_response(); } let path = uri.path(); if path.starts_with("/api") { return StatusCode::NOT_FOUND.into_response(); } let asset = match web::file(path) { Some(asset) => asset, None => { if path.rsplit('/').next().is_some_and(|s| s.contains('.')) { return StatusCode::NOT_FOUND.into_response(); } web::index() } }; serve(asset, &headers) } fn serve(asset: web::Asset, headers: &HeaderMap) -> Response { let etag = etags() .get(asset.path) .cloned() .unwrap_or_else(|| etag_of(asset.content)); let cache_control = HeaderValue::from_static(cache_control(asset.path)); if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) && etag_matches(if_none_match, &etag) { return ( StatusCode::NOT_MODIFIED, [(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)], ) .into_response(); } ( [ (header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)), (header::CACHE_CONTROL, cache_control), (header::ETAG, etag), ], asset.content, ) .into_response() } fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool { let Ok(value) = if_none_match.to_str() else { return false; }; let Ok(target) = etag.to_str() else { return false; }; fn strong(s: &str) -> &str { s.strip_prefix("W/").unwrap_or(s) } value .split(',') .map(str::trim) .any(|c| c == "*" || strong(c) == strong(target)) }