use rust_webx_core::error::Result;
use rust_webx_core::http::{FileBody, HttpStatus, IHttpContext};
use rust_webx_core::middleware::IMiddleware;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
const REVALIDATE_CACHE: &str = "public, max-age=0, must-revalidate";
pub struct SpaMiddleware {
root: PathBuf,
index: String,
}
impl SpaMiddleware {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: resolve_spa_root(root.into()),
index: "index.html".to_string(),
}
}
pub fn with_index(root: impl Into<PathBuf>, index: impl Into<String>) -> Self {
Self {
root: root.into(),
index: index.into(),
}
}
}
#[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;
}
ctx.response_mut().set_status(HttpStatus::NOT_FOUND);
return Ok(ControlFlow::Continue(()));
}
let file_path = self.resolve_file(request_path);
if file_path.is_file() {
return serve_file(ctx, &file_path, 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;
}
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(()))
}
impl SpaMiddleware {
fn resolve_file(&self, request_path: &str) -> PathBuf {
let relative = alias_static_path(request_path.trim_start_matches('/'));
if relative.is_empty() {
return self.root.join(&self.index);
}
let candidate = self.root.join(&relative);
match candidate.canonicalize() {
Ok(resolved) => {
let root_canonical = self
.root
.canonicalize()
.unwrap_or_else(|_| self.root.clone());
if resolved.starts_with(&root_canonical) {
resolved
} else {
self.root.join(&self.index)
}
}
Err(_) => {
if is_safe_subpath(&self.root, &candidate) {
candidate
} else {
self.root.join(&self.index)
}
}
}
}
}
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 is_safe_subpath(root: &Path, candidate: &Path) -> bool {
let normalized = normalize_path(candidate);
let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if normalized.is_absolute() {
return normalized.starts_with(&root_abs);
}
if let Ok(cwd) = std::env::current_dir() {
let abs_candidate = cwd.join(&normalized);
if let Ok(canon) = abs_candidate.canonicalize() {
return canon.starts_with(&root_abs);
}
}
true
}
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 = rust_webx_core::paths::app_base().join(&root);
if candidate.exists() {
return candidate;
}
root
}
#[cfg(test)]
mod tests {
use super::{alias_static_path, normalize_path};
use std::path::Path;
#[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 skips_api_paths_for_spa_fallback() {
assert!("/api/docs".starts_with("/api/"));
assert!(!"/works/foo".starts_with("/api/"));
}
}