use axum::extract::State;
use axum::response::{Html, IntoResponse, Response};
use crate::api::AppState;
pub async fn dashboard_handler(State(state): State<AppState>) -> Response {
let config = state.config.read().await;
let key = config.server.api_key.as_deref();
let nonce = uuid::Uuid::new_v4().to_string();
let html = build_dashboard_html(key, &nonce);
let csp = format!(
"default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
);
let mut response = Html(html).into_response();
if let Ok(csp_value) = axum::http::HeaderValue::from_str(&csp) {
response.headers_mut().insert(
axum::http::header::HeaderName::from_static("content-security-policy"),
csp_value,
);
} else {
tracing::error!("CSP header contained non-ASCII; applying fallback CSP");
response.headers_mut().insert(
axum::http::header::HeaderName::from_static("content-security-policy"),
axum::http::HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
),
);
}
response
}
pub fn build_dashboard_html(_api_key: Option<&str>, nonce: &str) -> String {
let css = [
include_str!("dashboard/css/variables.css"),
include_str!("dashboard/css/layout.css"),
include_str!("dashboard/css/components.css"),
include_str!("dashboard/css/animations.css"),
]
.join("\n");
let js = [
include_str!("dashboard/js/utils.js"),
include_str!("dashboard/js/api.js"),
include_str!("dashboard/js/hints.js"),
include_str!("dashboard/js/charts.js"),
include_str!("dashboard/js/app-core.js"),
include_str!("dashboard/js/pages/overview.js"),
include_str!("dashboard/js/pages/sessions.js"),
include_str!("dashboard/js/pages/memory.js"),
include_str!("dashboard/js/pages/skills.js"),
include_str!("dashboard/js/pages/scheduler.js"),
include_str!("dashboard/js/pages/integrations.js"),
include_str!("dashboard/js/pages/metrics.js"),
include_str!("dashboard/js/pages/efficiency.js"),
include_str!("dashboard/js/pages/agents.js"),
include_str!("dashboard/js/pages/context.js"),
include_str!("dashboard/js/pages/settings.js"),
include_str!("dashboard/js/pages/wallet.js"),
include_str!("dashboard/js/pages/workspace.js"),
include_str!("dashboard/js/themes.js"),
include_str!("dashboard/js/help-modal.js"),
include_str!("dashboard/js/events.js"),
include_str!("dashboard/js/websocket.js"),
]
.join("\n");
let template = include_str!("dashboard/template.html");
let assembled = template
.replace("/* {CSS} */", &css)
.replace("/* {JS} */", &js);
let with_vars = assembled.replace("var BASE = '';", "var BASE = ''; var API_KEY = null;");
with_vars.replace("<script>", &format!("<script nonce=\"{nonce}\">"))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_NONCE: &str = "test-nonce-value";
#[test]
fn dashboard_html_contains_title() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(html.contains("<title>Roboticus Dashboard</title>"));
assert!(html.contains("/api/health"));
}
#[test]
fn build_dashboard_html_contains_all_sections() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(html.contains("Roboticus"));
assert!(html.contains("Overview"));
assert!(html.contains("/api/sessions"));
assert!(html.contains("/api/memory/episodic"));
assert!(html.contains("/api/cron/jobs"));
assert!(html.contains("/api/stats/costs"));
assert!(html.contains("/api/skills"));
assert!(html.contains("/api/wallet/balance"));
assert!(html.contains("/api/breaker/status"));
}
#[test]
fn dashboard_html_contains_catalog_controls() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(html.contains("/api/skills/catalog"));
assert!(html.contains("/api/skills/catalog/install"));
assert!(html.contains("/api/skills/catalog/activate"));
assert!(html.contains("btn-catalog-install"));
assert!(html.contains("btn-catalog-install-activate"));
assert!(html.contains("cat-skill-check"));
}
#[test]
fn dashboard_html_without_key_has_api_health() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(html.contains("<title>Roboticus Dashboard</title>"));
assert!(html.contains("/api/health"));
}
#[test]
fn dashboard_never_injects_api_key() {
let html = build_dashboard_html(Some("test-dashboard-key"), TEST_NONCE);
assert!(
html.contains("API_KEY = null"),
"API key must never be embedded"
);
}
#[test]
fn dashboard_null_api_key_always() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(html.contains("API_KEY = null"));
}
#[test]
fn dashboard_html_contains_single_html_close_tag() {
let html = build_dashboard_html(None, TEST_NONCE);
assert_eq!(html.matches("</html>").count(), 1);
}
#[test]
fn dashboard_html_injects_nonce_into_script_tags() {
let html = build_dashboard_html(None, TEST_NONCE);
assert!(
html.contains(&format!("<script nonce=\"{TEST_NONCE}\">")),
"script tags must include nonce attribute"
);
assert!(
!html.contains("<script>"),
"no bare <script> tags should remain"
);
}
#[test]
fn dashboard_nonce_is_unique_per_call() {
let nonce_a = uuid::Uuid::new_v4().to_string();
let nonce_b = uuid::Uuid::new_v4().to_string();
assert_ne!(nonce_a, nonce_b);
let html_a = build_dashboard_html(None, &nonce_a);
let html_b = build_dashboard_html(None, &nonce_b);
assert!(html_a.contains(&nonce_a));
assert!(html_b.contains(&nonce_b));
assert!(!html_a.contains(&nonce_b));
}
}