1use crate::error::{Error, Result};
4use crate::handler::Handler;
5use crate::request::Request;
6use crate::response::Response;
7use async_trait::async_trait;
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11pub struct StaticFileHandler {
12 root_dir: PathBuf,
13}
14
15impl StaticFileHandler {
16 pub fn new(root_dir: &str) -> Self {
17 Self {
18 root_dir: PathBuf::from(root_dir),
19 }
20 }
21
22 async fn serve_file(&self, file_path: &str) -> Result<Response> {
23 let file_path = file_path.trim_start_matches('/');
25 let full_path = self.root_dir.join(file_path);
26
27 let canonical_root = fs::canonicalize(&self.root_dir)
29 .await
30 .map_err(|_| Error::not_found("Static directory not found"))?;
31
32 let canonical_path = fs::canonicalize(&full_path)
34 .await
35 .map_err(|_| Error::not_found("File not found"))?;
36
37 if !canonical_path.starts_with(&canonical_root) {
38 return Err(Error::forbidden("Access denied"));
39 }
40
41 let metadata = fs::metadata(&canonical_path)
43 .await
44 .map_err(|_| Error::not_found("File not found"))?;
45
46 if metadata.is_dir() {
47 let index_path = canonical_path.join("index.html");
49 if index_path.exists() {
50 return self.serve_file_content(&index_path).await;
51 }
52 return Err(Error::forbidden("Directory listing not allowed"));
53 }
54
55 self.serve_file_content(&canonical_path).await
56 }
57
58 async fn serve_file_content(&self, path: &Path) -> Result<Response> {
59 let content = fs::read(path)
60 .await
61 .map_err(|_| Error::internal("Failed to read file"))?;
62
63 let content_type = guess_content_type(path);
64
65 Ok(Response::new(http::StatusCode::OK)
66 .with_header("content-type", content_type)
67 .with_header("cache-control", "public, max-age=3600")
68 .with_body(content))
69 }
70}
71
72#[async_trait]
73impl Handler for StaticFileHandler {
74 async fn call(&self, req: Request) -> Result<Response> {
75 let filepath = req.params.get("filepath").unwrap_or("");
76 self.serve_file(filepath).await
77 }
78}
79
80fn guess_content_type(path: &Path) -> &'static str {
81 match path.extension().and_then(|e| e.to_str()) {
82 Some("html") => "text/html; charset=utf-8",
83 Some("css") => "text/css; charset=utf-8",
84 Some("js") => "application/javascript; charset=utf-8",
85 Some("json") => "application/json",
86 Some("png") => "image/png",
87 Some("jpg") | Some("jpeg") => "image/jpeg",
88 Some("gif") => "image/gif",
89 Some("svg") => "image/svg+xml",
90 Some("ico") => "image/x-icon",
91 Some("woff") => "font/woff",
92 Some("woff2") => "font/woff2",
93 Some("ttf") => "font/ttf",
94 Some("txt") => "text/plain; charset=utf-8",
95 Some("xml") => "application/xml",
96 Some("pdf") => "application/pdf",
97 Some("zip") => "application/zip",
98 _ => "application/octet-stream",
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn test_content_type_detection() {
108 assert_eq!(guess_content_type(Path::new("test.html")), "text/html; charset=utf-8");
109 assert_eq!(guess_content_type(Path::new("test.css")), "text/css; charset=utf-8");
110 assert_eq!(guess_content_type(Path::new("test.js")), "application/javascript; charset=utf-8");
111 assert_eq!(guess_content_type(Path::new("test.json")), "application/json");
112 assert_eq!(guess_content_type(Path::new("test.png")), "image/png");
113 assert_eq!(guess_content_type(Path::new("unknown")), "application/octet-stream");
114 }
115}