use axum::{
body::Body,
extract::Path,
http::{StatusCode, header},
response::{IntoResponse, Redirect, Response},
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "ui-search-dist/"]
struct SearchUiAssets;
const SEARCH_API_BASE: &str = "/api/search/";
pub async fn search_ui_redirect() -> Redirect {
Redirect::permanent("/tools/search/")
}
pub async fn search_ui_index() -> Response {
serve_search_index()
}
pub async fn search_ui_asset(Path(path): Path<String>) -> Response {
let trimmed = path.trim_start_matches('/');
match SearchUiAssets::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_search_index(),
}
}
fn serve_search_index() -> Response {
let Some(index) = SearchUiAssets::get("index.html") else {
return (
StatusCode::NOT_FOUND,
"search dashboard assets not bundled — run `make -C crates/trusty-console search-ui`.",
)
.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, SEARCH_API_BASE)))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn inject_api_base(html: &str, api_base: &str) -> String {
let script = format!(
"<script>\n\
// #6155: the console proxies the search API under this prefix.\n\
window.__SEARCH_BASE__ = 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 inject_api_base_lands_before_head_close() {
let html = "<html><head><title>x</title></head><body></body></html>";
let out = inject_api_base(html, "/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 inject_api_base_without_head() {
let out = inject_api_base("<html><body></body></html>", "/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>", "/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");
}
}