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