Skip to main content

ullun/
static_files.rs

1//! Static file serving
2
3use crate::error::{Error, Result};
4use crate::files::{serve_file, FileOptions};
5use crate::handler::Handler;
6use crate::request::Request;
7use crate::response::Response;
8use async_trait::async_trait;
9use std::path::PathBuf;
10use tokio::fs;
11
12/// Serves a directory: files, `index.html` for directories, ranges, conditional
13/// requests and streaming for large files (see [`crate::files`]).
14///
15/// Dotfiles (`.env`, `.git/`, ...) are never served, and requests that try to
16/// leave the directory (`..`, absolute paths, symlinks pointing outside) get `404`.
17pub struct StaticFileHandler {
18    root_dir: PathBuf,
19}
20
21impl StaticFileHandler {
22    pub fn new(root_dir: &str) -> Self {
23        Self {
24            root_dir: PathBuf::from(root_dir),
25        }
26    }
27
28    async fn serve(&self, req: &Request, file_path: &str) -> Result<Response> {
29        let not_found = || Error::not_found("File not found");
30
31        // %20 etc. in file names; decoded before validating, so %2e%2e is caught too
32        let decoded = urlencoding::decode(file_path).map_err(|_| not_found())?;
33        let mut relative = PathBuf::new();
34        for segment in decoded.split('/').filter(|s| !s.is_empty()) {
35            let unsafe_segment = segment.starts_with('.')
36                || segment.contains(['\\', '\0', ':'])
37                || segment.chars().any(char::is_control);
38            if unsafe_segment {
39                return Err(not_found());
40            }
41            relative.push(segment);
42        }
43
44        let canonical_root = fs::canonicalize(&self.root_dir)
45            .await
46            .map_err(|_| Error::not_found("Static directory not found"))?;
47        let mut canonical = fs::canonicalize(canonical_root.join(&relative))
48            .await
49            .map_err(|_| not_found())?;
50        // 404 rather than 403: do not reveal whether the target exists
51        if !canonical.starts_with(&canonical_root) {
52            return Err(not_found());
53        }
54
55        let meta = fs::metadata(&canonical).await.map_err(|_| not_found())?;
56        if meta.is_dir() {
57            let index = canonical.join("index.html");
58            if fs::metadata(&index).await.is_ok_and(|m| m.is_file()) {
59                canonical = index;
60            } else {
61                return Err(Error::forbidden("Directory listing not allowed"));
62            }
63        }
64
65        serve_file(
66            req,
67            &canonical,
68            &FileOptions::new().cache_control("public, max-age=3600"),
69        )
70        .await
71    }
72}
73
74#[async_trait]
75impl Handler for StaticFileHandler {
76    async fn call(&self, req: Request) -> Result<Response> {
77        let filepath = req.params.get("filepath").unwrap_or("").to_string();
78        self.serve(&req, &filepath).await
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use crate::files::content_type_for;
85    use std::path::Path;
86
87    #[test]
88    fn test_content_type_detection() {
89        assert_eq!(
90            content_type_for(Path::new("test.html")),
91            "text/html; charset=utf-8"
92        );
93        assert_eq!(
94            content_type_for(Path::new("test.css")),
95            "text/css; charset=utf-8"
96        );
97        assert_eq!(
98            content_type_for(Path::new("test.js")),
99            "application/javascript; charset=utf-8"
100        );
101        assert_eq!(content_type_for(Path::new("test.json")), "application/json");
102        assert_eq!(content_type_for(Path::new("test.png")), "image/png");
103        assert_eq!(
104            content_type_for(Path::new("unknown")),
105            "application/octet-stream"
106        );
107    }
108}