use axum::{
Router,
body::Body,
extract::Path,
http::{Response, StatusCode, header},
response::IntoResponse,
routing::get,
};
use tracing::{debug, warn};
#[cfg(all(feature = "web-embed", webui_dist))]
use rust_embed::Embed;
#[cfg(all(feature = "web-embed", webui_dist))]
#[derive(Embed)]
#[folder = "webui/dist"]
#[prefix = ""]
pub struct Assets;
#[allow(dead_code)]
fn assets_get(path: &str) -> Option<(Vec<u8>, String)> {
#[cfg(all(feature = "web-embed", webui_dist))]
{
Assets::get(path).map(|content| {
(
content.data.into_owned(),
content.metadata.mimetype().to_string(),
)
})
}
#[cfg(not(all(feature = "web-embed", webui_dist)))]
{
let _ = path; None
}
}
#[allow(dead_code)]
fn assets_list() -> Vec<String> {
#[cfg(all(feature = "web-embed", webui_dist))]
{
Assets::iter().map(|s| s.into_owned()).collect()
}
#[cfg(not(all(feature = "web-embed", webui_dist)))]
{
Vec::new()
}
}
async fn serve_asset(Path(path): Path<String>) -> impl IntoResponse {
serve_embedded_file(&path)
}
async fn serve_index() -> impl IntoResponse {
serve_embedded_file("index.html")
}
fn serve_embedded_file(path: &str) -> Response<Body> {
debug!("Attempting to serve embedded file: {}", path);
match assets_get(path) {
Some((data, mime_type)) => {
debug!("Found embedded file: {} ({})", path, mime_type);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(header::CACHE_CONTROL, cache_control_header(path))
.body(Body::from(data))
.unwrap()
}
None => {
debug!("File not found in embedded assets: {}", path);
if (!path.contains('.') || path.ends_with(".html"))
&& let Some((data, _mime)) = assets_get("index.html")
{
debug!("Serving index.html for SPA route: {}", path);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(data))
.unwrap();
}
warn!("Embedded asset not found: {}", path);
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from("Not Found"))
.unwrap()
}
}
}
fn cache_control_header(path: &str) -> &'static str {
if path.ends_with(".html") {
"no-cache"
} else if path.ends_with(".js") || path.ends_with(".css") {
"public, max-age=31536000, immutable"
} else if path.ends_with(".woff2")
|| path.ends_with(".woff")
|| path.ends_with(".ttf")
|| path.ends_with(".eot")
{
"public, max-age=31536000, immutable"
} else if path.ends_with(".png")
|| path.ends_with(".jpg")
|| path.ends_with(".jpeg")
|| path.ends_with(".gif")
|| path.ends_with(".svg")
|| path.ends_with(".ico")
{
"public, max-age=86400"
} else {
"public, max-age=3600"
}
}
pub fn embedded_assets_router() -> Router {
let assets = list_assets();
if !assets.is_empty() {
debug!("Embedded assets available (total: {})", assets.len());
for asset in assets.iter().take(10) {
debug!(" - {}", asset);
}
if assets.len() > 10 {
debug!(" ... and {} more", assets.len() - 10);
}
} else {
warn!("No embedded assets found!");
}
Router::new()
.route("/", get(serve_index))
.route("/{*path}", get(serve_asset))
}
pub fn has_embedded_assets() -> bool {
assets_get("index.html").is_some()
}
#[allow(dead_code)]
pub fn list_assets() -> Vec<String> {
assets_list()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_control_header() {
assert_eq!(cache_control_header("index.html"), "no-cache");
assert_eq!(
cache_control_header("assets/main.abc123.js"),
"public, max-age=31536000, immutable"
);
assert_eq!(
cache_control_header("assets/style.def456.css"),
"public, max-age=31536000, immutable"
);
assert_eq!(cache_control_header("favicon.ico"), "public, max-age=86400");
assert_eq!(
cache_control_header("fonts/inter.woff2"),
"public, max-age=31536000, immutable"
);
assert_eq!(cache_control_header("data.json"), "public, max-age=3600");
}
#[test]
fn test_has_embedded_assets() {
let _ = has_embedded_assets();
}
#[test]
fn test_list_assets() {
let assets = list_assets();
assert!(assets.is_empty() || !assets.is_empty());
}
}