1use std::collections::HashMap;
18use std::ops::ControlFlow;
19use std::path::{Path, PathBuf};
20use std::sync::{Mutex, OnceLock};
21use webx_core::error::Result;
22use webx_core::http::{FileBody, HttpStatus, IHttpContext};
23use webx_core::middleware::IMiddleware;
24
25use crate::embed::{EmbeddedAsset, EmbeddedAssets};
26
27const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
32const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
33
34const VARY: &str = "accept-encoding";
37
38pub const EMBED_ENV: &str = "WEBX_EMBED";
44
45#[derive(Debug, Clone)]
50pub enum SpaSource {
51 Disk(String),
53 Embedded {
56 assets: EmbeddedAssets,
58 overlay_root: String,
60 },
61}
62
63impl SpaSource {
64 pub fn disk_root(&self) -> &str {
66 match self {
67 SpaSource::Disk(root) => root,
68 SpaSource::Embedded { overlay_root, .. } => overlay_root,
69 }
70 }
71
72 pub fn embedded(&self) -> Option<&EmbeddedAssets> {
74 match self {
75 SpaSource::Disk(_) => None,
76 SpaSource::Embedded { assets, .. } => Some(assets),
77 }
78 }
79
80 pub fn with_overlay_root(self, dir: impl Into<String>) -> Self {
85 let dir = dir.into();
86 match self {
87 SpaSource::Disk(_) => SpaSource::Disk(dir),
88 SpaSource::Embedded { assets, .. } => SpaSource::Embedded {
89 assets,
90 overlay_root: dir,
91 },
92 }
93 }
94}
95
96impl From<&str> for SpaSource {
97 fn from(root: &str) -> Self {
98 SpaSource::Disk(root.to_string())
99 }
100}
101
102impl From<String> for SpaSource {
103 fn from(root: String) -> Self {
104 SpaSource::Disk(root)
105 }
106}
107
108impl From<PathBuf> for SpaSource {
109 fn from(root: PathBuf) -> Self {
110 SpaSource::Disk(root.to_string_lossy().into_owned())
111 }
112}
113
114impl From<&Path> for SpaSource {
115 fn from(root: &Path) -> Self {
116 SpaSource::Disk(root.to_string_lossy().into_owned())
117 }
118}
119
120impl From<&String> for SpaSource {
121 fn from(root: &String) -> Self {
122 SpaSource::Disk(root.clone())
123 }
124}
125
126impl From<EmbeddedAssets> for SpaSource {
127 fn from(assets: EmbeddedAssets) -> Self {
128 SpaSource::Embedded {
129 overlay_root: assets.root().to_string(),
130 assets,
131 }
132 }
133}
134
135pub struct SpaMiddleware {
142 root: PathBuf,
143 index: String,
144 embedded: Option<EmbeddedAssets>,
145 embed_enabled: bool,
147}
148
149impl SpaMiddleware {
150 pub fn new(source: impl Into<SpaSource>) -> Self {
157 Self::build(source.into(), "index.html".to_string())
158 }
159
160 pub fn with_index(source: impl Into<SpaSource>, index: impl Into<String>) -> Self {
162 Self::build(source.into(), index.into())
163 }
164
165 pub fn from_source(source: SpaSource) -> Self {
167 Self::build(source, "index.html".to_string())
168 }
169
170 fn build(source: SpaSource, index: String) -> Self {
171 let embedded = source.embedded().copied();
172 if let Some(assets) = embedded {
173 assets.debug_assert_sorted();
174 }
175 Self {
176 root: resolve_spa_root(PathBuf::from(source.disk_root())),
177 index,
178 embedded,
179 embed_enabled: embedded.is_some() && embed_enabled_from_env(),
180 }
181 }
182
183 pub fn resolved_root(&self) -> &Path {
185 &self.root
186 }
187
188 pub fn embedded(&self) -> Option<&EmbeddedAssets> {
192 self.embedded.as_ref()
193 }
194
195 fn embedded_asset(&self, key: &str) -> Option<&'static EmbeddedAsset> {
197 if !self.embed_enabled {
198 return None;
199 }
200 self.embedded.as_ref()?.get(key)
201 }
202
203 fn embedded_key(&self, request_path: &str) -> Option<String> {
208 let relative = alias_static_path(request_path.trim_start_matches('/'));
209 if relative.contains("..") {
210 return None;
211 }
212 Some(if relative.is_empty() {
213 self.index.clone()
214 } else {
215 relative
216 })
217 }
218}
219
220#[async_trait::async_trait]
221impl IMiddleware for SpaMiddleware {
222 async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
223 let method = ctx.request().method().to_uppercase();
224 if method != "GET" && method != "HEAD" {
225 return Ok(ControlFlow::Continue(()));
226 }
227
228 let request_path = ctx.request().path();
229
230 if request_path.starts_with("/api/") {
232 return Ok(ControlFlow::Continue(()));
233 }
234
235 if request_path.starts_with("/assets/") {
236 let relative = alias_static_path(request_path.trim_start_matches('/'));
237 if relative.is_empty() || relative.contains("..") {
238 ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
239 return Ok(ControlFlow::Continue(()));
240 }
241 let candidate = self.root.join(&relative);
242 if candidate.is_file() {
243 return serve_file(ctx, &candidate, IMMUTABLE_CACHE).await;
244 }
245 if let Some(asset) = self.embedded_asset(&relative) {
246 return serve_embedded(ctx, asset, IMMUTABLE_CACHE).await;
247 }
248 ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
249 return Ok(ControlFlow::Continue(()));
250 }
251
252 if let Some(file_path) = self.resolve_file(request_path) {
256 if file_path.is_file() {
257 return serve_file(ctx, &file_path, REVALIDATE_CACHE).await;
258 }
259 }
260 if let Some(key) = self.embedded_key(request_path) {
261 if let Some(asset) = self.embedded_asset(&key) {
262 return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
263 }
264 }
265
266 let index_path = self.root.join(&self.index);
268 if index_path.is_file() {
269 return serve_file(ctx, &index_path, REVALIDATE_CACHE).await;
270 }
271 if let Some(asset) = self.embedded_asset(&self.index) {
272 return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
273 }
274
275 Ok(ControlFlow::Continue(()))
277 }
278}
279
280async fn serve_file(
282 ctx: &mut dyn IHttpContext,
283 path: &Path,
284 cache_control: &str,
285) -> Result<ControlFlow<()>> {
286 ctx.response_mut().set_status(HttpStatus::OK);
287 ctx.response_mut()
288 .set_header("cache-control", cache_control);
289 ctx.response_mut()
290 .write_body(FileBody::path(path).into())
291 .await?;
292 Ok(ControlFlow::Continue(()))
293}
294
295async fn serve_embedded(
307 ctx: &mut dyn IHttpContext,
308 asset: &'static EmbeddedAsset,
309 cache_control: &str,
310) -> Result<ControlFlow<()>> {
311 ctx.response_mut().set_status(HttpStatus::OK);
312 ctx.response_mut()
313 .set_header("cache-control", cache_control);
314
315 let token = asset.encoding.token();
316 if let Some(token) = token {
317 ctx.response_mut().set_header("vary", VARY);
321 if accepts_encoding(ctx, token) {
322 return send_embedded(ctx, asset, asset.bytes, Some(token)).await;
323 }
324 if let Some(decoded) = decoded_once(asset) {
325 return send_embedded(ctx, asset, decoded, None).await;
326 }
327 }
328 send_embedded(ctx, asset, asset.bytes, token).await
331}
332
333async fn send_embedded(
338 ctx: &mut dyn IHttpContext,
339 asset: &'static EmbeddedAsset,
340 bytes: &'static [u8],
341 token: Option<&'static str>,
342) -> Result<ControlFlow<()>> {
343 if let Some(token) = token {
344 ctx.response_mut().set_header("content-encoding", token);
345 }
346 ctx.response_mut()
347 .write_body(
348 FileBody::static_bytes(bytes)
349 .content_type(asset.content_type)
350 .entity_tag(asset.etag_for(token.is_some()))
351 .into(),
352 )
353 .await?;
354 Ok(ControlFlow::Continue(()))
355}
356
357fn accepts_encoding(ctx: &dyn IHttpContext, token: &str) -> bool {
359 ctx.request()
360 .header("accept-encoding")
361 .is_some_and(|value| header_allows(value, token))
362}
363
364fn header_allows(value: &str, token: &str) -> bool {
371 let mut wildcard = None;
372 for entry in value.split(',') {
373 let mut params = entry.split(';');
374 let name = params.next().unwrap_or("").trim();
375 let quality = params
377 .filter_map(|param| param.trim().strip_prefix("q="))
378 .find_map(|quality| quality.trim().parse::<f32>().ok())
379 .unwrap_or(1.0);
380
381 if name.eq_ignore_ascii_case(token) {
382 return quality > 0.0;
383 }
384 if name == "*" {
385 wildcard = Some(quality);
386 }
387 }
388 wildcard.is_some_and(|quality| quality > 0.0)
389}
390
391pub(crate) fn decoded_once(asset: &'static EmbeddedAsset) -> Option<&'static [u8]> {
398 static DECODED: OnceLock<Mutex<HashMap<&'static str, &'static [u8]>>> = OnceLock::new();
399
400 let cache = DECODED.get_or_init(|| Mutex::new(HashMap::new()));
401 let mut cache = cache.lock().ok()?;
402 if let Some(bytes) = cache.get(asset.path) {
403 return Some(bytes);
404 }
405 let bytes: &'static [u8] = Box::leak(asset.decode()?.into_boxed_slice());
406 cache.insert(asset.path, bytes);
407 Some(bytes)
408}
409
410impl SpaMiddleware {
411 fn resolve_file(&self, request_path: &str) -> Option<PathBuf> {
421 let relative = alias_static_path(request_path.trim_start_matches('/'));
422 if relative.is_empty() {
423 return Some(self.root.join(&self.index));
424 }
425
426 let candidate = self.root.join(&relative);
427
428 if !is_safe_subpath(&self.root, &candidate) {
430 return None;
431 }
432
433 match candidate.canonicalize() {
435 Ok(resolved) if is_canonical_subpath(&self.root, &resolved) => Some(resolved),
436 Ok(_) => None,
437 Err(_) => Some(candidate),
438 }
439 }
440}
441
442fn alias_static_path(relative: &str) -> String {
447 const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
448 if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
449 format!("assets/vendor/vditor-dist/{rest}")
450 } else {
451 relative.to_string()
452 }
453}
454
455fn embed_enabled_from_env() -> bool {
457 embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
458}
459
460fn embed_enabled(value: Option<&str>) -> bool {
463 match value {
464 Some(value) => !matches!(
465 value.trim().to_ascii_lowercase().as_str(),
466 "off" | "0" | "false" | "no"
467 ),
468 None => true,
469 }
470}
471
472fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
482 let root_abs = normalize_path(&absolute(root));
483 let candidate_abs = normalize_path(&absolute(candidate));
484 candidate_abs.starts_with(&root_abs)
485}
486
487fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
490 match root.canonicalize() {
491 Ok(root) => resolved.starts_with(&root),
492 Err(_) => is_safe_subpath(root, resolved),
494 }
495}
496
497fn absolute(path: &Path) -> PathBuf {
500 if path.is_absolute() {
501 return path.to_path_buf();
502 }
503 match std::env::current_dir() {
504 Ok(cwd) => cwd.join(path),
505 Err(_) => path.to_path_buf(),
506 }
507}
508
509fn normalize_path(path: &Path) -> PathBuf {
511 let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
512 for component in path.components() {
513 match component {
514 std::path::Component::ParentDir => {
515 parts.pop();
516 }
517 std::path::Component::CurDir => {}
518 other => {
519 parts.push(other.as_os_str());
520 }
521 }
522 }
523 let mut result = PathBuf::new();
524 for part in parts {
525 result.push(part);
526 }
527 result
528}
529
530fn resolve_spa_root(root: PathBuf) -> PathBuf {
538 if root.is_absolute() || root.exists() {
540 return root;
541 }
542
543 let candidate = webx_core::paths::app_base().join(&root);
545 if candidate.exists() {
546 return candidate;
547 }
548
549 root
550}
551
552#[cfg(test)]
553mod tests {
554 use super::{alias_static_path, header_allows, normalize_path, SpaSource};
555 use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
556 use std::path::Path;
557
558 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
559 path: "index.html",
560 bytes: b"index",
561 raw_len: 5,
562 encoding: ContentEncoding::Identity,
563 content_type: "text/html",
564 etag: "\"sha256-1\"",
565 }];
566
567 fn embedded() -> SpaSource {
568 SpaSource::Embedded {
569 assets: EmbeddedAssets::new("wwwroot", FILES),
570 overlay_root: "wwwroot".to_string(),
571 }
572 }
573
574 #[test]
575 fn alias_static_path_strips_vditor_dist_segment() {
576 let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
577 assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
578 }
579
580 #[test]
581 fn normalize_path_removes_dot_dot() {
582 let p = normalize_path(Path::new("a/../b/./c"));
583 assert_eq!(p, Path::new("b/c"));
584 }
585
586 #[test]
594 fn missing_file_resolves_to_itself_not_the_index() {
595 let dir = tempfile::tempdir().unwrap();
596 std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
597 let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
598 let mw = super::SpaMiddleware::from_source(source);
599
600 let resolved = mw.resolve_file("/app.js").expect("inside the root");
601 assert_eq!(resolved, dir.path().join("app.js"));
602 assert!(!resolved.is_file(), "this is the miss case");
603
604 let index = mw.resolve_file("/").expect("inside the root");
607 assert!(index.is_file());
608
609 assert_eq!(mw.resolve_file("/../secrets.txt"), None);
611 assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
612 }
613
614 #[test]
615 fn skips_api_paths_for_spa_fallback() {
616 assert!("/api/docs".starts_with("/api/"));
619 assert!(!"/works/foo".starts_with("/api/"));
620 }
621
622 #[test]
623 fn embedded_key_maps_root_to_index_and_applies_aliases() {
624 let mw = super::SpaMiddleware::new(embedded());
625 assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
626 assert_eq!(
627 mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
628 .as_deref(),
629 Some("assets/vendor/vditor-dist/js/foo.js")
630 );
631 assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
632 }
633
634 #[test]
635 fn embedded_key_rejects_traversal() {
636 let mw = super::SpaMiddleware::new(embedded());
637 assert_eq!(mw.embedded_key("/../secrets.txt"), None);
638 assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
639 }
640
641 #[test]
642 fn embedded_is_served_only_for_the_embedded_source() {
643 let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
644 assert!(disk.embedded().is_none());
645 assert!(disk.embedded_asset("index.html").is_none());
646
647 let with_embedded = super::SpaMiddleware::new(embedded());
648 assert!(with_embedded.embedded().is_some());
649 assert!(with_embedded.embedded_asset("index.html").is_some());
650 assert!(with_embedded.embedded_asset("missing.html").is_none());
651 }
652
653 #[test]
654 fn disk_root_and_build_root_are_independent() {
655 let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
658 .with_overlay_root("wwwroot");
659 assert_eq!(source.disk_root(), "wwwroot");
660 assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
661 assert_eq!(source.embedded().unwrap().len(), 1);
662 }
663
664 #[test]
665 fn overlay_root_defaults_to_the_build_root() {
666 let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
667 assert_eq!(source.disk_root(), "wwwroot");
668 }
669
670 #[test]
671 fn with_overlay_root_replaces_a_disk_source() {
672 let source = SpaSource::from("static").with_overlay_root("wwwroot");
673 assert_eq!(source.disk_root(), "wwwroot");
674 assert!(source.embedded().is_none());
675 }
676
677 #[test]
678 fn embed_env_value_is_parsed() {
679 use super::embed_enabled;
680 assert!(embed_enabled(None));
682 assert!(embed_enabled(Some("on")));
683 assert!(embed_enabled(Some("embed")));
684 assert!(!embed_enabled(Some("off")));
686 assert!(!embed_enabled(Some(" OFF ")));
687 assert!(!embed_enabled(Some("false")));
688 assert!(!embed_enabled(Some("0")));
689 assert!(!embed_enabled(Some("no")));
690 }
691
692 #[test]
693 fn accept_encoding_recognises_brotli() {
694 assert!(header_allows("gzip, br", "br"));
695 assert!(header_allows("br", "br"));
696 assert!(header_allows("gzip, deflate, br, zstd", "br"));
697 assert!(header_allows("BR", "br"));
698 assert!(header_allows("gzip, br;q=1.0", "br"));
699 }
700
701 #[test]
702 fn accept_encoding_honours_a_zero_quality() {
703 assert!(!header_allows("br;q=0", "br"));
704 assert!(!header_allows("gzip, br;q=0.0", "br"));
705 assert!(!header_allows("gzip", "br"));
706 assert!(!header_allows("", "br"));
707 assert!(header_allows("gzip;q=0, br", "br"));
709 }
710
711 #[test]
712 fn accept_encoding_understands_the_wildcard() {
713 assert!(header_allows("*", "br"));
715 assert!(header_allows("gzip, *", "br"));
716 assert!(header_allows("*;q=0.5", "br"));
717 assert!(!header_allows("*;q=0", "br"));
719 assert!(!header_allows("gzip, *;q=0", "br"));
720 assert!(header_allows("*;q=0, br", "br"));
722 assert!(!header_allows("*;q=1, br;q=0", "br"));
723 }
724
725 #[test]
726 fn decoded_payloads_are_cached_per_path() {
727 let source = "x".repeat(2048);
728 let payload = {
729 use std::io::Write as _;
730 let mut out = Vec::new();
731 let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
732 encoder.write_all(source.as_bytes()).unwrap();
733 encoder.flush().unwrap();
734 drop(encoder);
735 out
736 };
737 let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
738 path: "cached.txt",
739 bytes: Box::leak(payload.into_boxed_slice()),
740 raw_len: source.len(),
741 encoding: ContentEncoding::Brotli,
742 content_type: "text/plain",
743 etag: "\"sha256-c\"",
744 }));
745
746 let first = super::decoded_once(asset).expect("decodes");
747 let second = super::decoded_once(asset).expect("cached");
748 assert_eq!(first, source.as_bytes());
749 assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
751 }
752}