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'; 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'; 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    let html = include_str!("dashboard_spa.html");
39    let canonical = if let Some(idx) = html.find("</html>") {
40        &html[..idx + "</html>".len()]
41    } else {
42        html
43    };
44    let with_vars = canonical.replace("var BASE = '';", "var BASE = ''; var API_KEY = null;");
45    // Inject the nonce into every inline <script> tag.
46    with_vars.replace("<script>", &format!("<script nonce=\"{nonce}\">"))
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    const TEST_NONCE: &str = "test-nonce-value";
54
55    #[test]
56    fn dashboard_html_contains_title() {
57        let html = build_dashboard_html(None, TEST_NONCE);
58        assert!(html.contains("<title>Roboticus Dashboard</title>"));
59        assert!(html.contains("/api/health"));
60    }
61
62    #[test]
63    fn build_dashboard_html_contains_all_sections() {
64        let html = build_dashboard_html(None, TEST_NONCE);
65        assert!(html.contains("Roboticus"));
66        assert!(html.contains("Overview"));
67        assert!(html.contains("/api/sessions"));
68        assert!(html.contains("/api/memory/episodic"));
69        assert!(html.contains("/api/cron/jobs"));
70        assert!(html.contains("/api/stats/costs"));
71        assert!(html.contains("/api/skills"));
72        assert!(html.contains("/api/wallet/balance"));
73        assert!(html.contains("/api/breaker/status"));
74    }
75
76    #[test]
77    fn dashboard_html_contains_catalog_controls() {
78        let html = build_dashboard_html(None, TEST_NONCE);
79        assert!(html.contains("/api/skills/catalog"));
80        assert!(html.contains("/api/skills/catalog/install"));
81        assert!(html.contains("/api/skills/catalog/activate"));
82        assert!(html.contains("btn-catalog-install"));
83        assert!(html.contains("btn-catalog-install-activate"));
84        assert!(html.contains("cat-skill-check"));
85    }
86
87    #[test]
88    fn dashboard_html_without_key_has_api_health() {
89        let html = build_dashboard_html(None, TEST_NONCE);
90        assert!(html.contains("<title>Roboticus Dashboard</title>"));
91        assert!(html.contains("/api/health"));
92    }
93
94    #[test]
95    fn dashboard_never_injects_api_key() {
96        let html = build_dashboard_html(Some("test-dashboard-key"), TEST_NONCE);
97        assert!(
98            html.contains("API_KEY = null"),
99            "API key must never be embedded"
100        );
101    }
102
103    #[test]
104    fn dashboard_null_api_key_always() {
105        let html = build_dashboard_html(None, TEST_NONCE);
106        assert!(html.contains("API_KEY = null"));
107    }
108
109    #[test]
110    fn dashboard_html_contains_single_html_close_tag() {
111        let html = build_dashboard_html(None, TEST_NONCE);
112        assert_eq!(html.matches("</html>").count(), 1);
113    }
114
115    #[test]
116    fn dashboard_html_injects_nonce_into_script_tags() {
117        let html = build_dashboard_html(None, TEST_NONCE);
118        assert!(
119            html.contains(&format!("<script nonce=\"{TEST_NONCE}\">")),
120            "script tags must include nonce attribute"
121        );
122        assert!(
123            !html.contains("<script>"),
124            "no bare <script> tags should remain"
125        );
126    }
127
128    #[test]
129    fn dashboard_nonce_is_unique_per_call() {
130        let nonce_a = uuid::Uuid::new_v4().to_string();
131        let nonce_b = uuid::Uuid::new_v4().to_string();
132        assert_ne!(nonce_a, nonce_b);
133
134        let html_a = build_dashboard_html(None, &nonce_a);
135        let html_b = build_dashboard_html(None, &nonce_b);
136        assert!(html_a.contains(&nonce_a));
137        assert!(html_b.contains(&nonce_b));
138        assert!(!html_a.contains(&nonce_b));
139    }
140}