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::{DashboardAssetSource, DashboardConfig},
error::ServerError,
};
#[derive(Clone)]
enum DashboardAssets {
FileSystem { root: PathBuf },
#[cfg(feature = "embed-dashboard")]
Embedded,
#[cfg(not(feature = "embed-dashboard"))]
Placeholder,
}
#[cfg(feature = "embed-dashboard")]
#[derive(rust_embed::RustEmbed)]
#[folder = "dashboard-embed"]
struct EmbeddedDashboard;
#[cfg(not(feature = "embed-dashboard"))]
const PLACEHOLDER_HTML: &str = include_str!("placeholder.html");
pub fn dashboard_router(config: &DashboardConfig) -> 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: &DashboardAssetSource) -> Result<DashboardAssets, ServerError> {
match source {
DashboardAssetSource::FileSystem { asset_path } => {
let index_path = asset_path.join("index.html");
if !index_path.is_file() {
return Err(ServerError::Config {
message: format!(
"dashboard asset bundle `{}` must contain index.html",
asset_path.display()
),
});
}
Ok(DashboardAssets::FileSystem {
root: asset_path.clone(),
})
}
#[cfg(feature = "embed-dashboard")]
DashboardAssetSource::Embedded => {
if EmbeddedDashboard::get("index.html").is_none() {
return Err(ServerError::Config {
message: "embedded dashboard bundle must contain index.html".to_owned(),
});
}
Ok(DashboardAssets::Embedded)
}
#[cfg(not(feature = "embed-dashboard"))]
DashboardAssetSource::Embedded => Ok(DashboardAssets::Placeholder),
}
}
async fn root_asset(State(assets): State<DashboardAssets>) -> Response {
serve_asset(&assets, "index.html").await
}
async fn path_asset(State(assets): State<DashboardAssets>, 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(&assets, asset_path.as_ref(), asset),
None => serve_asset(&assets, "index.html").await,
},
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn serve_asset(assets: &DashboardAssets, path: &str) -> Response {
read_asset(assets, path).await.map_or_else(
index_missing_response,
|asset| asset_response(assets, path, asset),
)
}
async fn read_asset(assets: &DashboardAssets, path: &str) -> Option<Cow<'static, [u8]>> {
match assets {
DashboardAssets::FileSystem { root } => {
let bytes = fs::read(root.join(path)).await.ok()?;
Some(Cow::Owned(bytes))
}
#[cfg(feature = "embed-dashboard")]
DashboardAssets::Embedded => Some(EmbeddedDashboard::get(path)?.data),
#[cfg(not(feature = "embed-dashboard"))]
DashboardAssets::Placeholder => Some(Cow::Borrowed(PLACEHOLDER_HTML.as_bytes())),
}
}
fn asset_response(assets: &DashboardAssets, 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_for(assets, path));
response
}
fn content_type_for(assets: &DashboardAssets, path: &str) -> HeaderValue {
match assets {
#[cfg(not(feature = "embed-dashboard"))]
DashboardAssets::Placeholder => HeaderValue::from_static("text/html; charset=utf-8"),
_ => content_type(path),
}
}
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 {
path == "workflows"
|| path.starts_with("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 {
HeaderValue::from_static("application/octet-stream")
}
}
fn index_missing_response() -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, "dashboard 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_html_index() -> TestResult {
let config = DashboardConfig {
source: DashboardAssetSource::Embedded,
};
let router = dashboard_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("<html"));
Ok(())
}
#[cfg(not(feature = "embed-dashboard"))]
#[tokio::test]
async fn placeholder_build_serves_branded_guidance_on_deep_links() -> TestResult {
let config = DashboardConfig {
source: DashboardAssetSource::Embedded,
};
let router = dashboard_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("Dashboard not embedded"));
assert!(text.contains("cargo xtask build-dashboard"));
Ok(())
}
}