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("/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 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 }
102 }
103 }
104 }
105
106 Ok(ControlFlow::Continue(()))
107 }
108}
109
110impl SpaMiddleware {
111 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 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 self.root.join(&self.index)
134 }
135 }
136 Err(_) => {
137 if is_safe_subpath(&self.root, &candidate) {
140 candidate
141 } else {
142 self.root.join(&self.index)
143 }
144 }
145 }
146 }
147}
148
149fn 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
162fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
165 let normalized = normalize_path(candidate);
167 let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
168
169 if normalized.is_absolute() {
171 return normalized.starts_with(&root_abs);
172 }
173
174 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
185fn 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
206fn 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
231fn resolve_spa_root(root: PathBuf) -> PathBuf {
239 if root.is_absolute() || root.exists() {
241 return root;
242 }
243
244 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}