Skip to main content

rust_webx_spa/
spa.rs

1//! SPA static file serving middleware.
2//!
3//! Serves files from a configured root directory.
4//! For SPA routing, non-file requests fall back to index.html.
5
6use rust_webx_core::error::Result;
7use rust_webx_core::http::{HttpStatus, IHttpContext};
8use rust_webx_core::middleware::IMiddleware;
9use std::ops::ControlFlow;
10use std::path::{Path, PathBuf};
11
12/// SPA static file middleware.
13///
14/// - Matches `/{filename}` against local filesystem
15/// - Serves files with auto-detected MIME types
16/// - Falls back to `index.html` for unknown paths (SPA routing)
17/// - Only handles GET requests; non-GET passes through silently
18pub struct SpaMiddleware {
19    root: PathBuf,
20    index: String,
21}
22
23impl SpaMiddleware {
24    /// Create a new SPA middleware with default index "index.html".
25    ///
26    /// The `root` path is resolved relative to the current working directory.
27    /// If the directory doesn't exist at that path, the middleware searches
28    /// upward through ancestor directories and their immediate subdirectories,
29    /// matching the strategy used by [`config::load_appsettings`].
30    pub fn new(root: impl Into<PathBuf>) -> Self {
31        Self {
32            root: resolve_spa_root(root.into()),
33            index: "index.html".to_string(),
34        }
35    }
36
37    /// Create a new SPA middleware with a custom index file name.
38    pub fn with_index(root: impl Into<PathBuf>, index: impl Into<String>) -> Self {
39        Self {
40            root: root.into(),
41            index: index.into(),
42        }
43    }
44}
45
46#[async_trait::async_trait]
47impl IMiddleware for SpaMiddleware {
48    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
49        let method = ctx.request().method().to_uppercase();
50        if method != "GET" {
51            return Ok(ControlFlow::Continue(()));
52        }
53
54        let request_path = ctx.request().path();
55
56        // API routes must reach the router — never serve index.html for /api/*.
57        if request_path.starts_with("/api/") {
58            return Ok(ControlFlow::Continue(()));
59        }
60
61        if request_path.starts_with("/assets/") {
62            let relative = alias_static_path(request_path.trim_start_matches('/'));
63            if relative.is_empty() {
64                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
65                return Ok(ControlFlow::Continue(()));
66            }
67            let candidate = self.root.join(&relative);
68            if relative.contains("..") || !candidate.is_file() {
69                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
70                return Ok(ControlFlow::Continue(()));
71            }
72            match tokio::fs::read(&candidate).await {
73                Ok(data) => {
74                    ctx.response_mut().set_status(HttpStatus::OK);
75                    ctx.response_mut()
76                        .set_header("content-type", mime_type(&candidate));
77                    ctx.response_mut().write_bytes(data).await?;
78                }
79                Err(_) => {
80                    ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
81                }
82            }
83            return Ok(ControlFlow::Continue(()));
84        }
85
86        let file_path = self.resolve_file(request_path);
87
88        match tokio::fs::read(&file_path).await {
89            Ok(data) => {
90                ctx.response_mut().set_status(HttpStatus::OK);
91                ctx.response_mut()
92                    .set_header("content-type", mime_type(&file_path));
93                ctx.response_mut().write_bytes(data).await?;
94            }
95            Err(_) => {
96                // File not found — try fallback to index.html for SPA routing
97                let index_path = self.root.join(&self.index);
98                match tokio::fs::read(&index_path).await {
99                    Ok(data) => {
100                        ctx.response_mut().set_status(HttpStatus::OK);
101                        ctx.response_mut().set_header("content-type", "text/html");
102                        ctx.response_mut().write_bytes(data).await?;
103                    }
104                    Err(_) => {
105                        // Neither file nor index.html exists — pass through
106                    }
107                }
108            }
109        }
110
111        Ok(ControlFlow::Continue(()))
112    }
113}
114
115impl SpaMiddleware {
116    /// Resolve a request path to a filesystem path, preventing traversal.
117    fn resolve_file(&self, request_path: &str) -> PathBuf {
118        let relative = alias_static_path(request_path.trim_start_matches('/'));
119        if relative.is_empty() {
120            return self.root.join(&self.index);
121        }
122
123        let candidate = self.root.join(&relative);
124
125        // Canonicalize to detect and prevent path traversal attacks.
126        // If canonicalization fails (file doesn't exist), do a manual
127        // check against the configured root.
128        match candidate.canonicalize() {
129            Ok(resolved) => {
130                let root_canonical = self
131                    .root
132                    .canonicalize()
133                    .unwrap_or_else(|_| self.root.clone());
134                if resolved.starts_with(&root_canonical) {
135                    resolved
136                } else {
137                    // Path traversal attempt — fall back to index.html
138                    self.root.join(&self.index)
139                }
140            }
141            Err(_) => {
142                // File doesn't exist; do a simple traversal check on the
143                // unresolved path before returning it for the caller to try.
144                if is_safe_subpath(&self.root, &candidate) {
145                    candidate
146                } else {
147                    self.root.join(&self.index)
148                }
149            }
150        }
151    }
152}
153
154/// Map vendor paths that include a redundant `dist/` segment to on-disk layout.
155///
156/// Vditor resolves assets as `{cdn}/dist/js/...` while our vendored tree keeps
157/// `js/`, `css/`, and bundle files at the package root.
158fn alias_static_path(relative: &str) -> String {
159    const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
160    if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
161        format!("assets/vendor/vditor-dist/{rest}")
162    } else {
163        relative.to_string()
164    }
165}
166
167/// Check that `candidate` is a sub-path of `root` without requiring the
168/// candidate to exist on disk (canonicalize requires the file exists).
169fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
170    // Normalize both paths by stripping "." and ".." components.
171    let normalized = normalize_path(candidate);
172    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
173
174    // If the candidate is absolute, check it starts with the absolute root.
175    if normalized.is_absolute() {
176        return normalized.starts_with(&root_abs);
177    }
178
179    // For relative candidates, resolve against the current dir and compare.
180    if let Ok(cwd) = std::env::current_dir() {
181        let abs_candidate = cwd.join(&normalized);
182        if let Ok(canon) = abs_candidate.canonicalize() {
183            return canon.starts_with(&root_abs);
184        }
185    }
186
187    true
188}
189
190/// Remove "." and ".." segments from a path without consulting the filesystem.
191fn normalize_path(path: &Path) -> PathBuf {
192    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
193    for component in path.components() {
194        match component {
195            std::path::Component::ParentDir => {
196                parts.pop();
197            }
198            std::path::Component::CurDir => {}
199            other => {
200                parts.push(other.as_os_str());
201            }
202        }
203    }
204    let mut result = PathBuf::new();
205    for part in parts {
206        result.push(part);
207    }
208    result
209}
210
211/// Detect MIME type from file extension.
212fn mime_type(path: &Path) -> &'static str {
213    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
214    match ext {
215        "html" | "htm" => "text/html",
216        "js" | "mjs" => "application/javascript",
217        "css" => "text/css",
218        "json" => "application/json",
219        "png" => "image/png",
220        "jpg" | "jpeg" => "image/jpeg",
221        "svg" => "image/svg+xml",
222        "ico" => "image/x-icon",
223        "wasm" => "application/wasm",
224        "woff" => "font/woff",
225        "woff2" => "font/woff2",
226        "ttf" => "font/ttf",
227        "eot" => "application/vnd.ms-fontobject",
228        "txt" => "text/plain",
229        "xml" => "application/xml",
230        "pdf" => "application/pdf",
231        "zip" => "application/zip",
232        _ => "application/octet-stream",
233    }
234}
235
236/// Resolve a SPA root path by first checking the application base directory
237/// (as resolved by `rust_webx_core::paths::app_base`), then as-is.
238///
239/// This mirrors the strategy used by `config::load_appsettings` so that
240/// `use_spa("wwwroot")` works whether the user runs from `demo/`, from
241/// the workspace root (`rust-webx/`), or from a deployment directory
242/// (exe alongside `wwwroot/`).
243fn resolve_spa_root(root: PathBuf) -> PathBuf {
244    // If the path is already absolute or exists, use it directly.
245    if root.is_absolute() || root.exists() {
246        return root;
247    }
248
249    // 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
250    let candidate = rust_webx_core::paths::app_base().join(&root);
251    if candidate.exists() {
252        return candidate;
253    }
254
255    root
256}
257
258#[cfg(test)]
259mod tests {
260    use super::{alias_static_path, mime_type, normalize_path};
261    use std::path::Path;
262
263    #[test]
264    fn alias_static_path_strips_vditor_dist_segment() {
265        let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
266        assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
267    }
268
269    #[test]
270    fn mime_type_maps_common_extensions() {
271        assert_eq!(mime_type(Path::new("a.js")), "application/javascript");
272        assert_eq!(mime_type(Path::new("a.css")), "text/css");
273        assert_eq!(mime_type(Path::new("a.wasm")), "application/wasm");
274    }
275
276    #[test]
277    fn normalize_path_removes_dot_dot() {
278        let p = normalize_path(Path::new("a/../b/./c"));
279        assert_eq!(p, Path::new("b/c"));
280    }
281
282    #[test]
283    fn skips_api_paths_for_spa_fallback() {
284        // API paths are passed through to the router; no static/index.html response.
285        // Full integration is covered by host tests; this documents the guard.
286        assert!("/api/docs".starts_with("/api/"));
287        assert!(!"/works/foo".starts_with("/api/"));
288    }
289}