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 super::client_routes::{CLIENT_ROUTES_MANIFEST, ClientRoutes};
use crate::{
config::{OpsConsoleAssetSource, OpsConsoleConfig},
error::ServerError,
};
#[derive(Clone)]
struct OpsConsoleAssets {
bundle: OpsConsoleBundle,
client_routes: ClientRoutes,
}
#[derive(Clone)]
enum OpsConsoleBundle {
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> {
let (bundle, description) = match source {
OpsConsoleAssetSource::FileSystem { asset_path } => (
OpsConsoleBundle::FileSystem {
root: asset_path.clone(),
},
format!("ops-console asset bundle `{}`", asset_path.display()),
),
OpsConsoleAssetSource::Embedded => (
OpsConsoleBundle::Embedded,
"embedded ops-console bundle".to_owned(),
),
};
if read_bundle_file(&bundle, "index.html").is_none() {
return Err(ServerError::Config {
message: format!("{description} must contain index.html"),
});
}
let Some(manifest) = read_bundle_file(&bundle, CLIENT_ROUTES_MANIFEST) else {
return Err(ServerError::Config {
message: format!(
"{description} must contain `{CLIENT_ROUTES_MANIFEST}` — the console's own route \
set, written by its build. Regenerate the bundle with \
`cargo xtask build-ops-console`."
),
});
};
let client_routes =
ClientRoutes::parse(manifest.as_ref()).map_err(|message| ServerError::Config {
message: format!("{description}: {message}"),
})?;
Ok(OpsConsoleAssets {
bundle,
client_routes,
})
}
fn read_bundle_file(bundle: &OpsConsoleBundle, path: &str) -> Option<Cow<'static, [u8]>> {
match bundle {
OpsConsoleBundle::FileSystem { root } => {
Some(Cow::Owned(std::fs::read(root.join(path)).ok()?))
}
OpsConsoleBundle::Embedded => Some(EmbeddedOpsConsole::get(path)?.data),
}
}
async fn root_asset(State(assets): State<OpsConsoleAssets>) -> Response {
serve_asset(&assets.bundle, "index.html").await
}
async fn path_asset(State(assets): State<OpsConsoleAssets>, uri: Uri) -> Response {
let path = uri.path();
if let Some((asset_path, asset)) =
bundle_asset(&assets.bundle, path.trim_start_matches('/')).await
{
return asset_response(asset_path.as_ref(), asset);
}
if assets.client_routes.matches(path) {
return serve_asset(&assets.bundle, "index.html").await;
}
StatusCode::NOT_FOUND.into_response()
}
async fn bundle_asset(
bundle: &OpsConsoleBundle,
path: &str,
) -> Option<(String, Cow<'static, [u8]>)> {
let asset_path = sanitize_path(path)?;
let asset = read_asset(bundle, &asset_path).await?;
Some((asset_path, asset))
}
async fn serve_asset(bundle: &OpsConsoleBundle, path: &str) -> Response {
read_asset(bundle, path)
.await
.map_or_else(index_missing_response, |asset| asset_response(path, asset))
}
async fn read_asset(bundle: &OpsConsoleBundle, path: &str) -> Option<Cow<'static, [u8]>> {
match bundle {
OpsConsoleBundle::FileSystem { root } => {
let bytes = fs::read(root.join(path)).await.ok()?;
Some(Cow::Owned(bytes))
}
OpsConsoleBundle::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 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 every_declared_client_route_serves_the_spa() -> TestResult {
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
let Some(manifest) = EmbeddedOpsConsole::get(CLIENT_ROUTES_MANIFEST) else {
return Err("the embedded bundle carries its client-route manifest".into());
};
let declared: serde_json::Value = serde_json::from_slice(manifest.data.as_ref())?;
let Some(declared_routes) = declared.get("routes").and_then(serde_json::Value::as_array)
else {
return Err("the manifest declares a `routes` array".into());
};
assert!(
declared_routes.len() >= 2,
"the console declares more than its root redirect"
);
let mut checked = 0_usize;
for route in declared_routes {
let Some(pattern) = route.as_str() else {
return Err("every declared route is a string".into());
};
let path = pattern
.replace("{uuid}", "141852b2-20b9-4e94-8361-7a1ea3d5f910")
.replace("{name}", "grade")
.replace(
"{hash}",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
let response = router
.clone()
.oneshot(Request::builder().uri(&path).body(body::Body::empty())?)
.await?;
assert_eq!(
response.status(),
StatusCode::OK,
"{path} is a declared console route and must load the SPA"
);
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>"), "{path}");
checked += 1;
}
assert_eq!(
checked,
declared_routes.len(),
"every declared route was requested"
);
assert!(
declared_routes
.iter()
.any(|route| route.as_str() == Some("/workflows")),
"the console's primary screen is in the declaration"
);
Ok(())
}
#[tokio::test]
async fn a_path_the_console_does_not_own_stays_a_plain_404() -> TestResult {
let config = OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
};
let router = ops_console_router(&config)?;
for unowned in [
"/workflows/count",
"/workflows/not-a-uuid",
"/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910/attempts",
"/events",
"/whoami",
"/workflows-view/deep/link",
] {
let response = router
.clone()
.oneshot(Request::builder().uri(unowned).body(body::Body::empty())?)
.await?;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"{unowned} is not a console route and must not be answered with the SPA"
);
}
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(())
}
}