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}
16
17impl Files {
18 pub fn new(root: impl Into<PathBuf>) -> Self {
19 Files { root: root.into(), index: Some("index.html".to_string()) }
20 }
21
22 pub fn without_index(mut self) -> Self {
23 self.index = None;
24 self
25 }
26
27 fn resolve(&self, path: &str) -> Option<PathBuf> {
28 let normalized = url::normalize_path(&url::decode(path))?;
29 let mut candidate = self.root.join(normalized.trim_start_matches('/'));
30
31 if candidate.is_dir() {
32 candidate = candidate.join(self.index.as_deref()?);
33 }
34
35 let real_root = self.root.canonicalize().ok()?;
38 let real = candidate.canonicalize().ok()?;
39 real.starts_with(&real_root).then_some(real)
40 }
41}
42
43impl Handler for Files {
44 fn call(&self, request: Request) -> BoxFuture<Response> {
45 let resolved = self.resolve(request.path());
46 Box::pin(async move {
47 let Some(path) = resolved else {
48 return Response::not_found();
49 };
50 match tokio::fs::read(&path).await {
51 Ok(bytes) => {
52 let mut response = Response::ok()
53 .with_header("content-type", content_type(&path))
54 .with_header("cache-control", "public, max-age=3600");
55 if let Some(modified) = modified_at(&path).await {
59 response.headers.set("last-modified", crate::date::http_date(modified));
60 }
61 response.with_body(bytes)
62 }
63 Err(_) => Response::new(Status::NOT_FOUND).with_text("Not Found"),
64 }
65 })
66 }
67}
68
69async fn modified_at(path: &Path) -> Option<i64> {
71 let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
72 let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
73 i64::try_from(since_epoch.as_secs()).ok()
74}
75
76pub fn content_type(path: &Path) -> &'static str {
78 match path.extension().and_then(|e| e.to_str()).unwrap_or_default() {
79 "html" | "htm" => "text/html; charset=utf-8",
80 "css" => "text/css; charset=utf-8",
81 "js" | "mjs" => "text/javascript; charset=utf-8",
82 "json" => "application/json",
83 "svg" => "image/svg+xml",
84 "png" => "image/png",
85 "jpg" | "jpeg" => "image/jpeg",
86 "gif" => "image/gif",
87 "webp" => "image/webp",
88 "avif" => "image/avif",
89 "ico" => "image/x-icon",
90 "woff2" => "font/woff2",
91 "woff" => "font/woff",
92 "ttf" => "font/ttf",
93 "pdf" => "application/pdf",
94 "txt" | "md" => "text/plain; charset=utf-8",
95 "wasm" => "application/wasm",
96 "xml" => "application/xml",
97 _ => "application/octet-stream",
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::method::Method;
105
106 fn fixture_dir(name: &str) -> PathBuf {
109 let dir = std::env::temp_dir().join(format!("rustlavel-files-{name}"));
110 std::fs::create_dir_all(dir.join("css")).unwrap();
111 std::fs::write(dir.join("index.html"), "<h1>home</h1>").unwrap();
112 std::fs::write(dir.join("css/app.css"), "body{}").unwrap();
113 dir
114 }
115
116 #[tokio::test]
117 async fn serves_a_file_with_its_content_type() {
118 let files = Files::new(fixture_dir("content-type"));
119 let response = files.call(Request::new(Method::Get, "/css/app.css")).await;
120
121 assert_eq!(response.status, Status::OK);
122 assert_eq!(response.body_string(), "body{}");
123 assert_eq!(response.headers.content_type(), Some("text/css"));
124 }
125
126 #[tokio::test]
127 async fn serves_the_index_for_a_directory() {
128 let files = Files::new(fixture_dir("index"));
129 let response = files.call(Request::new(Method::Get, "/")).await;
130
131 assert_eq!(response.body_string(), "<h1>home</h1>");
132 }
133
134 #[tokio::test]
135 async fn refuses_to_escape_the_root() {
136 let files = Files::new(fixture_dir("traversal"));
137
138 for attempt in ["/../../../etc/passwd", "/css/../../etc/passwd", "/%2e%2e/%2e%2e/etc/passwd"] {
139 let response = files.call(Request::new(Method::Get, attempt)).await;
140 assert_eq!(response.status, Status::NOT_FOUND, "{attempt} should not resolve");
141 }
142 }
143}