1use 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
12pub struct SpaMiddleware {
19 root: PathBuf,
20 index: String,
21}
22
23impl SpaMiddleware {
24 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 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("/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 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 }
107 }
108 }
109 }
110
111 Ok(ControlFlow::Continue(()))
112 }
113}
114
115impl SpaMiddleware {
116 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 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 self.root.join(&self.index)
139 }
140 }
141 Err(_) => {
142 if is_safe_subpath(&self.root, &candidate) {
145 candidate
146 } else {
147 self.root.join(&self.index)
148 }
149 }
150 }
151 }
152}
153
154fn 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
167fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
170 let normalized = normalize_path(candidate);
172 let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
173
174 if normalized.is_absolute() {
176 return normalized.starts_with(&root_abs);
177 }
178
179 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
190fn 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
211fn 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
236fn resolve_spa_root(root: PathBuf) -> PathBuf {
244 if root.is_absolute() || root.exists() {
246 return root;
247 }
248
249 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 assert!("/api/docs".starts_with("/api/"));
287 assert!(!"/works/foo".starts_with("/api/"));
288 }
289}