use axum::Router;
use axum::body::Body;
use axum::http::{StatusCode, Uri, header};
use axum::response::Response;
use axum::routing::get;
use include_dir::{Dir, include_dir};
static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
const DEFAULT_CONFIG: &str =
r#"window.HEADGATE = window.HEADGATE || {apiBase:"/api/v1",readOnly:false};"#;
#[derive(Clone, Debug)]
pub struct Config {
pub api_base: String,
pub read_only: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
api_base: "/api/v1".into(),
read_only: false,
}
}
}
pub fn router(cfg: Config) -> Router {
Router::new().fallback(get(move |uri: Uri| {
let cfg = cfg.clone();
async move { serve(uri.path(), &cfg) }
}))
}
fn serve(path: &str, cfg: &Config) -> Response<Body> {
let asset_path = path.trim_start_matches('/');
if !asset_path.is_empty() && asset_path != "index.html" {
if let Some(file) = ASSETS.get_file(asset_path) {
return response(
StatusCode::OK,
content_type(asset_path),
"public, max-age=31536000, immutable",
Body::from(file.contents()),
);
}
if asset_path.starts_with("assets/") {
return response(
StatusCode::NOT_FOUND,
"text/plain; charset=utf-8",
"no-store",
Body::from("asset not found"),
);
}
}
let index = ASSETS
.get_file("index.html")
.expect("ui/dist/index.html must be produced by pnpm build");
let template = std::str::from_utf8(index.contents()).expect("console shell must be UTF-8");
let config_json = serde_json::json!({ "apiBase": cfg.api_base, "readOnly": cfg.read_only })
.to_string()
.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029");
let injected = format!("window.HEADGATE = {config_json};");
let page = template
.replacen(DEFAULT_CONFIG, &injected, 1)
.replace("./assets/", &relative_asset_prefix(path));
response(
StatusCode::OK,
"text/html; charset=utf-8",
"no-cache",
Body::from(page),
)
}
fn relative_asset_prefix(request_path: &str) -> String {
let trimmed = request_path.trim_matches('/');
if trimmed.is_empty() {
return "./assets/".into();
}
let mut depth = trimmed.matches('/').count();
if request_path.ends_with('/') {
depth += 1;
}
if depth == 0 {
"./assets/".into()
} else {
format!("{}assets/", "../".repeat(depth))
}
}
fn response(
status: StatusCode,
content_type: &'static str,
cache_control: &'static str,
body: Body,
) -> Response<Body> {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, cache_control)
.body(body)
.expect("static response headers are valid")
}
fn content_type(path: &str) -> &'static str {
match path.rsplit('.').next() {
Some("css") => "text/css; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("woff2") => "font/woff2",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("ico") => "image/x-icon",
_ => "application/octet-stream",
}
}