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