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
391fn decoded_once(asset: &'static EmbeddedAsset) -> Option<&'static [u8]> {
397 static DECODED: OnceLock<Mutex<HashMap<&'static str, &'static [u8]>>> = OnceLock::new();
398
399 let cache = DECODED.get_or_init(|| Mutex::new(HashMap::new()));
400 let mut cache = cache.lock().ok()?;
401 if let Some(bytes) = cache.get(asset.path) {
402 return Some(bytes);
403 }
404 let bytes: &'static [u8] = Box::leak(asset.decode()?.into_boxed_slice());
405 cache.insert(asset.path, bytes);
406 Some(bytes)
407}
408
409impl SpaMiddleware {
410 fn resolve_file(&self, request_path: &str) -> Option<PathBuf> {
420 let relative = alias_static_path(request_path.trim_start_matches('/'));
421 if relative.is_empty() {
422 return Some(self.root.join(&self.index));
423 }
424
425 let candidate = self.root.join(&relative);
426
427 if !is_safe_subpath(&self.root, &candidate) {
429 return None;
430 }
431
432 match candidate.canonicalize() {
434 Ok(resolved) if is_canonical_subpath(&self.root, &resolved) => Some(resolved),
435 Ok(_) => None,
436 Err(_) => Some(candidate),
437 }
438 }
439}
440
441fn alias_static_path(relative: &str) -> String {
446 const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
447 if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
448 format!("assets/vendor/vditor-dist/{rest}")
449 } else {
450 relative.to_string()
451 }
452}
453
454fn embed_enabled_from_env() -> bool {
456 embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
457}
458
459fn embed_enabled(value: Option<&str>) -> bool {
462 match value {
463 Some(value) => !matches!(
464 value.trim().to_ascii_lowercase().as_str(),
465 "off" | "0" | "false" | "no"
466 ),
467 None => true,
468 }
469}
470
471fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
481 let root_abs = normalize_path(&absolute(root));
482 let candidate_abs = normalize_path(&absolute(candidate));
483 candidate_abs.starts_with(&root_abs)
484}
485
486fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
489 match root.canonicalize() {
490 Ok(root) => resolved.starts_with(&root),
491 Err(_) => is_safe_subpath(root, resolved),
493 }
494}
495
496fn absolute(path: &Path) -> PathBuf {
499 if path.is_absolute() {
500 return path.to_path_buf();
501 }
502 match std::env::current_dir() {
503 Ok(cwd) => cwd.join(path),
504 Err(_) => path.to_path_buf(),
505 }
506}
507
508fn normalize_path(path: &Path) -> PathBuf {
510 let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
511 for component in path.components() {
512 match component {
513 std::path::Component::ParentDir => {
514 parts.pop();
515 }
516 std::path::Component::CurDir => {}
517 other => {
518 parts.push(other.as_os_str());
519 }
520 }
521 }
522 let mut result = PathBuf::new();
523 for part in parts {
524 result.push(part);
525 }
526 result
527}
528
529fn resolve_spa_root(root: PathBuf) -> PathBuf {
537 if root.is_absolute() || root.exists() {
539 return root;
540 }
541
542 let candidate = webx_core::paths::app_base().join(&root);
544 if candidate.exists() {
545 return candidate;
546 }
547
548 root
549}
550
551#[cfg(test)]
552mod tests {
553 use super::{alias_static_path, header_allows, normalize_path, SpaSource};
554 use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
555 use std::path::Path;
556
557 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
558 path: "index.html",
559 bytes: b"index",
560 raw_len: 5,
561 encoding: ContentEncoding::Identity,
562 content_type: "text/html",
563 etag: "\"sha256-1\"",
564 }];
565
566 fn embedded() -> SpaSource {
567 SpaSource::Embedded {
568 assets: EmbeddedAssets::new("wwwroot", FILES),
569 overlay_root: "wwwroot".to_string(),
570 }
571 }
572
573 #[test]
574 fn alias_static_path_strips_vditor_dist_segment() {
575 let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
576 assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
577 }
578
579 #[test]
580 fn normalize_path_removes_dot_dot() {
581 let p = normalize_path(Path::new("a/../b/./c"));
582 assert_eq!(p, Path::new("b/c"));
583 }
584
585 #[test]
593 fn missing_file_resolves_to_itself_not_the_index() {
594 let dir = tempfile::tempdir().unwrap();
595 std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
596 let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
597 let mw = super::SpaMiddleware::from_source(source);
598
599 let resolved = mw.resolve_file("/app.js").expect("inside the root");
600 assert_eq!(resolved, dir.path().join("app.js"));
601 assert!(!resolved.is_file(), "this is the miss case");
602
603 let index = mw.resolve_file("/").expect("inside the root");
606 assert!(index.is_file());
607
608 assert_eq!(mw.resolve_file("/../secrets.txt"), None);
610 assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
611 }
612
613 #[test]
614 fn skips_api_paths_for_spa_fallback() {
615 assert!("/api/docs".starts_with("/api/"));
618 assert!(!"/works/foo".starts_with("/api/"));
619 }
620
621 #[test]
622 fn embedded_key_maps_root_to_index_and_applies_aliases() {
623 let mw = super::SpaMiddleware::new(embedded());
624 assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
625 assert_eq!(
626 mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
627 .as_deref(),
628 Some("assets/vendor/vditor-dist/js/foo.js")
629 );
630 assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
631 }
632
633 #[test]
634 fn embedded_key_rejects_traversal() {
635 let mw = super::SpaMiddleware::new(embedded());
636 assert_eq!(mw.embedded_key("/../secrets.txt"), None);
637 assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
638 }
639
640 #[test]
641 fn embedded_is_served_only_for_the_embedded_source() {
642 let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
643 assert!(disk.embedded().is_none());
644 assert!(disk.embedded_asset("index.html").is_none());
645
646 let with_embedded = super::SpaMiddleware::new(embedded());
647 assert!(with_embedded.embedded().is_some());
648 assert!(with_embedded.embedded_asset("index.html").is_some());
649 assert!(with_embedded.embedded_asset("missing.html").is_none());
650 }
651
652 #[test]
653 fn disk_root_and_build_root_are_independent() {
654 let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
657 .with_overlay_root("wwwroot");
658 assert_eq!(source.disk_root(), "wwwroot");
659 assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
660 assert_eq!(source.embedded().unwrap().len(), 1);
661 }
662
663 #[test]
664 fn overlay_root_defaults_to_the_build_root() {
665 let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
666 assert_eq!(source.disk_root(), "wwwroot");
667 }
668
669 #[test]
670 fn with_overlay_root_replaces_a_disk_source() {
671 let source = SpaSource::from("static").with_overlay_root("wwwroot");
672 assert_eq!(source.disk_root(), "wwwroot");
673 assert!(source.embedded().is_none());
674 }
675
676 #[test]
677 fn embed_env_value_is_parsed() {
678 use super::embed_enabled;
679 assert!(embed_enabled(None));
681 assert!(embed_enabled(Some("on")));
682 assert!(embed_enabled(Some("embed")));
683 assert!(!embed_enabled(Some("off")));
685 assert!(!embed_enabled(Some(" OFF ")));
686 assert!(!embed_enabled(Some("false")));
687 assert!(!embed_enabled(Some("0")));
688 assert!(!embed_enabled(Some("no")));
689 }
690
691 #[test]
692 fn accept_encoding_recognises_brotli() {
693 assert!(header_allows("gzip, br", "br"));
694 assert!(header_allows("br", "br"));
695 assert!(header_allows("gzip, deflate, br, zstd", "br"));
696 assert!(header_allows("BR", "br"));
697 assert!(header_allows("gzip, br;q=1.0", "br"));
698 }
699
700 #[test]
701 fn accept_encoding_honours_a_zero_quality() {
702 assert!(!header_allows("br;q=0", "br"));
703 assert!(!header_allows("gzip, br;q=0.0", "br"));
704 assert!(!header_allows("gzip", "br"));
705 assert!(!header_allows("", "br"));
706 assert!(header_allows("gzip;q=0, br", "br"));
708 }
709
710 #[test]
711 fn accept_encoding_understands_the_wildcard() {
712 assert!(header_allows("*", "br"));
714 assert!(header_allows("gzip, *", "br"));
715 assert!(header_allows("*;q=0.5", "br"));
716 assert!(!header_allows("*;q=0", "br"));
718 assert!(!header_allows("gzip, *;q=0", "br"));
719 assert!(header_allows("*;q=0, br", "br"));
721 assert!(!header_allows("*;q=1, br;q=0", "br"));
722 }
723
724 #[test]
725 fn decoded_payloads_are_cached_per_path() {
726 let source = "x".repeat(2048);
727 let payload = {
728 use std::io::Write as _;
729 let mut out = Vec::new();
730 let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
731 encoder.write_all(source.as_bytes()).unwrap();
732 encoder.flush().unwrap();
733 drop(encoder);
734 out
735 };
736 let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
737 path: "cached.txt",
738 bytes: Box::leak(payload.into_boxed_slice()),
739 raw_len: source.len(),
740 encoding: ContentEncoding::Brotli,
741 content_type: "text/plain",
742 etag: "\"sha256-c\"",
743 }));
744
745 let first = super::decoded_once(asset).expect("decodes");
746 let second = super::decoded_once(asset).expect("cached");
747 assert_eq!(first, source.as_bytes());
748 assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
750 }
751}