Skip to main content

ullun/
static_files.rs

1//! Static file serving
2
3use 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        // Sanitize path to prevent directory traversal
24        let file_path = file_path.trim_start_matches('/');
25        let full_path = self.root_dir.join(file_path);
26
27        // Ensure the path is within root_dir
28        let canonical_root = fs::canonicalize(&self.root_dir)
29            .await
30            .map_err(|_| Error::not_found("Static directory not found"))?;
31
32        // Canonicalize and validate path (prevents TOCTOU race)
33        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            // 404 instead of 403: do not reveal whether the target exists
39            return Err(Error::not_found("File not found"));
40        }
41
42        // Check if it's a directory
43        let metadata = fs::metadata(&canonical_path)
44            .await
45            .map_err(|_| Error::not_found("File not found"))?;
46
47        if metadata.is_dir() {
48            // Try to serve index.html
49            let index_path = canonical_path.join("index.html");
50            if fs::metadata(&index_path).await.is_ok() {
51                return self.serve_file_content(&index_path).await;
52            }
53            return Err(Error::forbidden("Directory listing not allowed"));
54        }
55
56        self.serve_file_content(&canonical_path).await
57    }
58
59    async fn serve_file_content(&self, path: &Path) -> Result<Response> {
60        let content = fs::read(path)
61            .await
62            .map_err(|_| Error::internal("Failed to read file"))?;
63
64        let content_type = guess_content_type(path);
65
66        Ok(Response::new(http::StatusCode::OK)
67            .with_header("content-type", content_type)
68            .with_header("cache-control", "public, max-age=3600")
69            .with_body(content))
70    }
71}
72
73#[async_trait]
74impl Handler for StaticFileHandler {
75    async fn call(&self, req: Request) -> Result<Response> {
76        let filepath = req.params.get("filepath").unwrap_or("");
77        self.serve_file(filepath).await
78    }
79}
80
81fn guess_content_type(path: &Path) -> &'static str {
82    match path.extension().and_then(|e| e.to_str()) {
83        Some("html") => "text/html; charset=utf-8",
84        Some("css") => "text/css; charset=utf-8",
85        Some("js") => "application/javascript; charset=utf-8",
86        Some("json") => "application/json",
87        Some("png") => "image/png",
88        Some("jpg") | Some("jpeg") => "image/jpeg",
89        Some("gif") => "image/gif",
90        Some("svg") => "image/svg+xml",
91        Some("ico") => "image/x-icon",
92        Some("woff") => "font/woff",
93        Some("woff2") => "font/woff2",
94        Some("ttf") => "font/ttf",
95        Some("txt") => "text/plain; charset=utf-8",
96        Some("xml") => "application/xml",
97        Some("pdf") => "application/pdf",
98        Some("zip") => "application/zip",
99        _ => "application/octet-stream",
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_content_type_detection() {
109        assert_eq!(
110            guess_content_type(Path::new("test.html")),
111            "text/html; charset=utf-8"
112        );
113        assert_eq!(
114            guess_content_type(Path::new("test.css")),
115            "text/css; charset=utf-8"
116        );
117        assert_eq!(
118            guess_content_type(Path::new("test.js")),
119            "application/javascript; charset=utf-8"
120        );
121        assert_eq!(
122            guess_content_type(Path::new("test.json")),
123            "application/json"
124        );
125        assert_eq!(guess_content_type(Path::new("test.png")), "image/png");
126        assert_eq!(
127            guess_content_type(Path::new("unknown")),
128            "application/octet-stream"
129        );
130    }
131}