Skip to main content

roboticus_api/
dashboard.rs

1use axum::extract::State;
2use axum::response::{Html, IntoResponse, Response};
3
4use crate::api::AppState;
5
6pub async fn dashboard_handler(State(state): State<AppState>) -> Response {
7    let config = state.config.read().await;
8    let key = config.server.api_key.as_deref();
9
10    // Generate a cryptographically random nonce for this request.
11    let nonce = uuid::Uuid::new_v4().to_string();
12    let html = build_dashboard_html(key, &nonce);
13
14    let csp = format!(
15        "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'"
16    );
17
18    let mut response = Html(html).into_response();
19    if let Ok(csp_value) = axum::http::HeaderValue::from_str(&csp) {
20        response.headers_mut().insert(
21            axum::http::header::HeaderName::from_static("content-security-policy"),
22            csp_value,
23        );
24    } else {
25        // Fallback CSP without nonce — still safer than no CSP at all.
26        tracing::error!("CSP header contained non-ASCII; applying fallback CSP");
27        response.headers_mut().insert(
28            axum::http::header::HeaderName::from_static("content-security-policy"),
29            axum::http::HeaderValue::from_static(
30                "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'"
31            ),
32        );
33    }
34    response
35}
36
37pub fn build_dashboard_html(_api_key: Option<&str>, nonce: &str) -> String {
38    // ── Compile-time assembly from modular files ─────────────────
39    // Each file is embedded via include_str!() and concatenated into
40    // the template's {CSS} and {JS} placeholders. The browser still
41    // receives a single HTML document.
42
43    let css = [
44        include_str!("dashboard/css/variables.css"),
45        include_str!("dashboard/css/layout.css"),
46        include_str!("dashboard/css/components.css"),
47        include_str!("dashboard/css/animations.css"),
48    ]
49    .join("\n");
50
51    let js = [
52        include_str!("dashboard/js/utils.js"),
53        include_str!("dashboard/js/api.js"),
54        include_str!("dashboard/js/hints.js"),
55        include_str!("dashboard/js/charts.js"),
56        include_str!("dashboard/js/app-core.js"),
57        include_str!("dashboard/js/pages/overview.js"),
58        include_str!("dashboard/js/pages/sessions.js"),
59        include_str!("dashboard/js/pages/memory.js"),
60        include_str!("dashboard/js/pages/skills.js"),
61        include_str!("dashboard/js/pages/scheduler.js"),
62        include_str!("dashboard/js/pages/integrations.js"),
63        include_str!("dashboard/js/pages/metrics.js"),
64        include_str!("dashboard/js/pages/efficiency.js"),
65        include_str!("dashboard/js/pages/agents.js"),
66        include_str!("dashboard/js/pages/context.js"),
67        include_str!("dashboard/js/pages/settings.js"),
68        include_str!("dashboard/js/pages/wallet.js"),
69        include_str!("dashboard/js/pages/workspace.js"),
70        include_str!("dashboard/js/themes.js"),
71        include_str!("dashboard/js/help-modal.js"),
72        include_str!("dashboard/js/events.js"),
73        include_str!("dashboard/js/websocket.js"),
74    ]
75    .join("\n");
76
77    let template = include_str!("dashboard/template.html");
78    let assembled = template
79        .replace("/* {CSS} */", &css)
80        .replace("/* {JS} */", &js);
81
82    let with_vars = assembled.replace("var BASE = '';", "var BASE = ''; var API_KEY = null;");
83    // Inject the nonce into every inline <script> tag.
84    with_vars.replace("<script>", &format!("<script nonce=\"{nonce}\">"))
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    const TEST_NONCE: &str = "test-nonce-value";
92
93    #[test]
94    fn dashboard_html_contains_title() {
95        let html = build_dashboard_html(None, TEST_NONCE);
96        assert!(html.contains("<title>Roboticus Dashboard</title>"));
97        assert!(html.contains("/api/health"));
98    }
99
100    #[test]
101    fn build_dashboard_html_contains_all_sections() {
102        let html = build_dashboard_html(None, TEST_NONCE);
103        assert!(html.contains("Roboticus"));
104        assert!(html.contains("Overview"));
105        assert!(html.contains("/api/sessions"));
106        assert!(html.contains("/api/memory/episodic"));
107        assert!(html.contains("/api/cron/jobs"));
108        assert!(html.contains("/api/stats/costs"));
109        assert!(html.contains("/api/skills"));
110        assert!(html.contains("/api/wallet/balance"));
111        assert!(html.contains("/api/breaker/status"));
112    }
113
114    #[test]
115    fn dashboard_html_contains_catalog_controls() {
116        let html = build_dashboard_html(None, TEST_NONCE);
117        assert!(html.contains("/api/skills/catalog"));
118        assert!(html.contains("/api/skills/catalog/install"));
119        assert!(html.contains("/api/skills/catalog/activate"));
120        assert!(html.contains("btn-catalog-install"));
121        assert!(html.contains("btn-catalog-install-activate"));
122        assert!(html.contains("cat-skill-check"));
123    }
124
125    #[test]
126    fn dashboard_html_without_key_has_api_health() {
127        let html = build_dashboard_html(None, TEST_NONCE);
128        assert!(html.contains("<title>Roboticus Dashboard</title>"));
129        assert!(html.contains("/api/health"));
130    }
131
132    #[test]
133    fn dashboard_never_injects_api_key() {
134        let html = build_dashboard_html(Some("test-dashboard-key"), TEST_NONCE);
135        assert!(
136            html.contains("API_KEY = null"),
137            "API key must never be embedded"
138        );
139    }
140
141    #[test]
142    fn dashboard_null_api_key_always() {
143        let html = build_dashboard_html(None, TEST_NONCE);
144        assert!(html.contains("API_KEY = null"));
145    }
146
147    #[test]
148    fn dashboard_html_contains_single_html_close_tag() {
149        let html = build_dashboard_html(None, TEST_NONCE);
150        assert_eq!(html.matches("</html>").count(), 1);
151    }
152
153    #[test]
154    fn dashboard_html_injects_nonce_into_script_tags() {
155        let html = build_dashboard_html(None, TEST_NONCE);
156        assert!(
157            html.contains(&format!("<script nonce=\"{TEST_NONCE}\">")),
158            "script tags must include nonce attribute"
159        );
160        assert!(
161            !html.contains("<script>"),
162            "no bare <script> tags should remain"
163        );
164    }
165
166    #[test]
167    fn dashboard_nonce_is_unique_per_call() {
168        let nonce_a = uuid::Uuid::new_v4().to_string();
169        let nonce_b = uuid::Uuid::new_v4().to_string();
170        assert_ne!(nonce_a, nonce_b);
171
172        let html_a = build_dashboard_html(None, &nonce_a);
173        let html_b = build_dashboard_html(None, &nonce_b);
174        assert!(html_a.contains(&nonce_a));
175        assert!(html_b.contains(&nonce_b));
176        assert!(!html_a.contains(&nonce_b));
177    }
178}