1use crate::body::Body;
17use crate::call::Call;
18use crate::error::{Error, Result};
19use crate::handler::{Handler, IntoHandler};
20use crate::response::Response;
21use bytes::Bytes;
22use std::path::{Component, Path, PathBuf};
23use tokio::io::AsyncReadExt;
24
25#[derive(Debug, Clone)]
28pub struct StaticFiles {
29 root: PathBuf,
30 index: Option<String>,
31}
32
33impl StaticFiles {
34 pub fn dir(root: impl Into<PathBuf>) -> Self {
36 Self {
37 root: root.into(),
38 index: None,
39 }
40 }
41
42 pub fn index(mut self, file: impl Into<String>) -> Self {
44 self.index = Some(file.into());
45 self
46 }
47
48 pub fn handler(self) -> impl Handler {
51 let cfg = self;
52 let closure = move |call: Call| {
57 let cfg = cfg.clone();
58 async move { cfg.serve(&call).await }
59 };
60 closure.into_handler()
61 }
62
63 async fn serve(&self, call: &Call) -> Result<Response> {
64 let rel = call
66 .params_iter()
67 .map(|(_, v)| v.to_string())
68 .next()
69 .unwrap_or_default();
70
71 let safe = sanitize(&rel).ok_or_else(|| Error::not_found("not found"))?;
72 let mut path = self.root.join(safe);
73
74 let meta = tokio::fs::metadata(&path)
75 .await
76 .map_err(|_| Error::not_found("not found"))?;
77 if meta.is_dir() {
78 match &self.index {
79 Some(index) => path = path.join(index),
80 None => return Err(Error::not_found("not found")),
81 }
82 }
83
84 let canonical = tokio::fs::canonicalize(&path)
86 .await
87 .map_err(|_| Error::not_found("not found"))?;
88 let canonical_root = tokio::fs::canonicalize(&self.root)
89 .await
90 .map_err(|_| Error::internal("static root does not exist"))?;
91 if !canonical.starts_with(&canonical_root) {
92 return Err(Error::not_found("not found"));
93 }
94
95 let file = tokio::fs::File::open(&canonical)
96 .await
97 .map_err(|_| Error::not_found("not found"))?;
98 let content_type = content_type_for(&canonical);
99 Ok(Response::stream(
100 content_type,
101 Body::from_stream(file_stream(file)),
102 ))
103 }
104}
105
106fn sanitize(rel: &str) -> Option<PathBuf> {
109 let mut out = PathBuf::new();
110 for component in Path::new(rel).components() {
111 match component {
112 Component::Normal(c) => out.push(c),
113 Component::CurDir => {}
114 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
116 }
117 }
118 Some(out)
119}
120
121fn file_stream(
123 file: tokio::fs::File,
124) -> impl futures_util::stream::Stream<Item = std::result::Result<Bytes, std::io::Error>> {
125 futures_util::stream::unfold((file, false), |(mut file, done)| async move {
126 if done {
127 return None;
128 }
129 let mut buf = vec![0u8; 64 * 1024];
130 match file.read(&mut buf).await {
131 Ok(0) => None,
132 Ok(n) => {
133 buf.truncate(n);
134 Some((Ok(Bytes::from(buf)), (file, false)))
135 }
136 Err(e) => Some((Err(e), (file, true))),
137 }
138 })
139}
140
141fn content_type_for(path: &Path) -> &'static str {
144 let ext = path
145 .extension()
146 .and_then(|e| e.to_str())
147 .unwrap_or("")
148 .to_ascii_lowercase();
149 match ext.as_str() {
150 "html" | "htm" => "text/html; charset=utf-8",
151 "css" => "text/css; charset=utf-8",
152 "js" | "mjs" => "text/javascript; charset=utf-8",
153 "json" => "application/json",
154 "wasm" => "application/wasm",
155 "txt" => "text/plain; charset=utf-8",
156 "csv" => "text/csv; charset=utf-8",
157 "xml" => "application/xml",
158 "svg" => "image/svg+xml",
159 "png" => "image/png",
160 "jpg" | "jpeg" => "image/jpeg",
161 "gif" => "image/gif",
162 "webp" => "image/webp",
163 "ico" => "image/x-icon",
164 "woff" => "font/woff",
165 "woff2" => "font/woff2",
166 "ttf" => "font/ttf",
167 "pdf" => "application/pdf",
168 "mp4" => "video/mp4",
169 "mp3" => "audio/mpeg",
170 _ => "application/octet-stream",
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::{Churust, TestClient};
178 use http::StatusCode;
179
180 fn temp_dir_with_files() -> PathBuf {
181 let base = std::env::temp_dir().join(format!("churust-fs-{}", std::process::id()));
183 let _ = std::fs::create_dir_all(base.join("sub"));
184 std::fs::write(base.join("hello.txt"), b"hello world").unwrap();
185 std::fs::write(base.join("page.html"), b"<h1>hi</h1>").unwrap();
186 std::fs::write(base.join("index.html"), b"INDEX").unwrap();
187 base
188 }
189
190 fn app(root: PathBuf) -> crate::App {
191 Churust::server()
192 .routing(move |r| {
193 r.get(
194 "/files/{path...}",
195 StaticFiles::dir(root.clone()).index("index.html").handler(),
196 );
197 })
198 .build()
199 }
200
201 #[tokio::test]
202 async fn serves_file_with_content_type() {
203 let root = temp_dir_with_files();
204 let res = TestClient::new(app(root))
205 .get("/files/hello.txt")
206 .send()
207 .await;
208 assert_eq!(res.status(), StatusCode::OK);
209 assert_eq!(
210 res.header("content-type"),
211 Some("text/plain; charset=utf-8")
212 );
213 assert_eq!(res.text(), "hello world");
214 }
215
216 #[tokio::test]
217 async fn html_content_type() {
218 let root = temp_dir_with_files();
219 let res = TestClient::new(app(root))
220 .get("/files/page.html")
221 .send()
222 .await;
223 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
224 }
225
226 #[tokio::test]
227 async fn missing_file_is_404() {
228 let root = temp_dir_with_files();
229 let res = TestClient::new(app(root))
230 .get("/files/nope.txt")
231 .send()
232 .await;
233 assert_eq!(res.status(), StatusCode::NOT_FOUND);
234 }
235
236 #[tokio::test]
237 async fn directory_serves_index() {
238 let root = temp_dir_with_files();
239 let res = TestClient::new(app(root)).get("/files/sub").send().await;
240 assert_eq!(res.status(), StatusCode::NOT_FOUND);
242 }
243
244 #[test]
245 fn sanitize_rejects_traversal() {
246 assert!(sanitize("../etc/passwd").is_none());
247 assert!(sanitize("/etc/passwd").is_none());
248 assert!(sanitize("a/../../b").is_none());
249 assert_eq!(sanitize("css/app.css"), Some(PathBuf::from("css/app.css")));
250 assert_eq!(sanitize("./x.txt"), Some(PathBuf::from("x.txt")));
251 }
252}