use crate::request::Request;
use crate::response::Response;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone)]
pub struct StaticConfig {
pub root: PathBuf,
pub index: String,
pub directory_listing: bool,
pub max_age: u32,
pub gzip: bool,
}
impl Default for StaticConfig {
fn default() -> Self {
Self {
root: PathBuf::from("public"),
index: "index.html".to_string(),
directory_listing: false,
max_age: 3600,
gzip: true,
}
}
}
impl StaticConfig {
pub fn new(root: &str) -> Self {
Self {
root: PathBuf::from(root),
..Default::default()
}
}
pub fn index(mut self, index: &str) -> Self {
self.index = index.to_string();
self
}
pub fn directory_listing(mut self, enabled: bool) -> Self {
self.directory_listing = enabled;
self
}
pub fn max_age(mut self, seconds: u32) -> Self {
self.max_age = seconds;
self
}
}
pub async fn serve_file(path: &Path) -> Option<(Vec<u8>, String)> {
if let Ok(content) = fs::read(path).await {
let mime_type = get_mime_type(path);
Some((content, mime_type))
} else {
None
}
}
pub fn get_mime_type(path: &Path) -> String {
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match extension.to_lowercase().as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
"xml" => "application/xml; charset=utf-8",
"txt" => "text/plain; charset=utf-8",
"md" => "text/markdown; charset=utf-8",
"csv" => "text/csv; charset=utf-8",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"webp" => "image/webp",
"avif" => "image/avif",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"eot" => "application/vnd.ms-fontobject",
"pdf" => "application/pdf",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"zip" => "application/zip",
"tar" => "application/x-tar",
"gz" => "application/gzip",
"rar" => "application/vnd.rar",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"mp4" => "video/mp4",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
.to_string()
}
pub fn static_handler(
config: StaticConfig,
) -> impl Fn(Request, Response) -> std::pin::Pin<Box<dyn std::future::Future<Output = Response> + Send>> + Send + Sync + Clone + 'static {
move |req: Request, res: Response| {
let config = config.clone();
Box::pin(async move {
let request_path = req.path();
let file_path = request_path
.trim_start_matches('/')
.trim_start_matches("static/");
let full_path = config.root.join(file_path);
if !full_path.starts_with(&config.root) {
return res.status(403).json(serde_json::json!({
"error": "Forbidden",
"message": "Access denied"
}));
}
if full_path.is_dir() {
let index_path = full_path.join(&config.index);
if index_path.exists() {
if let Some((content, mime)) = serve_file(&index_path).await {
return res
.status(200)
.header("Content-Type", &mime)
.header("Cache-Control", &format!("max-age={}", config.max_age))
.send_bytes(content);
}
}
return res.status(404).json(serde_json::json!({
"error": "Not Found",
"message": "File not found"
}));
}
if let Some((content, mime)) = serve_file(&full_path).await {
res.status(200)
.header("Content-Type", &mime)
.header("Cache-Control", &format!("max-age={}", config.max_age))
.send_bytes(content)
} else {
res.status(404).json(serde_json::json!({
"error": "Not Found",
"message": "File not found"
}))
}
})
}
}