use mime_guess::from_path;
use std::fs;
use std::io;
use std::path::PathBuf;
use tracing;
#[derive(Debug, thiserror::Error)]
pub enum StaticError {
#[error("File not found: {0}")]
NotFound(String),
#[error("Directory traversal blocked: {0}")]
DirectoryTraversal(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
#[derive(Debug)]
pub struct StaticFile {
pub content: Vec<u8>,
pub path: PathBuf,
pub mime_type: String,
}
impl StaticFile {
pub fn etag(&self) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
self.content.hash(&mut hasher);
format!("\"{}\"", hasher.finish())
}
}
pub struct StaticFileHandler {
root: PathBuf,
index_files: Vec<String>,
}
impl StaticFileHandler {
pub fn new(root: PathBuf) -> Self {
Self {
root,
index_files: vec!["index.html".to_string()],
}
}
pub fn with_index_files(mut self, index_files: Vec<String>) -> Self {
self.index_files = index_files;
self
}
pub async fn serve(&self, path: &str) -> Result<StaticFile, StaticError> {
let resolved = self.resolve_path(path).await?;
let content = fs::read(&resolved)?;
let mime_type = from_path(&resolved).first_or_octet_stream().to_string();
Ok(StaticFile {
content,
path: resolved,
mime_type,
})
}
pub async fn resolve_path(&self, path: &str) -> Result<PathBuf, StaticError> {
let path = path.trim_start_matches('/');
if path.contains("..") {
return Err(StaticError::DirectoryTraversal(path.to_string()));
}
let file_path = self.root.join(path);
let canonical_file = file_path.canonicalize().map_err(|e| {
tracing::warn!(
"Failed to canonicalize path: {} (root: {}, error: {})",
file_path.display(),
self.root.display(),
e
);
StaticError::NotFound(path.to_string())
})?;
let canonical_root = self.root.canonicalize().map_err(|e| {
tracing::error!(
"Static files root directory does not exist: {} (error: {})",
self.root.display(),
e
);
StaticError::Io(e)
})?;
if !canonical_file.starts_with(&canonical_root) {
return Err(StaticError::DirectoryTraversal(path.to_string()));
}
if canonical_file.is_dir() {
for index_file in &self.index_files {
let index_path = canonical_file.join(index_file);
if index_path.exists() && index_path.is_file() {
return Ok(index_path);
}
}
return Err(StaticError::NotFound(format!(
"{} (directory without index file)",
path
)));
}
Ok(canonical_file)
}
}
pub type StaticResult<T> = Result<T, StaticError>;