use std::sync::Arc;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Router,
};
static CONSOLE_DIST: include_dir::Dir<'_> = include_dir::include_dir!("$OUT_DIR/console-dist");
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsoleMount {
pub host: String,
pub path: String,
}
impl ConsoleMount {
pub fn resolve(host: Option<String>, path: Option<String>) -> Self {
let host = host
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "*".to_string());
Self {
host,
path: normalize_path(path.as_deref()),
}
}
pub fn from_effective(eff: &boatramp_core::daemon_config::EffectiveConfig) -> Option<Self> {
eff.console_enabled
.then(|| Self::resolve(eff.console_host.clone(), eff.console_path.clone()))
}
}
fn normalize_path(path: Option<&str>) -> String {
let raw = path
.map(str::trim)
.filter(|p| !p.is_empty())
.unwrap_or("/_console");
let core = raw.trim_matches('/');
if core.is_empty() {
String::new()
} else {
format!("/{core}")
}
}
pub fn host_matches(pattern: &str, host: &str) -> bool {
if pattern == "*" {
return true;
}
let host = host.to_ascii_lowercase();
let pattern = pattern.to_ascii_lowercase();
if let Some(suffix) = pattern.strip_prefix("*.") {
host == suffix || host.ends_with(&format!(".{suffix}"))
} else {
pattern == host
}
}
pub fn path_under(mount: &str, req_path: &str) -> bool {
if mount.is_empty() {
return true;
}
req_path == mount
|| req_path
.strip_prefix(mount)
.is_some_and(|rest| rest.starts_with('/'))
}
pub fn rewrite_index(index_html: &str, base: &str) -> String {
let mut html = index_html.to_string();
if !base.is_empty() {
for file in CONSOLE_DIST.files() {
if let Some(name) = file.path().to_str() {
html = html.replace(&format!("/{name}"), &format!("{base}/{name}"));
}
}
}
let injection =
format!("<base href=\"{base}/\"><meta name=\"boatramp-console-base\" content=\"{base}\">");
match html.find("<head>") {
Some(pos) => html.insert_str(pos + "<head>".len(), &injection),
None => html.insert_str(0, &injection),
}
html
}
fn content_type(name: &str) -> &'static str {
match name.rsplit('.').next() {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("wasm") => "application/wasm",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("png") => "image/png",
_ => "application/octet-stream",
}
}
fn serve_console(mount: &ConsoleMount, req_path: &str) -> Response {
let sub = req_path
.strip_prefix(&mount.path)
.unwrap_or(req_path)
.trim_start_matches('/');
if !sub.is_empty() {
if let Some(file) = CONSOLE_DIST.get_file(sub) {
let mut resp = Response::new(Body::from(file.contents()));
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type(sub)),
);
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
return resp;
}
}
let Some(index) = CONSOLE_DIST.get_file("index.html") else {
return (StatusCode::INTERNAL_SERVER_ERROR, "console assets missing").into_response();
};
let html = rewrite_index(index.contents_utf8().unwrap_or_default(), &mount.path);
let mut resp = Response::new(Body::from(html));
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
resp
}
#[derive(Clone)]
struct ConsoleState(Arc<crate::DaemonRuntime>);
async fn intercept(State(state): State<ConsoleState>, req: Request, next: Next) -> Response {
let Some(mount) = ConsoleMount::from_effective(&state.0.effective()) else {
return next.run(req).await;
};
let host = req
.headers()
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(crate::strip_port)
.unwrap_or("");
let path = req.uri().path().to_string();
if host_matches(&mount.host, host) && path_under(&mount.path, &path) {
return serve_console(&mount, &path);
}
next.run(req).await
}
pub fn mount(app: Router, daemon: Arc<crate::DaemonRuntime>) -> Router {
app.layer(axum::middleware::from_fn_with_state(
ConsoleState(daemon),
intercept,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_normalization() {
assert_eq!(normalize_path(None), "/_console");
assert_eq!(normalize_path(Some("")), "/_console");
assert_eq!(normalize_path(Some("/_console")), "/_console");
assert_eq!(normalize_path(Some("_console")), "/_console");
assert_eq!(normalize_path(Some("/admin/console/")), "/admin/console");
assert_eq!(normalize_path(Some("/")), "");
}
#[test]
fn host_matching() {
assert!(host_matches("*", "anything.example.com"));
assert!(host_matches("console.example.com", "console.example.com"));
assert!(host_matches("console.example.com", "Console.Example.COM"));
assert!(!host_matches("console.example.com", "other.example.com"));
assert!(host_matches("*.example.com", "a.example.com"));
assert!(host_matches("*.example.com", "example.com")); assert!(!host_matches("*.example.com", "example.org"));
}
#[test]
fn path_under_matching() {
assert!(path_under("/_console", "/_console"));
assert!(path_under("/_console", "/_console/"));
assert!(path_under("/_console", "/_console/sites/blog"));
assert!(!path_under("/_console", "/_consolex"));
assert!(!path_under("/_console", "/api/sites"));
assert!(path_under("", "/anything")); }
#[test]
fn from_effective_reflects_enabled_flag() {
use boatramp_core::daemon_config::{ConfigBaseline, DaemonConfig};
use boatramp_core::security::SecurityProfile;
let base = ConfigBaseline {
default_site: None,
protect_previews: false,
max_upload_bytes: 0,
upload_idle_timeout_secs: None,
max_concurrent_uploads: None,
cluster_rate_limit: false,
compute_vcpus: 0,
compute_mem_mib: 0,
console_enabled: false,
console_host: None,
console_path: None,
max_upload_ceiling: 0,
max_concurrent_uploads_ceiling: None,
posture: SecurityProfile::MultiTenant.preset(),
};
assert!(ConsoleMount::from_effective(&DaemonConfig::default().resolve(&base)).is_none());
let cfg = DaemonConfig {
console: boatramp_core::daemon_config::ConsoleSettings {
enabled: Some(true),
host: Some("admin.example.com".into()),
path: Some("/ui".into()),
},
..Default::default()
};
let m = ConsoleMount::from_effective(&cfg.resolve(&base)).expect("enabled ⇒ Some");
assert_eq!(m.host, "admin.example.com");
assert_eq!(m.path, "/ui");
}
#[test]
fn resolve_defaults() {
let m = ConsoleMount::resolve(None, None);
assert_eq!(m.host, "*");
assert_eq!(m.path, "/_console");
let m = ConsoleMount::resolve(Some("console.x.com".into()), Some("/ui".into()));
assert_eq!(m.host, "console.x.com");
assert_eq!(m.path, "/ui");
}
#[test]
fn serve_console_routes_asset_vs_index_vs_fallback() {
let mount = ConsoleMount::resolve(Some("*".into()), Some("/_console".into()));
let r = serve_console(&mount, "/_console");
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(
r.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
let r = serve_console(&mount, "/_console/sites/blog");
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(
r.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
if let Some(js) = CONSOLE_DIST
.files()
.find(|f| f.path().extension().is_some_and(|e| e == "js"))
{
let name = js.path().to_str().unwrap();
let r = serve_console(&mount, &format!("/_console/{name}"));
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(
r.headers().get(header::CONTENT_TYPE).unwrap(),
"text/javascript; charset=utf-8"
);
assert!(r
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap()
.contains("immutable"));
}
}
#[test]
fn index_rewrite_prefixes_assets_and_injects_base() {
let idx =
r#"<!DOCTYPE html><html><head><link href="/tailwind.css"/></head><body></body></html>"#;
let out = rewrite_index(idx, "/_console");
assert!(out.contains(r#"<base href="/_console/">"#));
assert!(out.contains(r#"<meta name="boatramp-console-base" content="/_console">"#));
let out0 = rewrite_index(idx, "");
assert!(out0.contains(r#"<base href="/">"#));
assert!(out0.contains(r#"href="/tailwind.css""#));
}
}