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        if request_path.starts_with("/assets/") {
57            let relative = alias_static_path(request_path.trim_start_matches('/'));
58            if relative.is_empty() {
59                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
60                return Ok(ControlFlow::Continue(()));
61            }
62            let candidate = self.root.join(&relative);
63            if relative.contains("..") || !candidate.is_file() {
64                ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
65                return Ok(ControlFlow::Continue(()));
66            }
67            match tokio::fs::read(&candidate).await {
68                Ok(data) => {
69                    ctx.response_mut().set_status(HttpStatus::OK);
70                    ctx.response_mut()
71                        .set_header("content-type", mime_type(&candidate));
72                    ctx.response_mut().write_bytes(data).await?;
73                }
74                Err(_) => {
75                    ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
76                }
77            }
78            return Ok(ControlFlow::Continue(()));
79        }
80
81        let file_path = self.resolve_file(request_path);
82
83        match tokio::fs::read(&file_path).await {
84            Ok(data) => {
85                ctx.response_mut().set_status(HttpStatus::OK);
86                ctx.response_mut()
87                    .set_header("content-type", mime_type(&file_path));
88                ctx.response_mut().write_bytes(data).await?;
89            }
90            Err(_) => {
91                // File not found — try fallback to index.html for SPA routing
92                let index_path = self.root.join(&self.index);
93                match tokio::fs::read(&index_path).await {
94                    Ok(data) => {
95                        ctx.response_mut().set_status(HttpStatus::OK);
96                        ctx.response_mut().set_header("content-type", "text/html");
97                        ctx.response_mut().write_bytes(data).await?;
98                    }
99                    Err(_) => {
100                        // Neither file nor index.html exists — pass through
101                    }
102                }
103            }
104        }
105
106        Ok(ControlFlow::Continue(()))
107    }
108}
109
110impl SpaMiddleware {
111    /// Resolve a request path to a filesystem path, preventing traversal.
112    fn resolve_file(&self, request_path: &str) -> PathBuf {
113        let relative = alias_static_path(request_path.trim_start_matches('/'));
114        if relative.is_empty() {
115            return self.root.join(&self.index);
116        }
117
118        let candidate = self.root.join(&relative);
119
120        // Canonicalize to detect and prevent path traversal attacks.
121        // If canonicalization fails (file doesn't exist), do a manual
122        // check against the configured root.
123        match candidate.canonicalize() {
124            Ok(resolved) => {
125                let root_canonical = self
126                    .root
127                    .canonicalize()
128                    .unwrap_or_else(|_| self.root.clone());
129                if resolved.starts_with(&root_canonical) {
130                    resolved
131                } else {
132                    // Path traversal attempt — fall back to index.html
133                    self.root.join(&self.index)
134                }
135            }
136            Err(_) => {
137                // File doesn't exist; do a simple traversal check on the
138                // unresolved path before returning it for the caller to try.
139                if is_safe_subpath(&self.root, &candidate) {
140                    candidate
141                } else {
142                    self.root.join(&self.index)
143                }
144            }
145        }
146    }
147}
148
149/// Map vendor paths that include a redundant `dist/` segment to on-disk layout.
150///
151/// Vditor resolves assets as `{cdn}/dist/js/...` while our vendored tree keeps
152/// `js/`, `css/`, and bundle files at the package root.
153fn alias_static_path(relative: &str) -> String {
154    const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
155    if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
156        format!("assets/vendor/vditor-dist/{rest}")
157    } else {
158        relative.to_string()
159    }
160}
161
162/// Check that `candidate` is a sub-path of `root` without requiring the
163/// candidate to exist on disk (canonicalize requires the file exists).
164fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
165    // Normalize both paths by stripping "." and ".." components.
166    let normalized = normalize_path(candidate);
167    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
168
169    // If the candidate is absolute, check it starts with the absolute root.
170    if normalized.is_absolute() {
171        return normalized.starts_with(&root_abs);
172    }
173
174    // For relative candidates, resolve against the current dir and compare.
175    if let Ok(cwd) = std::env::current_dir() {
176        let abs_candidate = cwd.join(&normalized);
177        if let Ok(canon) = abs_candidate.canonicalize() {
178            return canon.starts_with(&root_abs);
179        }
180    }
181
182    true
183}
184
185/// Remove "." and ".." segments from a path without consulting the filesystem.
186fn normalize_path(path: &Path) -> PathBuf {
187    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
188    for component in path.components() {
189        match component {
190            std::path::Component::ParentDir => {
191                parts.pop();
192            }
193            std::path::Component::CurDir => {}
194            other => {
195                parts.push(other.as_os_str());
196            }
197        }
198    }
199    let mut result = PathBuf::new();
200    for part in parts {
201        result.push(part);
202    }
203    result
204}
205
206/// Detect MIME type from file extension.
207fn mime_type(path: &Path) -> &'static str {
208    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
209    match ext {
210        "html" | "htm" => "text/html",
211        "js" | "mjs" => "application/javascript",
212        "css" => "text/css",
213        "json" => "application/json",
214        "png" => "image/png",
215        "jpg" | "jpeg" => "image/jpeg",
216        "svg" => "image/svg+xml",
217        "ico" => "image/x-icon",
218        "wasm" => "application/wasm",
219        "woff" => "font/woff",
220        "woff2" => "font/woff2",
221        "ttf" => "font/ttf",
222        "eot" => "application/vnd.ms-fontobject",
223        "txt" => "text/plain",
224        "xml" => "application/xml",
225        "pdf" => "application/pdf",
226        "zip" => "application/zip",
227        _ => "application/octet-stream",
228    }
229}
230
231/// Resolve a SPA root path by first checking the application base directory
232/// (as resolved by `rust_webx_core::paths::app_base`), then as-is.
233///
234/// This mirrors the strategy used by `config::load_appsettings` so that
235/// `use_spa("wwwroot")` works whether the user runs from `demo/`, from
236/// the workspace root (`rust-webx/`), or from a deployment directory
237/// (exe alongside `wwwroot/`).
238fn resolve_spa_root(root: PathBuf) -> PathBuf {
239    // If the path is already absolute or exists, use it directly.
240    if root.is_absolute() || root.exists() {
241        return root;
242    }
243
244    // 应用基准目录(exe 同级 / cwd / 上溯统一由 app_base 处理)。
245    let candidate = rust_webx_core::paths::app_base().join(&root);
246    if candidate.exists() {
247        return candidate;
248    }
249
250    root
251}
252
253#[cfg(test)]
254mod tests {
255    use super::{alias_static_path, mime_type, normalize_path};
256    use std::path::Path;
257
258    #[test]
259    fn alias_static_path_strips_vditor_dist_segment() {
260        let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
261        assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
262    }
263
264    #[test]
265    fn mime_type_maps_common_extensions() {
266        assert_eq!(mime_type(Path::new("a.js")), "application/javascript");
267        assert_eq!(mime_type(Path::new("a.css")), "text/css");
268        assert_eq!(mime_type(Path::new("a.wasm")), "application/wasm");
269    }
270
271    #[test]
272    fn normalize_path_removes_dot_dot() {
273        let p = normalize_path(Path::new("a/../b/./c"));
274        assert_eq!(p, Path::new("b/c"));
275    }
276}