use std::{borrow::Cow, path::PathBuf};
use axum::{
Router,
body::Body,
extract::State,
http::{HeaderValue, StatusCode, Uri, header},
response::{IntoResponse, Response},
routing::get,
};
use tokio::fs;
use crate::{
config::{OpsConsoleAssetSource, OpsConsoleConfig},
error::ServerError,
};
#[derive(Clone)]
enum OpsConsoleAssets {
FileSystem {
root: PathBuf,
},
Embedded,
}
#[derive(rust_embed::RustEmbed)]
#[folder = "ops-console-embed"]
struct EmbeddedOpsConsole;
pub fn ops_console_router(config: &OpsConsoleConfig) -> Result<Router, ServerError> {
let assets = resolve_assets(&config.source)?;
Ok(Router::new()
.route("/", get(root_asset))
.fallback(get(path_asset))
.with_state(assets))
}
fn resolve_assets(source: &OpsConsoleAssetSource) -> Result<OpsConsoleAssets, ServerError> {
match source {
OpsConsoleAssetSource::FileSystem { asset_path } => {
let index_path = asset_path.join("index.html");
if !index_path.is_file() {
return Err(ServerError::Config {
message: format!(
"ops-console asset bundle `{}` must contain index.html",
asset_path.display()
),
});
}
Ok(OpsConsoleAssets::FileSystem {
root: asset_path.clone(),
})
}
OpsConsoleAssetSource::Embedded => {
if EmbeddedOpsConsole::get("index.html").is_none() {
return Err(ServerError::Config {
message: "embedded ops-console bundle must contain index.html".to_owned(),
});
}
Ok(OpsConsoleAssets::Embedded)
}
}
}
async fn root_asset(State(assets): State<OpsConsoleAssets>) -> Response {
serve_asset(&assets, "index.html").await
}
async fn path_asset(State(assets): State<OpsConsoleAssets>, uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if is_reserved_public_path(path) {
return StatusCode::NOT_FOUND.into_response();
}
match sanitize_path(path) {
Some(asset_path) => match read_asset(&assets, &asset_path).await {
Some(asset) => asset_response(asset_path.as_ref(), asset),
None => serve_asset(&assets, "index.html").await,
},
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn serve_asset(assets: &OpsConsoleAssets, path: &str) -> Response {
read_asset(assets, path)
.await
.map_or_else(index_missing_response, |asset| asset_response(path, asset))
}
async fn read_asset(assets: &OpsConsoleAssets, path: &str) -> Option<Cow<'static, [u8]>> {
match assets {
OpsConsoleAssets::FileSystem { root } => {
let bytes = fs::read(root.join(path)).await.ok()?;
Some(Cow::Owned(bytes))
}
OpsConsoleAssets::Embedded => Some(EmbeddedOpsConsole::get(path)?.data),
}
}
fn asset_response(path: &str, asset: Cow<'static, [u8]>) -> Response {
let mut response = Body::from(asset.into_owned()).into_response();
response
.headers_mut()
.insert(header::CONTENT_TYPE, content_type(path));
response
}
fn sanitize_path(path: &str) -> Option<String> {
if path.is_empty()
|| path
.split('/')
.any(|component| component.is_empty() || component == "." || component == "..")
{
return None;
}
Some(path.to_owned())
}
fn is_reserved_public_path(path: &str) -> bool {
if let Some(rest) = path.strip_prefix("workflows/") {
return uuid::Uuid::parse_str(rest).is_err();
}
path == "workflows" || path == "events" || path.starts_with("events/")
}
fn content_type(path: &str) -> HeaderValue {
let extension = std::path::Path::new(path)
.extension()
.and_then(std::ffi::OsStr::to_str);
if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("html")) {
HeaderValue::from_static("text/html; charset=utf-8")
} else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("js")) {
HeaderValue::from_static("text/javascript; charset=utf-8")
} else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("css")) {
HeaderValue::from_static("text/css; charset=utf-8")
} else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("json")) {
HeaderValue::from_static("application/json")
} else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) {
HeaderValue::from_static("image/svg+xml")
} else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("wasm")) {
HeaderValue::from_static("application/wasm")
} else {
HeaderValue::from_static("application/octet-stream")
}
}
fn index_missing_response() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
"ops-console index missing",
)
.into_response()
}
#[cfg(test)]
mod tests {
use axum::body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
async fn body_text(response: Response) -> Result<String, Box<dyn std::error::Error>> {
let bytes = body::to_bytes(response.into_body(), usize::MAX).await?;
Ok(String::from_utf8(bytes.to_vec())?)
}
#[tokio::test]
async fn embedded_source_serves_real_ops_console_index() -> TestResult {
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let response = router
.oneshot(Request::builder().uri("/").body(body::Body::empty())?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let text = body_text(response).await?;
assert!(text.contains("<!doctype html>") || text.contains("<!DOCTYPE html>"));
assert!(text.contains("<title>Aion Ops Console</title>"));
assert!(
text.contains("/assets/index-"),
"embedded index must reference the built asset bundle, got: {text}"
);
assert!(
!text.contains("AION_EMBED_PLACEHOLDER"),
"embedded index must be the real bundle, not the placeholder stub"
);
Ok(())
}
#[tokio::test]
async fn embedded_source_spa_fallback_serves_index_on_deep_links() -> TestResult {
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let response = router
.oneshot(
Request::builder()
.uri("/workflows-view/deep/link")
.body(body::Body::empty())?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(content_type.starts_with("text/html"), "got {content_type}");
let text = body_text(response).await?;
assert!(text.contains("<title>Aion Ops Console</title>"));
Ok(())
}
#[tokio::test]
async fn workflow_detail_deep_link_serves_index_but_api_paths_stay_reserved() -> TestResult {
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let detail = router
.clone()
.oneshot(
Request::builder()
.uri("/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910")
.body(body::Body::empty())?,
)
.await?;
assert_eq!(
detail.status(),
StatusCode::OK,
"a workflow-detail deep link must load the SPA"
);
let text = body_text(detail).await?;
assert!(text.contains("<title>Aion Ops Console</title>"));
for reserved in ["/workflows", "/workflows/count", "/workflows/not-a-uuid"] {
let response = router
.clone()
.oneshot(Request::builder().uri(reserved).body(body::Body::empty())?)
.await?;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"{reserved} must stay reserved for the API"
);
}
Ok(())
}
#[tokio::test]
async fn embedded_source_serves_hashed_js_asset() -> TestResult {
let Some(index) = EmbeddedOpsConsole::get("index.html") else {
return Err("embedded index present".into());
};
let html = String::from_utf8(index.data.into_owned())?;
let marker = "/assets/index-";
let Some(start) = html.find(marker) else {
return Err("index references an asset bundle".into());
};
let tail = &html[start + 1..]; let Some(js_at) = tail.find(".js") else {
return Err(".js asset present".into());
};
let asset_path = &tail[..js_at + ".js".len()];
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let response = router
.oneshot(
Request::builder()
.uri(format!("/{asset_path}"))
.body(body::Body::empty())?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
content_type.starts_with("text/javascript"),
"got {content_type}"
);
Ok(())
}
#[tokio::test]
async fn embedded_source_serves_wasm_with_the_streaming_compile_content_type() -> TestResult {
let Some(wasm_path) = EmbeddedOpsConsole::iter().find(|path| path.ends_with(".wasm"))
else {
return Err("embedded bundle contains a wasm asset".into());
};
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let response = router
.oneshot(
Request::builder()
.uri(format!("/{wasm_path}"))
.body(body::Body::empty())?,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
assert_eq!(content_type, "application/wasm", "got {content_type}");
Ok(())
}
#[test]
fn embedded_bundle_ships_every_asset_index_references() -> TestResult {
let index = EmbeddedOpsConsole::get("index.html")
.ok_or("embedded ops-console bundle is missing index.html")?;
let html = String::from_utf8(index.data.into_owned())?;
let mut referenced = Vec::new();
let mut rest = html.as_str();
while let Some(pos) = rest.find("/assets/") {
let tail = &rest[pos + 1..]; let end = tail
.find(['"', '\''])
.ok_or("unterminated /assets reference in embedded index.html")?;
referenced.push(tail[..end].to_owned());
rest = &tail[end..];
}
assert!(
!referenced.is_empty(),
"embedded index.html references no /assets bundle — the embedded ops \
console is empty or a placeholder, so `cargo install` would ship an \
API-only binary"
);
for asset in &referenced {
let embedded = EmbeddedOpsConsole::get(asset).ok_or_else(|| {
format!(
"embedded index.html references `{asset}` but it is NOT in the \
embedded bundle — the packaged crate dropped ops-console \
assets, so `cargo install` serves a broken console. Ensure \
`crates/aion-server/ops-console-embed/**` is git-tracked and \
not excluded from the package."
)
})?;
assert!(
!embedded.data.is_empty(),
"embedded ops-console asset `{asset}` is present but empty"
);
}
Ok(())
}
}