1use std::collections::HashMap;
24use std::ops::ControlFlow;
25use std::path::{Path, PathBuf};
26use std::sync::{Mutex, OnceLock};
27use webx_core::error::Result;
28use webx_core::http::{FileBody, HttpStatus, IHttpContext};
29use webx_core::middleware::IMiddleware;
30
31use crate::embed::{EmbeddedAsset, EmbeddedAssets};
32
33const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
38const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
39
40const VARY: &str = "accept-encoding";
43
44pub const EMBED_ENV: &str = "WEBX_EMBED";
50
51#[derive(Debug, Clone)]
56pub enum SpaSource {
57 Disk(String),
59 Embedded {
62 assets: EmbeddedAssets,
64 overlay_root: String,
66 },
67}
68
69impl SpaSource {
70 pub fn disk_root(&self) -> &str {
72 match self {
73 SpaSource::Disk(root) => root,
74 SpaSource::Embedded { overlay_root, .. } => overlay_root,
75 }
76 }
77
78 pub fn embedded(&self) -> Option<&EmbeddedAssets> {
80 match self {
81 SpaSource::Disk(_) => None,
82 SpaSource::Embedded { assets, .. } => Some(assets),
83 }
84 }
85
86 pub fn with_overlay_root(self, dir: impl Into<String>) -> Self {
91 let dir = dir.into();
92 match self {
93 SpaSource::Disk(_) => SpaSource::Disk(dir),
94 SpaSource::Embedded { assets, .. } => SpaSource::Embedded {
95 assets,
96 overlay_root: dir,
97 },
98 }
99 }
100}
101
102impl From<&str> for SpaSource {
103 fn from(root: &str) -> Self {
104 SpaSource::Disk(root.to_string())
105 }
106}
107
108impl From<String> for SpaSource {
109 fn from(root: String) -> Self {
110 SpaSource::Disk(root)
111 }
112}
113
114impl From<PathBuf> for SpaSource {
115 fn from(root: PathBuf) -> Self {
116 SpaSource::Disk(root.to_string_lossy().into_owned())
117 }
118}
119
120impl From<&Path> for SpaSource {
121 fn from(root: &Path) -> Self {
122 SpaSource::Disk(root.to_string_lossy().into_owned())
123 }
124}
125
126impl From<&String> for SpaSource {
127 fn from(root: &String) -> Self {
128 SpaSource::Disk(root.clone())
129 }
130}
131
132impl From<EmbeddedAssets> for SpaSource {
133 fn from(assets: EmbeddedAssets) -> Self {
134 SpaSource::Embedded {
135 overlay_root: assets.root().to_string(),
136 assets,
137 }
138 }
139}
140
141pub struct SpaMiddleware {
148 root: PathBuf,
149 index: String,
150 embedded: Option<EmbeddedAssets>,
151 embed_enabled: bool,
153}
154
155impl SpaMiddleware {
156 pub fn new(source: impl Into<SpaSource>) -> Self {
163 Self::build(source.into(), "index.html".to_string())
164 }
165
166 pub fn with_index(source: impl Into<SpaSource>, index: impl Into<String>) -> Self {
168 Self::build(source.into(), index.into())
169 }
170
171 pub fn from_source(source: SpaSource) -> Self {
173 Self::build(source, "index.html".to_string())
174 }
175
176 fn build(source: SpaSource, index: String) -> Self {
177 let embedded = source.embedded().copied();
178 if let Some(assets) = embedded {
179 assets.debug_assert_sorted();
180 }
181 Self {
182 root: resolve_spa_root(PathBuf::from(source.disk_root())),
183 index,
184 embedded,
185 embed_enabled: embedded.is_some() && embed_enabled_from_env(),
186 }
187 }
188
189 pub fn resolved_root(&self) -> &Path {
191 &self.root
192 }
193
194 pub fn embedded(&self) -> Option<&EmbeddedAssets> {
198 self.embedded.as_ref()
199 }
200
201 fn embedded_asset(&self, key: &str) -> Option<&'static EmbeddedAsset> {
203 if !self.embed_enabled {
204 return None;
205 }
206 self.embedded.as_ref()?.get(key)
207 }
208
209 fn embedded_key(&self, request_path: &str) -> Option<String> {
214 let relative = alias_static_path(request_path.trim_start_matches('/'));
215 if relative.contains("..") {
216 return None;
217 }
218 Some(if relative.is_empty() {
219 self.index.clone()
220 } else {
221 relative
222 })
223 }
224}
225
226#[async_trait::async_trait]
227impl IMiddleware for SpaMiddleware {
228 async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
229 let method = ctx.request().method().to_uppercase();
230 if method != "GET" && method != "HEAD" {
231 return Ok(ControlFlow::Continue(()));
232 }
233
234 let request_path = ctx.request().path();
235
236 if request_path.starts_with("/api/") {
238 return Ok(ControlFlow::Continue(()));
239 }
240
241 if request_path.starts_with("/assets/") {
242 let relative = alias_static_path(request_path.trim_start_matches('/'));
243 if relative.is_empty() || relative.contains("..") {
244 ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
245 return Ok(ControlFlow::Continue(()));
246 }
247 let candidate = self.root.join(&relative);
248 if candidate.is_file() {
249 return serve_file(ctx, &candidate, IMMUTABLE_CACHE).await;
250 }
251 if let Some(asset) = self.embedded_asset(&relative) {
252 return serve_embedded(ctx, asset, IMMUTABLE_CACHE).await;
253 }
254 ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
255 return Ok(ControlFlow::Continue(()));
256 }
257
258 if let Some(file_path) = self.resolve_file(request_path) {
262 if file_path.is_file() {
263 return serve_file(ctx, &file_path, REVALIDATE_CACHE).await;
264 }
265 }
266 if let Some(key) = self.embedded_key(request_path) {
267 if let Some(asset) = self.embedded_asset(&key) {
268 return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
269 }
270 }
271
272 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 pub async fn try_index_fallback(&self, ctx: &mut dyn IHttpContext) -> Result<bool> {
452 if ctx.response().has_body() {
453 return Ok(false);
454 }
455 let method = ctx.request().method().to_uppercase();
456 if method != "GET" && method != "HEAD" {
457 return Ok(false);
458 }
459 if ctx.request().path().starts_with("/api/") {
460 return Ok(false);
461 }
462 if !accepts_html(ctx.request().header("accept")) {
463 return Ok(false);
464 }
465
466 let index_path = self.root.join(&self.index);
467 if index_path.is_file() {
468 let _ = serve_file(ctx, &index_path, REVALIDATE_CACHE).await?;
469 return Ok(true);
470 }
471 if let Some(asset) = self.embedded_asset(&self.index) {
472 let _ = serve_embedded(ctx, asset, REVALIDATE_CACHE).await?;
473 return Ok(true);
474 }
475 Ok(false)
476 }
477}
478
479fn accepts_html(accept: Option<&str>) -> bool {
485 accept
486 .map(|value| value.to_ascii_lowercase().contains("text/html"))
487 .unwrap_or(false)
488}
489
490fn alias_static_path(relative: &str) -> String {
495 const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
496 if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
497 format!("assets/vendor/vditor-dist/{rest}")
498 } else {
499 relative.to_string()
500 }
501}
502
503fn embed_enabled_from_env() -> bool {
505 embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
506}
507
508fn embed_enabled(value: Option<&str>) -> bool {
511 match value {
512 Some(value) => !matches!(
513 value.trim().to_ascii_lowercase().as_str(),
514 "off" | "0" | "false" | "no"
515 ),
516 None => true,
517 }
518}
519
520fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
530 let root_abs = normalize_path(&absolute(root));
531 let candidate_abs = normalize_path(&absolute(candidate));
532 candidate_abs.starts_with(&root_abs)
533}
534
535fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
538 match root.canonicalize() {
539 Ok(root) => resolved.starts_with(&root),
540 Err(_) => is_safe_subpath(root, resolved),
542 }
543}
544
545fn absolute(path: &Path) -> PathBuf {
548 if path.is_absolute() {
549 return path.to_path_buf();
550 }
551 match std::env::current_dir() {
552 Ok(cwd) => cwd.join(path),
553 Err(_) => path.to_path_buf(),
554 }
555}
556
557fn normalize_path(path: &Path) -> PathBuf {
559 let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
560 for component in path.components() {
561 match component {
562 std::path::Component::ParentDir => {
563 parts.pop();
564 }
565 std::path::Component::CurDir => {}
566 other => {
567 parts.push(other.as_os_str());
568 }
569 }
570 }
571 let mut result = PathBuf::new();
572 for part in parts {
573 result.push(part);
574 }
575 result
576}
577
578fn resolve_spa_root(root: PathBuf) -> PathBuf {
586 if root.is_absolute() || root.exists() {
588 return root;
589 }
590
591 let candidate = webx_core::paths::app_base().join(&root);
593 if candidate.exists() {
594 return candidate;
595 }
596
597 root
598}
599
600#[cfg(test)]
601mod tests {
602 use super::{alias_static_path, header_allows, normalize_path, SpaSource};
603 use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
604 use std::path::Path;
605
606 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
607 path: "index.html",
608 bytes: b"index",
609 raw_len: 5,
610 encoding: ContentEncoding::Identity,
611 content_type: "text/html",
612 etag: "\"sha256-1\"",
613 }];
614
615 fn embedded() -> SpaSource {
616 SpaSource::Embedded {
617 assets: EmbeddedAssets::new("wwwroot", FILES),
618 overlay_root: "wwwroot".to_string(),
619 }
620 }
621
622 #[test]
623 fn alias_static_path_strips_vditor_dist_segment() {
624 let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
625 assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
626 }
627
628 #[test]
629 fn normalize_path_removes_dot_dot() {
630 let p = normalize_path(Path::new("a/../b/./c"));
631 assert_eq!(p, Path::new("b/c"));
632 }
633
634 #[test]
642 fn missing_file_resolves_to_itself_not_the_index() {
643 let dir = tempfile::tempdir().unwrap();
644 std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
645 let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
646 let mw = super::SpaMiddleware::from_source(source);
647
648 let resolved = mw.resolve_file("/app.js").expect("inside the root");
649 assert_eq!(resolved, dir.path().join("app.js"));
650 assert!(!resolved.is_file(), "this is the miss case");
651
652 let index = mw.resolve_file("/").expect("inside the root");
655 assert!(index.is_file());
656
657 assert_eq!(mw.resolve_file("/../secrets.txt"), None);
659 assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
660 }
661
662 #[test]
663 fn skips_api_paths_for_spa_fallback() {
664 assert!("/api/docs".starts_with("/api/"));
667 assert!(!"/works/foo".starts_with("/api/"));
668 }
669
670 #[test]
671 fn embedded_key_maps_root_to_index_and_applies_aliases() {
672 let mw = super::SpaMiddleware::new(embedded());
673 assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
674 assert_eq!(
675 mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
676 .as_deref(),
677 Some("assets/vendor/vditor-dist/js/foo.js")
678 );
679 assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
680 }
681
682 #[test]
683 fn embedded_key_rejects_traversal() {
684 let mw = super::SpaMiddleware::new(embedded());
685 assert_eq!(mw.embedded_key("/../secrets.txt"), None);
686 assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
687 }
688
689 #[test]
690 fn embedded_is_served_only_for_the_embedded_source() {
691 let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
692 assert!(disk.embedded().is_none());
693 assert!(disk.embedded_asset("index.html").is_none());
694
695 let with_embedded = super::SpaMiddleware::new(embedded());
696 assert!(with_embedded.embedded().is_some());
697 assert!(with_embedded.embedded_asset("index.html").is_some());
698 assert!(with_embedded.embedded_asset("missing.html").is_none());
699 }
700
701 #[test]
702 fn disk_root_and_build_root_are_independent() {
703 let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
706 .with_overlay_root("wwwroot");
707 assert_eq!(source.disk_root(), "wwwroot");
708 assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
709 assert_eq!(source.embedded().unwrap().len(), 1);
710 }
711
712 #[test]
713 fn overlay_root_defaults_to_the_build_root() {
714 let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
715 assert_eq!(source.disk_root(), "wwwroot");
716 }
717
718 #[test]
719 fn with_overlay_root_replaces_a_disk_source() {
720 let source = SpaSource::from("static").with_overlay_root("wwwroot");
721 assert_eq!(source.disk_root(), "wwwroot");
722 assert!(source.embedded().is_none());
723 }
724
725 #[test]
726 fn embed_env_value_is_parsed() {
727 use super::embed_enabled;
728 assert!(embed_enabled(None));
730 assert!(embed_enabled(Some("on")));
731 assert!(embed_enabled(Some("embed")));
732 assert!(!embed_enabled(Some("off")));
734 assert!(!embed_enabled(Some(" OFF ")));
735 assert!(!embed_enabled(Some("false")));
736 assert!(!embed_enabled(Some("0")));
737 assert!(!embed_enabled(Some("no")));
738 }
739
740 #[test]
741 fn accept_encoding_recognises_brotli() {
742 assert!(header_allows("gzip, br", "br"));
743 assert!(header_allows("br", "br"));
744 assert!(header_allows("gzip, deflate, br, zstd", "br"));
745 assert!(header_allows("BR", "br"));
746 assert!(header_allows("gzip, br;q=1.0", "br"));
747 }
748
749 #[test]
750 fn accept_encoding_honours_a_zero_quality() {
751 assert!(!header_allows("br;q=0", "br"));
752 assert!(!header_allows("gzip, br;q=0.0", "br"));
753 assert!(!header_allows("gzip", "br"));
754 assert!(!header_allows("", "br"));
755 assert!(header_allows("gzip;q=0, br", "br"));
757 }
758
759 #[test]
760 fn accept_encoding_understands_the_wildcard() {
761 assert!(header_allows("*", "br"));
763 assert!(header_allows("gzip, *", "br"));
764 assert!(header_allows("*;q=0.5", "br"));
765 assert!(!header_allows("*;q=0", "br"));
767 assert!(!header_allows("gzip, *;q=0", "br"));
768 assert!(header_allows("*;q=0, br", "br"));
770 assert!(!header_allows("*;q=1, br;q=0", "br"));
771 }
772
773 #[test]
774 fn decoded_payloads_are_cached_per_path() {
775 let source = "x".repeat(2048);
776 let payload = {
777 use std::io::Write as _;
778 let mut out = Vec::new();
779 let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
780 encoder.write_all(source.as_bytes()).unwrap();
781 encoder.flush().unwrap();
782 drop(encoder);
783 out
784 };
785 let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
786 path: "cached.txt",
787 bytes: Box::leak(payload.into_boxed_slice()),
788 raw_len: source.len(),
789 encoding: ContentEncoding::Brotli,
790 content_type: "text/plain",
791 etag: "\"sha256-c\"",
792 }));
793
794 let first = super::decoded_once(asset).expect("decodes");
795 let second = super::decoded_once(asset).expect("cached");
796 assert_eq!(first, source.as_bytes());
797 assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
799 }
800}