1use 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
17const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
22const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
23
24pub struct SpaMiddleware {
31 root: PathBuf,
32 index: String,
33}
34
35impl SpaMiddleware {
36 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 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 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 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 Ok(ControlFlow::Continue(()))
100 }
101}
102
103async 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 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 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 self.root.join(&self.index)
141 }
142 }
143 Err(_) => {
144 if is_safe_subpath(&self.root, &candidate) {
147 candidate
148 } else {
149 self.root.join(&self.index)
150 }
151 }
152 }
153 }
154}
155
156fn 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
169fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
172 let normalized = normalize_path(candidate);
174 let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
175
176 if normalized.is_absolute() {
178 return normalized.starts_with(&root_abs);
179 }
180
181 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
192fn 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
213fn resolve_spa_root(root: PathBuf) -> PathBuf {
221 if root.is_absolute() || root.exists() {
223 return root;
224 }
225
226 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 assert!("/api/docs".starts_with("/api/"));
257 assert!(!"/works/foo".starts_with("/api/"));
258 }
259}