Skip to main content

zeroclaw_gateway/
static_files.rs

1//! Static file serving for the web dashboard.
2//!
3//! Serves the compiled `web/dist/` directory from the filesystem at runtime.
4//! The directory path is configured via `gateway.web_dist_dir`.
5
6use axum::{
7    extract::State,
8    http::{StatusCode, Uri, header},
9    response::{IntoResponse, Response},
10};
11use std::path::PathBuf;
12
13use super::AppState;
14
15#[cfg(feature = "embedded-web")]
16use include_dir::{Dir, include_dir};
17
18#[cfg(feature = "embedded-web")]
19static EMBEDDED_WEB_DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist");
20
21/// Serve static files from `/_app/*` path
22pub async fn handle_static(State(state): State<AppState>, uri: Uri) -> Response {
23    let path = uri
24        .path()
25        .strip_prefix("/_app/")
26        .unwrap_or(uri.path())
27        .trim_start_matches('/');
28
29    #[cfg(feature = "embedded-web")]
30    if let Some(resp) = serve_embedded_file(path) {
31        return resp;
32    }
33
34    serve_fs_file(state.web_dist_dir.as_ref(), path).await
35}
36
37/// SPA fallback: serve index.html for any non-API, non-static GET request.
38/// Injects `window.__ZEROCLAW_BASE__` so the frontend knows the path prefix.
39pub async fn handle_spa_fallback(State(state): State<AppState>) -> Response {
40    let Some(bytes) = load_index_html_bytes(state.web_dist_dir.as_ref()).await else {
41        return (
42            StatusCode::SERVICE_UNAVAILABLE,
43            "Web dashboard not available. Build the frontend with `cargo web build` \
44             (the supported entry point — it generates the TS API client and runs \
45             the Vite production build) and point gateway.web_dist_dir at the \
46             resulting web/dist. The daemon's API endpoints remain reachable \
47             independently of the dashboard.",
48        )
49            .into_response();
50    };
51
52    let html = String::from_utf8_lossy(&bytes);
53
54    // Inject path prefix for the SPA and rewrite asset paths in the HTML
55    let html = if state.path_prefix.is_empty() {
56        html.into_owned()
57    } else {
58        let pfx = &state.path_prefix;
59        // JSON-encode the prefix to safely embed in a <script> block
60        let json_pfx = serde_json::to_string(pfx).unwrap_or_else(|_| "\"\"".to_string());
61        let script = format!("<script>window.__ZEROCLAW_BASE__={json_pfx};</script>");
62        // Rewrite absolute /_app/ references so the browser requests {prefix}/_app/...
63        html.replace("/_app/", &format!("{pfx}/_app/"))
64            .replace("<head>", &format!("<head>{script}"))
65    };
66
67    (
68        StatusCode::OK,
69        [
70            (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
71            (header::CACHE_CONTROL, "no-cache".to_string()),
72        ],
73        html,
74    )
75        .into_response()
76}
77
78async fn load_index_html_bytes(dist_dir: Option<&PathBuf>) -> Option<Vec<u8>> {
79    #[cfg(feature = "embedded-web")]
80    if let Some(file) = EMBEDDED_WEB_DIST.get_file("index.html") {
81        return Some(file.contents().to_vec());
82    }
83
84    let dir = dist_dir?;
85    let index_path = dir.join("index.html");
86    tokio::fs::read(&index_path).await.ok()
87}
88
89async fn serve_fs_file(dist_dir: Option<&PathBuf>, path: &str) -> Response {
90    let Some(dir) = dist_dir else {
91        return (StatusCode::NOT_FOUND, "Not found").into_response();
92    };
93
94    // Sanitize: reject path traversal attempts
95    if path.contains("..") {
96        return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
97    }
98
99    let file_path = dir.join(path);
100
101    match tokio::fs::read(&file_path).await {
102        Ok(content) => {
103            let mime = mime_guess::from_path(path)
104                .first_or_octet_stream()
105                .to_string();
106
107            (
108                StatusCode::OK,
109                [
110                    (header::CONTENT_TYPE, mime),
111                    (
112                        header::CACHE_CONTROL,
113                        if path.contains("assets/") {
114                            // Hashed filenames — immutable cache
115                            "public, max-age=31536000, immutable".to_string()
116                        } else {
117                            // index.html etc — no cache
118                            "no-cache".to_string()
119                        },
120                    ),
121                ],
122                content,
123            )
124                .into_response()
125        }
126        Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
127    }
128}
129
130#[cfg(feature = "embedded-web")]
131fn serve_embedded_file(path: &str) -> Option<Response> {
132    if path.contains("..") {
133        return Some((StatusCode::BAD_REQUEST, "Invalid path").into_response());
134    }
135
136    let file = EMBEDDED_WEB_DIST.get_file(path)?;
137    let mime = mime_guess::from_path(path)
138        .first_or_octet_stream()
139        .to_string();
140    let cache = if path.contains("assets/") {
141        "public, max-age=31536000, immutable".to_string()
142    } else {
143        "no-cache".to_string()
144    };
145
146    Some(
147        (
148            StatusCode::OK,
149            [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
150            file.contents().to_vec(),
151        )
152            .into_response(),
153    )
154}