Skip to main content

rustlavel_http/
files.rs

1//! Serving static files out of a directory, for `public/`.
2
3use crate::handler::{BoxFuture, Handler};
4use crate::request::Request;
5use crate::response::Response;
6use crate::status::Status;
7use crate::url;
8use std::path::{Path, PathBuf};
9
10/// Serves files from a directory, refusing anything that escapes it.
11pub struct Files {
12    root: PathBuf,
13    /// Served when the request maps to a directory.
14    index: Option<String>,
15    /// What to send as `cache-control`.
16    cache_control: String,
17}
18
19impl Files {
20    pub fn new(root: impl Into<PathBuf>) -> Self {
21        Files {
22            root: root.into(),
23            index: Some("index.html".to_string()),
24            // `no-cache` does not mean "do not cache"; it means "cache, but ask
25            // before reusing". The browser still keeps the file and still gets
26            // a 304 from the `last-modified` below, so the saving is nearly all
27            // of it — and a rebuilt stylesheet is visible on the next reload
28            // rather than an hour later. Nothing here is fingerprinted, so a
29            // long `max-age` would be a promise the filename cannot keep; an
30            // application that does fingerprint its assets says so with
31            // [`Files::cache_control`].
32            cache_control: "no-cache".to_string(),
33        }
34    }
35
36    /// Sets the `cache-control` header sent with every file.
37    ///
38    /// Use it when the URLs carry a content hash, and the file at a given URL
39    /// therefore can never change:
40    ///
41    /// ```no_run
42    /// # use rustlavel_http::files::Files;
43    /// Files::new("public").cache_control("public, max-age=31536000, immutable");
44    /// ```
45    pub fn cache_control(mut self, value: impl Into<String>) -> Self {
46        self.cache_control = value.into();
47        self
48    }
49
50    pub fn without_index(mut self) -> Self {
51        self.index = None;
52        self
53    }
54
55    fn resolve(&self, path: &str) -> Option<PathBuf> {
56        let normalized = url::normalize_path(&url::decode(path))?;
57        let mut candidate = self.root.join(normalized.trim_start_matches('/'));
58
59        if candidate.is_dir() {
60            candidate = candidate.join(self.index.as_deref()?);
61        }
62
63        // Resolving symlinks last is what actually proves the file is inside
64        // the root — normalization alone cannot see through a link.
65        let real_root = self.root.canonicalize().ok()?;
66        let real = candidate.canonicalize().ok()?;
67        real.starts_with(&real_root).then_some(real)
68    }
69}
70
71impl Handler for Files {
72    fn call(&self, request: Request) -> BoxFuture<Response> {
73        let resolved = self.resolve(request.path());
74        let cache_control = self.cache_control.clone();
75        Box::pin(async move {
76            let Some(path) = resolved else {
77                return Response::not_found();
78            };
79            match tokio::fs::read(&path).await {
80                Ok(bytes) => {
81                    let mut response = Response::ok()
82                        .with_header("content-type", content_type(&path))
83                        .with_header("cache-control", cache_control);
84                    // The modification time is what lets a browser's
85                    // `If-Modified-Since` be answered with a 304 by the ETag
86                    // middleware, instead of the file being sent again.
87                    if let Some(modified) = modified_at(&path).await {
88                        response.headers.set("last-modified", crate::date::http_date(modified));
89                    }
90                    response.with_body(bytes)
91                }
92                Err(_) => Response::new(Status::NOT_FOUND).with_text("Not Found"),
93            }
94        })
95    }
96}
97
98/// The file's modification time as a unix timestamp, when the filesystem has one.
99async fn modified_at(path: &Path) -> Option<i64> {
100    let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
101    let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
102    i64::try_from(since_epoch.as_secs()).ok()
103}
104
105/// Guess a content type from the file extension.
106pub fn content_type(path: &Path) -> &'static str {
107    match path.extension().and_then(|e| e.to_str()).unwrap_or_default() {
108        "html" | "htm" => "text/html; charset=utf-8",
109        "css" => "text/css; charset=utf-8",
110        "js" | "mjs" => "text/javascript; charset=utf-8",
111        "json" => "application/json",
112        "svg" => "image/svg+xml",
113        "png" => "image/png",
114        "jpg" | "jpeg" => "image/jpeg",
115        "gif" => "image/gif",
116        "webp" => "image/webp",
117        "avif" => "image/avif",
118        "ico" => "image/x-icon",
119        "woff2" => "font/woff2",
120        "woff" => "font/woff",
121        "ttf" => "font/ttf",
122        "pdf" => "application/pdf",
123        "txt" | "md" => "text/plain; charset=utf-8",
124        "wasm" => "application/wasm",
125        "xml" => "application/xml",
126        _ => "application/octet-stream",
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::method::Method;
134
135    /// Each test gets its own directory: tests run concurrently, and a shared
136    /// fixture would be re-written underneath a test that is reading it.
137    fn fixture_dir(name: &str) -> PathBuf {
138        let dir = std::env::temp_dir().join(format!("rustlavel-files-{name}"));
139        std::fs::create_dir_all(dir.join("css")).unwrap();
140        std::fs::write(dir.join("index.html"), "<h1>home</h1>").unwrap();
141        std::fs::write(dir.join("css/app.css"), "body{}").unwrap();
142        dir
143    }
144
145    #[tokio::test]
146    async fn serves_a_file_with_its_content_type() {
147        let files = Files::new(fixture_dir("content-type"));
148        let response = files.call(Request::new(Method::Get, "/css/app.css")).await;
149
150        assert_eq!(response.status, Status::OK);
151        assert_eq!(response.body_string(), "body{}");
152        assert_eq!(response.headers.content_type(), Some("text/css"));
153    }
154
155    #[tokio::test]
156    async fn serves_the_index_for_a_directory() {
157        let files = Files::new(fixture_dir("index"));
158        let response = files.call(Request::new(Method::Get, "/")).await;
159
160        assert_eq!(response.body_string(), "<h1>home</h1>");
161    }
162
163    /// A stylesheet rebuilt by Tailwind keeps its name, so a long `max-age`
164    /// leaves the old one on screen until it expires. The default asks first.
165    #[tokio::test]
166    async fn revalidates_by_default_and_takes_an_override() {
167        let dir = fixture_dir("cache-control");
168
169        let response = Files::new(dir.clone()).call(Request::new(Method::Get, "/css/app.css")).await;
170        assert_eq!(response.headers.get("cache-control"), Some("no-cache"));
171        assert!(response.headers.get("last-modified").is_some(), "304s need a validator");
172
173        let hashed = Files::new(dir).cache_control("public, max-age=31536000, immutable");
174        let response = hashed.call(Request::new(Method::Get, "/css/app.css")).await;
175        assert_eq!(response.headers.get("cache-control"), Some("public, max-age=31536000, immutable"));
176    }
177
178    #[tokio::test]
179    async fn refuses_to_escape_the_root() {
180        let files = Files::new(fixture_dir("traversal"));
181
182        for attempt in ["/../../../etc/passwd", "/css/../../etc/passwd", "/%2e%2e/%2e%2e/etc/passwd"] {
183            let response = files.call(Request::new(Method::Get, attempt)).await;
184            assert_eq!(response.status, Status::NOT_FOUND, "{attempt} should not resolve");
185        }
186    }
187}