1use 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
10pub struct Files {
12 root: PathBuf,
13 index: Option<String>,
15 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 cache_control: "no-cache".to_string(),
33 }
34 }
35
36 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 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 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
98async 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
105pub 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 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 #[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}