use std::collections::HashMap;
use std::sync::Arc;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use crate::Error;
#[derive(Debug, Clone, Copy, Default)]
pub struct Embedded {
pub views: &'static [(&'static str, &'static str)],
pub lang: &'static [(&'static str, &'static str)],
pub public: &'static [(&'static str, &'static [u8])],
}
pub(crate) fn content_type(path: &str) -> &'static str {
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
match ext.as_deref() {
Some("css") => "text/css; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("html" | "htm") => "text/html; charset=utf-8",
Some("json" | "map") => "application/json",
Some("txt") => "text/plain; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("pdf") => "application/pdf",
Some("xml") => "application/xml",
_ => "application/octet-stream",
}
}
pub(crate) type PublicFiles = Arc<HashMap<&'static str, &'static [u8]>>;
pub(crate) fn public_map(files: &'static [(&'static str, &'static [u8])]) -> PublicFiles {
Arc::new(files.iter().copied().collect())
}
pub(crate) fn serve(files: &PublicFiles, uri: &Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
match files.get(path) {
Some(bytes) => (
StatusCode::OK,
[
(CONTENT_TYPE, content_type(path)),
(CACHE_CONTROL, cache_control(uri)),
],
*bytes,
)
.into_response(),
None => Error::NotFound.into_response(),
}
}
pub(crate) enum AssetVersions {
Embedded(HashMap<&'static str, String>),
Disk {
root: std::path::PathBuf,
seen: std::sync::Mutex<HashMap<String, (std::time::SystemTime, String)>>,
},
}
fn short_hash(bytes: &[u8]) -> String {
crate::webhook::sha256_hex(bytes)[..8].to_owned()
}
impl AssetVersions {
pub(crate) fn new(
public: &std::path::Path,
embedded: Option<&'static [(&'static str, &'static [u8])]>,
) -> Self {
match embedded {
Some(files) => Self::Embedded(
files
.iter()
.map(|(path, bytes)| (*path, short_hash(bytes)))
.collect(),
),
None => Self::Disk {
root: public.to_path_buf(),
seen: std::sync::Mutex::default(),
},
}
}
pub(crate) fn version(&self, path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
match self {
Self::Embedded(files) => files.get(path).cloned(),
Self::Disk { root, seen } => {
let file = root.join(path);
if !file.starts_with(root) || path.split('/').any(|part| part == "..") {
return None;
}
let modified = std::fs::metadata(&file).and_then(|m| m.modified()).ok()?;
let mut seen = seen.lock().unwrap_or_else(|e| e.into_inner());
if let Some((at, hash)) = seen.get(path)
&& *at == modified
{
return Some(hash.clone());
}
let hash = short_hash(&std::fs::read(&file).ok()?);
seen.insert(path.to_owned(), (modified, hash.clone()));
Some(hash)
}
}
}
}
pub(crate) fn is_versioned(uri: &Uri) -> bool {
uri.query()
.is_some_and(|query| query.split('&').any(|pair| pair.starts_with("v=")))
}
pub(crate) fn cache_control(uri: &Uri) -> &'static str {
if is_versioned(uri) {
"public, max-age=31536000, immutable"
} else {
"public, max-age=3600"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_files_get_their_content_type() {
for (path, expected) in [
("logo.PNG", "image/png"),
("a.jpg", "image/jpeg"),
("a.jpeg", "image/jpeg"),
("a.gif", "image/gif"),
("a.webp", "image/webp"),
("favicon.ico", "image/x-icon"),
("f.woff", "font/woff"),
("f.woff2", "font/woff2"),
("terms.pdf", "application/pdf"),
("sitemap.xml", "application/xml"),
("archive.zip", "application/octet-stream"),
("README", "application/octet-stream"),
] {
assert_eq!(content_type(path), expected, "{path}");
}
}
#[test]
fn disk_versions_are_cached_and_stay_in_the_public_folder() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.css"), "a{}").unwrap();
let versions = AssetVersions::new(dir.path(), None);
let first = versions.version("/app.css").expect("a version");
assert_eq!(versions.version("app.css"), Some(first));
assert_eq!(versions.version("../secret.txt"), None);
assert_eq!(versions.version("missing.css"), None);
}
}