use axum::{
body::Body,
extract::Path,
http::{StatusCode, header},
response::{IntoResponse, Redirect, Response},
};
use rust_embed::{EmbeddedFile, RustEmbed};
#[derive(RustEmbed)]
#[folder = "ui-search-dist/"]
struct SearchUiAssets;
#[derive(RustEmbed)]
#[folder = "ui-memory-dist/"]
struct MemoryUiAssets;
#[derive(RustEmbed)]
#[folder = "ui-analyze-dist/"]
struct AnalyzeUiAssets;
struct SpaMount {
get: fn(&str) -> Option<EmbeddedFile>,
base_global: &'static str,
api_base: &'static str,
remedy: &'static str,
}
const SEARCH: SpaMount = SpaMount {
get: SearchUiAssets::get,
base_global: "__SEARCH_BASE__",
api_base: "/api/search/",
remedy: "search dashboard assets not bundled — run `make -C crates/trusty-console search-ui`.",
};
const MEMORY: SpaMount = SpaMount {
get: MemoryUiAssets::get,
base_global: "__MEMORY_BASE__",
api_base: "/api/memory/",
remedy: "memory dashboard assets not bundled — run `make -C crates/trusty-console memory-ui`.",
};
const ANALYZE: SpaMount = SpaMount {
get: AnalyzeUiAssets::get,
base_global: "__ANALYZE_BASE__",
api_base: "/api/analyze/",
remedy: "analyze dashboard assets not bundled — run `make -C crates/trusty-console analyze-ui`.",
};
pub async fn search_ui_redirect() -> Redirect {
Redirect::permanent("/tools/search/")
}
pub async fn search_ui_index() -> Response {
serve_index(&SEARCH)
}
pub async fn search_ui_asset(Path(path): Path<String>) -> Response {
serve_asset(&SEARCH, &path)
}
pub async fn memory_ui_redirect() -> Redirect {
Redirect::permanent("/tools/memory/")
}
pub async fn memory_ui_index() -> Response {
serve_index(&MEMORY)
}
pub async fn memory_ui_asset(Path(path): Path<String>) -> Response {
serve_asset(&MEMORY, &path)
}
pub async fn analyze_ui_redirect() -> Redirect {
Redirect::permanent("/tools/analyze/")
}
pub async fn analyze_ui_index() -> Response {
serve_index(&ANALYZE)
}
pub async fn analyze_ui_asset(Path(path): Path<String>) -> Response {
serve_asset(&ANALYZE, &path)
}
fn serve_asset(mount: &SpaMount, path: &str) -> Response {
let trimmed = path.trim_start_matches('/');
match (mount.get)(trimmed) {
Some(content) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
mime_guess::from_path(trimmed)
.first_or_octet_stream()
.as_ref(),
)
.header(header::CACHE_CONTROL, cache_control_for(trimmed))
.body(Body::from(content.data.to_vec()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
None => serve_index(mount),
}
}
fn serve_index(mount: &SpaMount) -> Response {
let Some(index) = (mount.get)("index.html") else {
return (StatusCode::NOT_FOUND, mount.remedy).into_response();
};
let html = String::from_utf8_lossy(index.data.as_ref());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(inject_api_base(
&html,
mount.base_global,
mount.api_base,
)))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn inject_api_base(html: &str, global: &str, api_base: &str) -> String {
let script = format!(
"<script>\n\
// #6155: the console bridges this tool's API under this prefix.\n\
window.{global} = new URL({api_base:?}, document.baseURI).href;\n\
</script>"
);
match html.find("</head>") {
Some(idx) => {
let mut out = String::with_capacity(html.len() + script.len());
out.push_str(&html[..idx]);
out.push_str(&script);
out.push_str(&html[idx..]);
out
}
None => format!("{script}{html}"),
}
}
fn cache_control_for(path: &str) -> &'static str {
if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_index_is_embedded() {
let index = SearchUiAssets::get("index.html").expect("bundle carries index.html");
let html = String::from_utf8_lossy(index.data.as_ref());
assert!(html.contains("Trusty Search"), "wrong bundle embedded");
assert!(
html.contains("./assets/"),
"bundle must use relative asset refs so the /tools/search/ mount resolves them"
);
}
#[test]
fn memory_index_is_embedded() {
let index = MemoryUiAssets::get("index.html").expect("bundle carries index.html");
let html = String::from_utf8_lossy(index.data.as_ref());
assert!(html.contains("Trusty Memory"), "wrong bundle embedded");
assert!(
html.contains("./assets/"),
"bundle must use relative asset refs so the /tools/memory/ mount resolves them"
);
}
#[test]
fn inject_api_base_lands_before_head_close() {
let html = "<html><head><title>x</title></head><body></body></html>";
let out = inject_api_base(html, "__SEARCH_BASE__", "/api/search/");
let script = out.find("__SEARCH_BASE__").expect("global injected");
let head_close = out.find("</head>").expect("head close preserved");
assert!(script < head_close, "script must sit inside <head>");
assert!(out.contains(r#"new URL("/api/search/", document.baseURI)"#));
}
#[test]
fn analyze_index_is_embedded() {
let index = AnalyzeUiAssets::get("index.html").expect("bundle carries index.html");
let html = String::from_utf8_lossy(index.data.as_ref());
assert!(html.contains("trusty-analyzer"), "wrong bundle embedded");
assert!(
html.contains("./assets/"),
"bundle must use relative asset refs so the /tools/analyze/ mount resolves them"
);
}
#[test]
fn each_mount_injects_its_own_global_and_prefix() {
let html = "<html><head></head></html>";
let search = inject_api_base(html, SEARCH.base_global, SEARCH.api_base);
let memory = inject_api_base(html, MEMORY.base_global, MEMORY.api_base);
let analyze = inject_api_base(html, ANALYZE.base_global, ANALYZE.api_base);
assert!(search.contains(r#"window.__SEARCH_BASE__ = new URL("/api/search/""#));
assert!(memory.contains(r#"window.__MEMORY_BASE__ = new URL("/api/memory/""#));
assert!(analyze.contains(r#"window.__ANALYZE_BASE__ = new URL("/api/analyze/""#));
assert!(!memory.contains("__SEARCH_BASE__"));
assert!(!analyze.contains("__MEMORY_BASE__"));
}
#[test]
fn inject_api_base_without_head() {
let out = inject_api_base(
"<html><body></body></html>",
"__SEARCH_BASE__",
"/api/search/",
);
assert!(out.starts_with("<script>"));
assert!(out.contains("__SEARCH_BASE__"));
}
#[test]
fn inject_api_base_escapes_the_base() {
let out = inject_api_base(
"<html><head></head></html>",
"__SEARCH_BASE__",
"/api/\"evil\"/",
);
assert!(out.contains(r#""/api/\"evil\"/""#));
assert!(!out.contains(r#""/api/"evil"/""#));
}
#[test]
fn cache_control_hashed_assets_are_immutable() {
assert!(cache_control_for("assets/index-abc.js").contains("immutable"));
assert_eq!(cache_control_for("index.html"), "no-cache");
}
}