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