use crate::error::{Error, Result};
use crate::files::{serve_file, FileOptions};
use crate::handler::Handler;
use crate::request::Request;
use crate::response::Response;
use async_trait::async_trait;
use std::path::PathBuf;
use tokio::fs;
pub struct StaticFileHandler {
root_dir: PathBuf,
}
impl StaticFileHandler {
pub fn new(root_dir: &str) -> Self {
Self {
root_dir: PathBuf::from(root_dir),
}
}
async fn serve(&self, req: &Request, file_path: &str) -> Result<Response> {
let not_found = || Error::not_found("File not found");
let decoded = urlencoding::decode(file_path).map_err(|_| not_found())?;
let mut relative = PathBuf::new();
for segment in decoded.split('/').filter(|s| !s.is_empty()) {
let unsafe_segment = segment.starts_with('.')
|| segment.contains(['\\', '\0', ':'])
|| segment.chars().any(char::is_control);
if unsafe_segment {
return Err(not_found());
}
relative.push(segment);
}
let canonical_root = fs::canonicalize(&self.root_dir)
.await
.map_err(|_| Error::not_found("Static directory not found"))?;
let mut canonical = fs::canonicalize(canonical_root.join(&relative))
.await
.map_err(|_| not_found())?;
if !canonical.starts_with(&canonical_root) {
return Err(not_found());
}
let meta = fs::metadata(&canonical).await.map_err(|_| not_found())?;
if meta.is_dir() {
let index = canonical.join("index.html");
if fs::metadata(&index).await.is_ok_and(|m| m.is_file()) {
canonical = index;
} else {
return Err(Error::forbidden("Directory listing not allowed"));
}
}
serve_file(
req,
&canonical,
&FileOptions::new().cache_control("public, max-age=3600"),
)
.await
}
}
#[async_trait]
impl Handler for StaticFileHandler {
async fn call(&self, req: Request) -> Result<Response> {
let filepath = req.params.get("filepath").unwrap_or("").to_string();
self.serve(&req, &filepath).await
}
}
#[cfg(test)]
mod tests {
use crate::files::content_type_for;
use std::path::Path;
#[test]
fn test_content_type_detection() {
assert_eq!(
content_type_for(Path::new("test.html")),
"text/html; charset=utf-8"
);
assert_eq!(
content_type_for(Path::new("test.css")),
"text/css; charset=utf-8"
);
assert_eq!(
content_type_for(Path::new("test.js")),
"application/javascript; charset=utf-8"
);
assert_eq!(content_type_for(Path::new("test.json")), "application/json");
assert_eq!(content_type_for(Path::new("test.png")), "image/png");
assert_eq!(
content_type_for(Path::new("unknown")),
"application/octet-stream"
);
}
}