1use crate::core::Handler;
4use crate::error::WebServerError;
5use crate::types::{Request, Response, StatusCode};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9#[derive(Debug, Clone)]
11pub struct StaticFileConfig {
12 pub root_dir: PathBuf,
14 pub url_prefix: String,
16 pub show_index: bool,
18 pub index_files: Vec<String>,
20 pub compress: bool,
22 pub cache: bool,
24 pub cache_max_age: u32,
26}
27
28impl Default for StaticFileConfig {
29 fn default() -> Self {
30 Self {
31 root_dir: PathBuf::from("./static"),
32 url_prefix: "/static".to_string(),
33 show_index: false,
34 index_files: vec!["index.html".to_string(), "index.htm".to_string()],
35 compress: true,
36 cache: true,
37 cache_max_age: 3600, }
39 }
40}
41
42#[derive(Debug, Clone, Default)]
44pub struct StaticFileHandler {
45 config: StaticFileConfig,
46}
47
48impl StaticFileHandler {
49 pub fn new(config: StaticFileConfig) -> Self {
51 Self { config }
52 }
53
54 pub fn root_dir(mut self, root_dir: impl Into<PathBuf>) -> Self {
56 self.config.root_dir = root_dir.into();
57 self
58 }
59
60 pub fn url_prefix(mut self, prefix: impl Into<String>) -> Self {
62 self.config.url_prefix = prefix.into();
63 self
64 }
65
66 pub fn show_index(mut self, show: bool) -> Self {
68 self.config.show_index = show;
69 self
70 }
71
72 pub fn cache(mut self, cache: bool) -> Self {
74 self.config.cache = cache;
75 self
76 }
77
78 pub fn cache_max_age(mut self, max_age: u32) -> Self {
80 self.config.cache_max_age = max_age;
81 self
82 }
83
84 async fn serve_file(&self, file_path: &Path) -> Result<Response, WebServerError> {
86 if !file_path.exists() {
88 return Ok(Response::new(StatusCode::NOT_FOUND).body("File not found"));
89 }
90
91 if !file_path.is_file() {
92 return Ok(Response::new(StatusCode::FORBIDDEN).body("Not a file"));
93 }
94
95 let content = tokio::fs::read(file_path)
97 .await
98 .map_err(|e| WebServerError::custom(format!("Failed to read file: {}", e)))?;
99
100 let mut response = Response::ok().body(content);
101
102 if let Some(content_type) = mime_type_for_file(file_path) {
104 response = response.header("Content-Type", content_type);
105 }
106
107 if self.config.cache {
109 response = response
110 .header(
111 "Cache-Control",
112 format!("public, max-age={}", self.config.cache_max_age),
113 )
114 .header("ETag", generate_etag(file_path).await?);
115 }
116
117 Ok(response)
118 }
119
120 async fn serve_directory(
122 &self,
123 dir_path: &Path,
124 url_path: &str,
125 ) -> Result<Response, WebServerError> {
126 if !self.config.show_index {
127 return Ok(Response::new(StatusCode::FORBIDDEN).body("Directory listing disabled"));
128 }
129
130 for index_file in &self.config.index_files {
132 let index_path = dir_path.join(index_file);
133 if index_path.is_file() {
134 return self.serve_file(&index_path).await;
135 }
136 }
137
138 let entries = tokio::fs::read_dir(dir_path)
140 .await
141 .map_err(|e| WebServerError::custom(format!("Failed to read directory: {}", e)))?;
142
143 let mut html = String::new();
144 html.push_str(&format!(
145 "<html><head><title>Index of {}</title></head><body>",
146 url_path
147 ));
148 html.push_str(&format!("<h1>Index of {}</h1><hr><pre>", url_path));
149
150 if url_path != "/" {
152 let parent_path = Path::new(url_path)
153 .parent()
154 .and_then(|p| p.to_str())
155 .unwrap_or("/");
156 html.push_str(&format!("<a href=\"{}\">../</a>\n", parent_path));
157 }
158
159 let mut entries_vec = Vec::new();
161 let mut entries_stream = entries;
162 while let Some(entry) = entries_stream
163 .next_entry()
164 .await
165 .map_err(|e| WebServerError::custom(format!("Failed to read directory entry: {}", e)))?
166 {
167 entries_vec.push(entry);
168 }
169
170 entries_vec.sort_by_key(|a| a.file_name());
172
173 for entry in entries_vec {
175 let file_name = entry.file_name();
176 let file_name_str = file_name.to_string_lossy();
177 let metadata = entry
178 .metadata()
179 .await
180 .map_err(|e| WebServerError::custom(format!("Failed to read metadata: {}", e)))?;
181
182 let url_name = if metadata.is_dir() {
183 format!("{}/", file_name_str)
184 } else {
185 file_name_str.to_string()
186 };
187
188 let full_url = if url_path.ends_with('/') {
189 format!("{}{}", url_path, url_name)
190 } else {
191 format!("{}/{}", url_path, url_name)
192 };
193
194 html.push_str(&format!("<a href=\"{}\">{}</a>\n", full_url, url_name));
195 }
196
197 html.push_str("</pre><hr></body></html>");
198
199 Ok(Response::ok()
200 .header("Content-Type", "text/html; charset=utf-8")
201 .body(html))
202 }
203}
204
205impl StaticFileHandler {
206 pub async fn handle(&self, request: Request) -> Result<Response, WebServerError> {
208 let url_path = request.uri.path();
209
210 if !url_path.starts_with(&self.config.url_prefix) {
212 return Ok(Response::new(StatusCode::NOT_FOUND).body("Not found"));
213 }
214
215 let relative_path = url_path
217 .strip_prefix(&self.config.url_prefix)
218 .unwrap_or(url_path)
219 .trim_start_matches('/');
220
221 let file_path = self.config.root_dir.join(relative_path);
223
224 if !file_path.starts_with(&self.config.root_dir) {
226 return Ok(Response::new(StatusCode::FORBIDDEN).body("Access denied"));
227 }
228
229 if file_path.is_file() {
230 self.serve_file(&file_path).await
231 } else if file_path.is_dir() {
232 self.serve_directory(&file_path, url_path).await
233 } else {
234 Ok(Response::new(StatusCode::NOT_FOUND).body("File not found"))
235 }
236 }
237}
238
239impl Handler<()> for StaticFileHandler {
241 fn into_handler(self) -> crate::core::HandlerFn {
242 Arc::new(move |req| {
243 let handler = self.clone();
244 Box::pin(async move { handler.handle(req).await })
245 })
246 }
247}
248
249async fn generate_etag(file_path: &Path) -> Result<String, WebServerError> {
251 let metadata = tokio::fs::metadata(file_path)
252 .await
253 .map_err(|e| WebServerError::custom(format!("Failed to read file metadata: {}", e)))?;
254
255 let modified = metadata
256 .modified()
257 .map_err(|e| WebServerError::custom(format!("Failed to get modification time: {}", e)))?;
258
259 let timestamp = modified
260 .duration_since(std::time::UNIX_EPOCH)
261 .map_err(|e| WebServerError::custom(format!("Invalid modification time: {}", e)))?
262 .as_secs();
263
264 Ok(format!("\"{}\"", timestamp))
265}
266
267fn mime_type_for_file(file_path: &Path) -> Option<String> {
269 let extension = file_path.extension()?.to_str()?.to_lowercase();
270
271 let mime_type = match extension.as_str() {
272 "html" | "htm" => "text/html",
273 "css" => "text/css",
274 "js" => "application/javascript",
275 "json" => "application/json",
276 "xml" => "application/xml",
277 "txt" => "text/plain",
278 "pdf" => "application/pdf",
279 "zip" => "application/zip",
280 "jpg" | "jpeg" => "image/jpeg",
281 "png" => "image/png",
282 "gif" => "image/gif",
283 "svg" => "image/svg+xml",
284 "ico" => "image/x-icon",
285 "woff" => "font/woff",
286 "woff2" => "font/woff2",
287 "ttf" => "font/ttf",
288 "eot" => "application/vnd.ms-fontobject",
289 "mp4" => "video/mp4",
290 "mp3" => "audio/mpeg",
291 "wav" => "audio/wav",
292 _ => "application/octet-stream",
293 };
294
295 Some(mime_type.to_string())
296}
297
298pub fn static_files(config: StaticFileConfig) -> Arc<StaticFileHandler> {
300 Arc::new(StaticFileHandler::new(config))
301}
302
303pub fn serve_static(root_dir: impl Into<PathBuf>) -> Arc<StaticFileHandler> {
305 Arc::new(StaticFileHandler::new(StaticFileConfig {
306 root_dir: root_dir.into(),
307 ..Default::default()
308 }))
309}
310
311pub fn serve_static_with_prefix(
313 root_dir: impl Into<PathBuf>,
314 prefix: impl Into<String>,
315) -> Arc<StaticFileHandler> {
316 Arc::new(StaticFileHandler::new(StaticFileConfig {
317 root_dir: root_dir.into(),
318 url_prefix: prefix.into(),
319 ..Default::default()
320 }))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_mime_type_detection() {
329 assert_eq!(
330 mime_type_for_file(Path::new("test.html")),
331 Some("text/html".to_string())
332 );
333 assert_eq!(
334 mime_type_for_file(Path::new("style.css")),
335 Some("text/css".to_string())
336 );
337 assert_eq!(
338 mime_type_for_file(Path::new("app.js")),
339 Some("application/javascript".to_string())
340 );
341 assert_eq!(
342 mime_type_for_file(Path::new("image.png")),
343 Some("image/png".to_string())
344 );
345 assert_eq!(
346 mime_type_for_file(Path::new("unknown.xyz")),
347 Some("application/octet-stream".to_string())
348 );
349 }
350
351 #[test]
352 fn test_static_file_config() {
353 let config = StaticFileConfig {
354 root_dir: PathBuf::from("./public"),
355 url_prefix: "/assets".to_string(),
356 show_index: true,
357 cache: false,
358 ..Default::default()
359 };
360
361 assert_eq!(config.root_dir, PathBuf::from("./public"));
362 assert_eq!(config.url_prefix, "/assets");
363 assert!(config.show_index);
364 assert!(!config.cache);
365 }
366
367 #[tokio::test]
368 async fn test_static_handler_creation() {
369 let handler = StaticFileHandler::default()
370 .root_dir("./test_files")
371 .url_prefix("/files");
372
373 assert_eq!(handler.config.root_dir, PathBuf::from("./test_files"));
374 assert_eq!(handler.config.url_prefix, "/files");
375 }
376}