prospero_api/dashboard_v2.rs
1//! Serve the embedded Dashboard v2 bundle (Dioxus/WASM — #97, epic #95).
2//!
3//! The bundle under `../dashboard-v2/` is a build artifact **committed to the
4//! repo** and regenerated by `scripts/build-dashboard.sh`. Committing it keeps
5//! the "one binary ships the UI" property — an ordinary `cargo build` needs no
6//! wasm toolchain — at the cost of a build artifact in git. CI reruns the build
7//! and diffs the tree, so a stale bundle fails the build rather than shipping.
8//!
9//! Assets are looked up in a table generated by `build.rs` (see that file for
10//! why the list can't be hardcoded). v2 is the default dashboard, served at
11//! both `/` and `/v2` (#191) — the latter kept permanently because the bundle's
12//! own asset URLs are absolute `/v2/...`. The deprecated v1 page now lives at
13//! `/v1` (see [`crate::dashboard`]).
14
15use axum::extract::Path;
16use axum::http::{StatusCode, header};
17use axum::response::{IntoResponse, Response};
18
19include!(concat!(env!("OUT_DIR"), "/dashboard_v2_assets.rs"));
20
21/// Content-Security-Policy for the v2 page.
22///
23/// The bundle is fully self-contained — no CDN, font, remote image, or inline
24/// handler — so every fetch directive is denied except same-origin.
25/// `'wasm-unsafe-eval'` is the one grant WebAssembly instantiation requires;
26/// without it the module refuses to start.
27pub const CSP: &str = "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; \
28style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; \
29form-action 'none'";
30
31/// Look up one bundle file by its path relative to the bundle root.
32fn lookup(path: &str) -> Option<(&'static str, &'static [u8])> {
33 ASSETS
34 .iter()
35 .find(|(p, _, _)| *p == path)
36 .map(|(_, ct, bytes)| (*ct, *bytes))
37}
38
39fn serve(path: &str) -> Response {
40 match lookup(path) {
41 Some((content_type, bytes)) => {
42 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
43 }
44 None => StatusCode::NOT_FOUND.into_response(),
45 }
46}
47
48/// `GET /v2` — the dashboard v2 page.
49pub async fn index() -> Response {
50 match lookup("index.html") {
51 Some((content_type, bytes)) => (
52 [
53 (header::CONTENT_TYPE, content_type),
54 (header::CONTENT_SECURITY_POLICY, CSP),
55 ],
56 bytes,
57 )
58 .into_response(),
59 None => StatusCode::NOT_FOUND.into_response(),
60 }
61}
62
63/// `GET /v2/{*path}` — any other file in the bundle (JS glue, wasm, CSS, and
64/// the wasm-bindgen `snippets/` tree).
65///
66/// Lookup is an exact match against a static table, so a traversal attempt like
67/// `../Cargo.toml` simply misses and 404s — there is no filesystem access here.
68pub async fn asset(Path(path): Path<String>) -> Response {
69 serve(&path)
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn the_bundle_contains_its_entrypoints() {
78 for required in [
79 "index.html",
80 "app.css",
81 "prospero-dashboard.js",
82 "prospero-dashboard_bg.wasm",
83 ] {
84 assert!(
85 lookup(required).is_some(),
86 "bundle is missing {required} — run scripts/build-dashboard.sh"
87 );
88 }
89 }
90
91 #[test]
92 fn wasm_is_served_as_application_wasm() {
93 // instantiateStreaming rejects every other content type.
94 let (ct, bytes) = lookup("prospero-dashboard_bg.wasm").unwrap();
95 assert_eq!(ct, "application/wasm");
96 assert_eq!(&bytes[..4], b"\0asm", "not a wasm module");
97 }
98
99 #[test]
100 fn unknown_paths_and_traversal_attempts_miss() {
101 assert!(lookup("nope.js").is_none());
102 assert!(lookup("../Cargo.toml").is_none());
103 }
104}