use std::collections::HashMap;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use webx_core::error::Result;
use webx_core::http::{FileBody, HttpStatus, IHttpContext};
use webx_core::middleware::IMiddleware;
use crate::embed::{EmbeddedAsset, EmbeddedAssets};
const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
const VARY: &str = "accept-encoding";
pub const EMBED_ENV: &str = "WEBX_EMBED";
#[derive(Debug, Clone)]
pub enum SpaSource {
Disk(String),
Embedded {
assets: EmbeddedAssets,
overlay_root: String,
},
}
impl SpaSource {
pub fn disk_root(&self) -> &str {
match self {
SpaSource::Disk(root) => root,
SpaSource::Embedded { overlay_root, .. } => overlay_root,
}
}
pub fn embedded(&self) -> Option<&EmbeddedAssets> {
match self {
SpaSource::Disk(_) => None,
SpaSource::Embedded { assets, .. } => Some(assets),
}
}
pub fn with_overlay_root(self, dir: impl Into<String>) -> Self {
let dir = dir.into();
match self {
SpaSource::Disk(_) => SpaSource::Disk(dir),
SpaSource::Embedded { assets, .. } => SpaSource::Embedded {
assets,
overlay_root: dir,
},
}
}
}
impl From<&str> for SpaSource {
fn from(root: &str) -> Self {
SpaSource::Disk(root.to_string())
}
}
impl From<String> for SpaSource {
fn from(root: String) -> Self {
SpaSource::Disk(root)
}
}
impl From<PathBuf> for SpaSource {
fn from(root: PathBuf) -> Self {
SpaSource::Disk(root.to_string_lossy().into_owned())
}
}
impl From<&Path> for SpaSource {
fn from(root: &Path) -> Self {
SpaSource::Disk(root.to_string_lossy().into_owned())
}
}
impl From<&String> for SpaSource {
fn from(root: &String) -> Self {
SpaSource::Disk(root.clone())
}
}
impl From<EmbeddedAssets> for SpaSource {
fn from(assets: EmbeddedAssets) -> Self {
SpaSource::Embedded {
overlay_root: assets.root().to_string(),
assets,
}
}
}
pub struct SpaMiddleware {
root: PathBuf,
index: String,
embedded: Option<EmbeddedAssets>,
embed_enabled: bool,
}
impl SpaMiddleware {
pub fn new(source: impl Into<SpaSource>) -> Self {
Self::build(source.into(), "index.html".to_string())
}
pub fn with_index(source: impl Into<SpaSource>, index: impl Into<String>) -> Self {
Self::build(source.into(), index.into())
}
pub fn from_source(source: SpaSource) -> Self {
Self::build(source, "index.html".to_string())
}
fn build(source: SpaSource, index: String) -> Self {
let embedded = source.embedded().copied();
if let Some(assets) = embedded {
assets.debug_assert_sorted();
}
Self {
root: resolve_spa_root(PathBuf::from(source.disk_root())),
index,
embedded,
embed_enabled: embedded.is_some() && embed_enabled_from_env(),
}
}
pub fn resolved_root(&self) -> &Path {
&self.root
}
pub fn embedded(&self) -> Option<&EmbeddedAssets> {
self.embedded.as_ref()
}
fn embedded_asset(&self, key: &str) -> Option<&'static EmbeddedAsset> {
if !self.embed_enabled {
return None;
}
self.embedded.as_ref()?.get(key)
}
fn embedded_key(&self, request_path: &str) -> Option<String> {
let relative = alias_static_path(request_path.trim_start_matches('/'));
if relative.contains("..") {
return None;
}
Some(if relative.is_empty() {
self.index.clone()
} else {
relative
})
}
}
#[async_trait::async_trait]
impl IMiddleware for SpaMiddleware {
async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
let method = ctx.request().method().to_uppercase();
if method != "GET" && method != "HEAD" {
return Ok(ControlFlow::Continue(()));
}
let request_path = ctx.request().path();
if request_path.starts_with("/api/") {
return Ok(ControlFlow::Continue(()));
}
if request_path.starts_with("/assets/") {
let relative = alias_static_path(request_path.trim_start_matches('/'));
if relative.is_empty() || relative.contains("..") {
ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
return Ok(ControlFlow::Continue(()));
}
let candidate = self.root.join(&relative);
if candidate.is_file() {
return serve_file(ctx, &candidate, IMMUTABLE_CACHE).await;
}
if let Some(asset) = self.embedded_asset(&relative) {
return serve_embedded(ctx, asset, IMMUTABLE_CACHE).await;
}
ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
return Ok(ControlFlow::Continue(()));
}
if let Some(file_path) = self.resolve_file(request_path) {
if file_path.is_file() {
return serve_file(ctx, &file_path, REVALIDATE_CACHE).await;
}
}
if let Some(key) = self.embedded_key(request_path) {
if let Some(asset) = self.embedded_asset(&key) {
return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
}
}
let index_path = self.root.join(&self.index);
if index_path.is_file() {
return serve_file(ctx, &index_path, REVALIDATE_CACHE).await;
}
if let Some(asset) = self.embedded_asset(&self.index) {
return serve_embedded(ctx, asset, REVALIDATE_CACHE).await;
}
Ok(ControlFlow::Continue(()))
}
}
async fn serve_file(
ctx: &mut dyn IHttpContext,
path: &Path,
cache_control: &str,
) -> Result<ControlFlow<()>> {
ctx.response_mut().set_status(HttpStatus::OK);
ctx.response_mut()
.set_header("cache-control", cache_control);
ctx.response_mut()
.write_body(FileBody::path(path).into())
.await?;
Ok(ControlFlow::Continue(()))
}
async fn serve_embedded(
ctx: &mut dyn IHttpContext,
asset: &'static EmbeddedAsset,
cache_control: &str,
) -> Result<ControlFlow<()>> {
ctx.response_mut().set_status(HttpStatus::OK);
ctx.response_mut()
.set_header("cache-control", cache_control);
let token = asset.encoding.token();
if let Some(token) = token {
ctx.response_mut().set_header("vary", VARY);
if accepts_encoding(ctx, token) {
return send_embedded(ctx, asset, asset.bytes, Some(token)).await;
}
if let Some(decoded) = decoded_once(asset) {
return send_embedded(ctx, asset, decoded, None).await;
}
}
send_embedded(ctx, asset, asset.bytes, token).await
}
async fn send_embedded(
ctx: &mut dyn IHttpContext,
asset: &'static EmbeddedAsset,
bytes: &'static [u8],
token: Option<&'static str>,
) -> Result<ControlFlow<()>> {
if let Some(token) = token {
ctx.response_mut().set_header("content-encoding", token);
}
ctx.response_mut()
.write_body(
FileBody::static_bytes(bytes)
.content_type(asset.content_type)
.entity_tag(asset.etag_for(token.is_some()))
.into(),
)
.await?;
Ok(ControlFlow::Continue(()))
}
fn accepts_encoding(ctx: &dyn IHttpContext, token: &str) -> bool {
ctx.request()
.header("accept-encoding")
.is_some_and(|value| header_allows(value, token))
}
fn header_allows(value: &str, token: &str) -> bool {
let mut wildcard = None;
for entry in value.split(',') {
let mut params = entry.split(';');
let name = params.next().unwrap_or("").trim();
let quality = params
.filter_map(|param| param.trim().strip_prefix("q="))
.find_map(|quality| quality.trim().parse::<f32>().ok())
.unwrap_or(1.0);
if name.eq_ignore_ascii_case(token) {
return quality > 0.0;
}
if name == "*" {
wildcard = Some(quality);
}
}
wildcard.is_some_and(|quality| quality > 0.0)
}
pub(crate) fn decoded_once(asset: &'static EmbeddedAsset) -> Option<&'static [u8]> {
static DECODED: OnceLock<Mutex<HashMap<&'static str, &'static [u8]>>> = OnceLock::new();
let cache = DECODED.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
if let Some(bytes) = cache.get(asset.path) {
return Some(bytes);
}
let bytes: &'static [u8] = Box::leak(asset.decode()?.into_boxed_slice());
cache.insert(asset.path, bytes);
Some(bytes)
}
impl SpaMiddleware {
fn resolve_file(&self, request_path: &str) -> Option<PathBuf> {
let relative = alias_static_path(request_path.trim_start_matches('/'));
if relative.is_empty() {
return Some(self.root.join(&self.index));
}
let candidate = self.root.join(&relative);
if !is_safe_subpath(&self.root, &candidate) {
return None;
}
match candidate.canonicalize() {
Ok(resolved) if is_canonical_subpath(&self.root, &resolved) => Some(resolved),
Ok(_) => None,
Err(_) => Some(candidate),
}
}
}
fn alias_static_path(relative: &str) -> String {
const VDITOR_DIST: &str = "assets/vendor/vditor-dist/dist/";
if let Some(rest) = relative.strip_prefix(VDITOR_DIST) {
format!("assets/vendor/vditor-dist/{rest}")
} else {
relative.to_string()
}
}
fn embed_enabled_from_env() -> bool {
embed_enabled(std::env::var(EMBED_ENV).ok().as_deref())
}
fn embed_enabled(value: Option<&str>) -> bool {
match value {
Some(value) => !matches!(
value.trim().to_ascii_lowercase().as_str(),
"off" | "0" | "false" | "no"
),
None => true,
}
}
fn is_safe_subpath(root: &Path, candidate: &Path) -> bool {
let root_abs = normalize_path(&absolute(root));
let candidate_abs = normalize_path(&absolute(candidate));
candidate_abs.starts_with(&root_abs)
}
fn is_canonical_subpath(root: &Path, resolved: &Path) -> bool {
match root.canonicalize() {
Ok(root) => resolved.starts_with(&root),
Err(_) => is_safe_subpath(root, resolved),
}
}
fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => path.to_path_buf(),
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
parts.pop();
}
std::path::Component::CurDir => {}
other => {
parts.push(other.as_os_str());
}
}
}
let mut result = PathBuf::new();
for part in parts {
result.push(part);
}
result
}
fn resolve_spa_root(root: PathBuf) -> PathBuf {
if root.is_absolute() || root.exists() {
return root;
}
let candidate = webx_core::paths::app_base().join(&root);
if candidate.exists() {
return candidate;
}
root
}
#[cfg(test)]
mod tests {
use super::{alias_static_path, header_allows, normalize_path, SpaSource};
use crate::embed::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
use std::path::Path;
const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
path: "index.html",
bytes: b"index",
raw_len: 5,
encoding: ContentEncoding::Identity,
content_type: "text/html",
etag: "\"sha256-1\"",
}];
fn embedded() -> SpaSource {
SpaSource::Embedded {
assets: EmbeddedAssets::new("wwwroot", FILES),
overlay_root: "wwwroot".to_string(),
}
}
#[test]
fn alias_static_path_strips_vditor_dist_segment() {
let out = alias_static_path("assets/vendor/vditor-dist/dist/js/foo.js");
assert_eq!(out, "assets/vendor/vditor-dist/js/foo.js");
}
#[test]
fn normalize_path_removes_dot_dot() {
let p = normalize_path(Path::new("a/../b/./c"));
assert_eq!(p, Path::new("b/c"));
}
#[test]
fn missing_file_resolves_to_itself_not_the_index() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), b"shell").unwrap();
let source = embedded().with_overlay_root(dir.path().to_string_lossy().into_owned());
let mw = super::SpaMiddleware::from_source(source);
let resolved = mw.resolve_file("/app.js").expect("inside the root");
assert_eq!(resolved, dir.path().join("app.js"));
assert!(!resolved.is_file(), "this is the miss case");
let index = mw.resolve_file("/").expect("inside the root");
assert!(index.is_file());
assert_eq!(mw.resolve_file("/../secrets.txt"), None);
assert_eq!(mw.resolve_file("/assets/../../app.db"), None);
}
#[test]
fn skips_api_paths_for_spa_fallback() {
assert!("/api/docs".starts_with("/api/"));
assert!(!"/works/foo".starts_with("/api/"));
}
#[test]
fn embedded_key_maps_root_to_index_and_applies_aliases() {
let mw = super::SpaMiddleware::new(embedded());
assert_eq!(mw.embedded_key("/").as_deref(), Some("index.html"));
assert_eq!(
mw.embedded_key("/assets/vendor/vditor-dist/dist/js/foo.js")
.as_deref(),
Some("assets/vendor/vditor-dist/js/foo.js")
);
assert_eq!(mw.embedded_key("some/page").as_deref(), Some("some/page"));
}
#[test]
fn embedded_key_rejects_traversal() {
let mw = super::SpaMiddleware::new(embedded());
assert_eq!(mw.embedded_key("/../secrets.txt"), None);
assert_eq!(mw.embedded_key("/assets/../../app.db"), None);
}
#[test]
fn embedded_is_served_only_for_the_embedded_source() {
let disk = super::SpaMiddleware::new(SpaSource::Disk("wwwroot".to_string()));
assert!(disk.embedded().is_none());
assert!(disk.embedded_asset("index.html").is_none());
let with_embedded = super::SpaMiddleware::new(embedded());
assert!(with_embedded.embedded().is_some());
assert!(with_embedded.embedded_asset("index.html").is_some());
assert!(with_embedded.embedded_asset("missing.html").is_none());
}
#[test]
fn disk_root_and_build_root_are_independent() {
let source = SpaSource::from(EmbeddedAssets::new("frontend/dist", FILES))
.with_overlay_root("wwwroot");
assert_eq!(source.disk_root(), "wwwroot");
assert_eq!(source.embedded().unwrap().root(), "frontend/dist");
assert_eq!(source.embedded().unwrap().len(), 1);
}
#[test]
fn overlay_root_defaults_to_the_build_root() {
let source = SpaSource::from(EmbeddedAssets::new("wwwroot", FILES));
assert_eq!(source.disk_root(), "wwwroot");
}
#[test]
fn with_overlay_root_replaces_a_disk_source() {
let source = SpaSource::from("static").with_overlay_root("wwwroot");
assert_eq!(source.disk_root(), "wwwroot");
assert!(source.embedded().is_none());
}
#[test]
fn embed_env_value_is_parsed() {
use super::embed_enabled;
assert!(embed_enabled(None));
assert!(embed_enabled(Some("on")));
assert!(embed_enabled(Some("embed")));
assert!(!embed_enabled(Some("off")));
assert!(!embed_enabled(Some(" OFF ")));
assert!(!embed_enabled(Some("false")));
assert!(!embed_enabled(Some("0")));
assert!(!embed_enabled(Some("no")));
}
#[test]
fn accept_encoding_recognises_brotli() {
assert!(header_allows("gzip, br", "br"));
assert!(header_allows("br", "br"));
assert!(header_allows("gzip, deflate, br, zstd", "br"));
assert!(header_allows("BR", "br"));
assert!(header_allows("gzip, br;q=1.0", "br"));
}
#[test]
fn accept_encoding_honours_a_zero_quality() {
assert!(!header_allows("br;q=0", "br"));
assert!(!header_allows("gzip, br;q=0.0", "br"));
assert!(!header_allows("gzip", "br"));
assert!(!header_allows("", "br"));
assert!(header_allows("gzip;q=0, br", "br"));
}
#[test]
fn accept_encoding_understands_the_wildcard() {
assert!(header_allows("*", "br"));
assert!(header_allows("gzip, *", "br"));
assert!(header_allows("*;q=0.5", "br"));
assert!(!header_allows("*;q=0", "br"));
assert!(!header_allows("gzip, *;q=0", "br"));
assert!(header_allows("*;q=0, br", "br"));
assert!(!header_allows("*;q=1, br;q=0", "br"));
}
#[test]
fn decoded_payloads_are_cached_per_path() {
let source = "x".repeat(2048);
let payload = {
use std::io::Write as _;
let mut out = Vec::new();
let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
encoder.write_all(source.as_bytes()).unwrap();
encoder.flush().unwrap();
drop(encoder);
out
};
let asset: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
path: "cached.txt",
bytes: Box::leak(payload.into_boxed_slice()),
raw_len: source.len(),
encoding: ContentEncoding::Brotli,
content_type: "text/plain",
etag: "\"sha256-c\"",
}));
let first = super::decoded_once(asset).expect("decodes");
let second = super::decoded_once(asset).expect("cached");
assert_eq!(first, source.as_bytes());
assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
}
}