1use crate::error::ApiError;
18use crate::response::{IntoResponse, Response};
19use bytes::Bytes;
20use http::{header, StatusCode};
21use http_body_util::Full;
22use std::path::{Path, PathBuf};
23use std::time::SystemTime;
24use tokio::fs;
25
26fn mime_type_for_extension(extension: &str) -> &'static str {
28 match extension.to_lowercase().as_str() {
29 "html" | "htm" => "text/html; charset=utf-8",
31 "css" => "text/css; charset=utf-8",
32 "js" | "mjs" => "text/javascript; charset=utf-8",
33 "json" => "application/json",
34 "xml" => "application/xml",
35 "txt" => "text/plain; charset=utf-8",
36 "md" => "text/markdown; charset=utf-8",
37 "csv" => "text/csv",
38
39 "png" => "image/png",
41 "jpg" | "jpeg" => "image/jpeg",
42 "gif" => "image/gif",
43 "webp" => "image/webp",
44 "svg" => "image/svg+xml",
45 "ico" => "image/x-icon",
46 "bmp" => "image/bmp",
47 "avif" => "image/avif",
48
49 "woff" => "font/woff",
51 "woff2" => "font/woff2",
52 "ttf" => "font/ttf",
53 "otf" => "font/otf",
54 "eot" => "application/vnd.ms-fontobject",
55
56 "mp3" => "audio/mpeg",
58 "wav" => "audio/wav",
59 "ogg" => "audio/ogg",
60 "mp4" => "video/mp4",
61 "webm" => "video/webm",
62
63 "pdf" => "application/pdf",
65 "zip" => "application/zip",
66 "tar" => "application/x-tar",
67 "gz" => "application/gzip",
68
69 "wasm" => "application/wasm",
71
72 _ => "application/octet-stream",
74 }
75}
76
77fn calculate_etag(modified: SystemTime, size: u64) -> String {
79 let timestamp = modified
80 .duration_since(SystemTime::UNIX_EPOCH)
81 .map(|d| d.as_secs())
82 .unwrap_or(0);
83 format!("\"{:x}-{:x}\"", timestamp, size)
84}
85
86fn format_http_date(time: SystemTime) -> String {
88 use std::time::Duration;
89
90 let duration = time
91 .duration_since(SystemTime::UNIX_EPOCH)
92 .unwrap_or(Duration::ZERO);
93 let secs = duration.as_secs();
94
95 let days = secs / 86400;
98 let remaining = secs % 86400;
99 let hours = remaining / 3600;
100 let minutes = (remaining % 3600) / 60;
101 let seconds = remaining % 60;
102
103 let days_since_epoch = days;
105 let day_of_week = (days_since_epoch + 4) % 7; let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
107 let month_names = [
108 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
109 ];
110
111 let mut year = 1970;
113 let mut remaining_days = days_since_epoch as i64;
114
115 loop {
116 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
117 if remaining_days < days_in_year {
118 break;
119 }
120 remaining_days -= days_in_year;
121 year += 1;
122 }
123
124 let mut month = 0;
125 let days_in_months = if is_leap_year(year) {
126 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
127 } else {
128 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
129 };
130
131 for (i, &days_in_month) in days_in_months.iter().enumerate() {
132 if remaining_days < days_in_month as i64 {
133 month = i;
134 break;
135 }
136 remaining_days -= days_in_month as i64;
137 }
138
139 let day = remaining_days + 1;
140
141 format!(
142 "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
143 day_names[day_of_week as usize], day, month_names[month], year, hours, minutes, seconds
144 )
145}
146
147fn is_leap_year(year: i64) -> bool {
148 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
149}
150
151#[derive(Clone)]
153pub struct StaticFileConfig {
154 pub root: PathBuf,
156 pub prefix: String,
158 pub serve_index: bool,
160 pub index_file: String,
162 pub etag: bool,
164 pub last_modified: bool,
166 pub max_age: u64,
168 pub fallback: Option<String>,
170}
171
172impl Default for StaticFileConfig {
173 fn default() -> Self {
174 Self {
175 root: PathBuf::from("./static"),
176 prefix: "/".to_string(),
177 serve_index: true,
178 index_file: "index.html".to_string(),
179 etag: true,
180 last_modified: true,
181 max_age: 3600, fallback: None,
183 }
184 }
185}
186
187impl StaticFileConfig {
188 pub fn new(root: impl Into<PathBuf>, prefix: impl Into<String>) -> Self {
190 Self {
191 root: root.into(),
192 prefix: prefix.into(),
193 ..Default::default()
194 }
195 }
196
197 pub fn serve_index(mut self, enabled: bool) -> Self {
199 self.serve_index = enabled;
200 self
201 }
202
203 pub fn index_file(mut self, name: impl Into<String>) -> Self {
205 self.index_file = name.into();
206 self
207 }
208
209 pub fn etag(mut self, enabled: bool) -> Self {
211 self.etag = enabled;
212 self
213 }
214
215 pub fn last_modified(mut self, enabled: bool) -> Self {
217 self.last_modified = enabled;
218 self
219 }
220
221 pub fn max_age(mut self, seconds: u64) -> Self {
223 self.max_age = seconds;
224 self
225 }
226
227 pub fn fallback(mut self, file: impl Into<String>) -> Self {
229 self.fallback = Some(file.into());
230 self
231 }
232}
233
234pub struct StaticFile {
236 #[allow(dead_code)]
237 path: PathBuf,
238 #[allow(dead_code)]
239 config: StaticFileConfig,
240}
241
242impl StaticFile {
243 pub fn new(path: impl Into<PathBuf>, config: StaticFileConfig) -> Self {
245 Self {
246 path: path.into(),
247 config,
248 }
249 }
250
251 pub async fn serve(
253 relative_path: &str,
254 config: &StaticFileConfig,
255 ) -> Result<Response, ApiError> {
256 let clean_path = sanitize_path(relative_path);
258 let file_path = config.root.join(&clean_path);
259
260 if file_path.is_dir() {
262 if config.serve_index {
263 let index_path = file_path.join(&config.index_file);
264 if index_path.exists() {
265 return Self::serve_file(&index_path, config).await;
266 }
267 }
268 return Err(ApiError::not_found("Directory listing not allowed"));
269 }
270
271 match Self::serve_file(&file_path, config).await {
273 Ok(response) => Ok(response),
274 Err(_) if config.fallback.is_some() => {
275 let fallback_path = config.root.join(config.fallback.as_ref().unwrap());
277 Self::serve_file(&fallback_path, config).await
278 }
279 Err(e) => Err(e),
280 }
281 }
282
283 async fn serve_file(path: &Path, config: &StaticFileConfig) -> Result<Response, ApiError> {
285 let metadata = fs::metadata(path)
287 .await
288 .map_err(|_| ApiError::not_found(format!("File not found: {}", path.display())))?;
289
290 if !metadata.is_file() {
291 return Err(ApiError::not_found("Not a file"));
292 }
293
294 let content = fs::read(path)
296 .await
297 .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
298
299 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
301 let content_type = mime_type_for_extension(extension);
302
303 let mut builder = http::Response::builder()
305 .status(StatusCode::OK)
306 .header(header::CONTENT_TYPE, content_type)
307 .header(header::CONTENT_LENGTH, content.len());
308
309 if config.etag {
311 if let Ok(modified) = metadata.modified() {
312 let etag = calculate_etag(modified, metadata.len());
313 builder = builder.header(header::ETAG, etag);
314 }
315 }
316
317 if config.last_modified {
319 if let Ok(modified) = metadata.modified() {
320 let http_date = format_http_date(modified);
321 builder = builder.header(header::LAST_MODIFIED, http_date);
322 }
323 }
324
325 if config.max_age > 0 {
327 builder = builder.header(
328 header::CACHE_CONTROL,
329 format!("public, max-age={}", config.max_age),
330 );
331 }
332
333 builder
334 .body(Full::new(Bytes::from(content)))
335 .map_err(|e| ApiError::internal(format!("Failed to build response: {}", e)))
336 }
337}
338
339fn sanitize_path(path: &str) -> String {
341 let path = path.trim_start_matches('/');
343
344 let parts: Vec<&str> = path
346 .split('/')
347 .filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.contains('\\'))
348 .collect();
349
350 parts.join("/")
351}
352
353pub fn static_handler(
364 config: StaticFileConfig,
365) -> impl Fn(crate::Request) -> std::pin::Pin<Box<dyn std::future::Future<Output = Response> + Send>>
366 + Clone
367 + Send
368 + Sync
369 + 'static {
370 move |req: crate::Request| {
371 let config = config.clone();
372 let path = req.uri().path().to_string();
373
374 Box::pin(async move {
375 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
377
378 match StaticFile::serve(relative_path, &config).await {
379 Ok(response) => response,
380 Err(err) => err.into_response(),
381 }
382 })
383 }
384}
385
386pub fn serve_dir(prefix: impl Into<String>, root: impl Into<PathBuf>) -> StaticFileConfig {
404 StaticFileConfig::new(root.into(), prefix.into())
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_mime_type_detection() {
413 assert_eq!(mime_type_for_extension("html"), "text/html; charset=utf-8");
414 assert_eq!(mime_type_for_extension("css"), "text/css; charset=utf-8");
415 assert_eq!(
416 mime_type_for_extension("js"),
417 "text/javascript; charset=utf-8"
418 );
419 assert_eq!(mime_type_for_extension("png"), "image/png");
420 assert_eq!(mime_type_for_extension("jpg"), "image/jpeg");
421 assert_eq!(mime_type_for_extension("json"), "application/json");
422 assert_eq!(
423 mime_type_for_extension("unknown"),
424 "application/octet-stream"
425 );
426 }
427
428 #[test]
429 fn test_sanitize_path() {
430 assert_eq!(sanitize_path("file.txt"), "file.txt");
431 assert_eq!(sanitize_path("/file.txt"), "file.txt");
432 assert_eq!(sanitize_path("../../../etc/passwd"), "etc/passwd");
433 assert_eq!(sanitize_path("foo/../bar"), "foo/bar");
434 assert_eq!(sanitize_path("./file.txt"), "file.txt");
435 assert_eq!(sanitize_path("foo/./bar"), "foo/bar");
436 }
437
438 #[test]
439 fn test_etag_calculation() {
440 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1000000);
441 let etag = calculate_etag(time, 12345);
442 assert!(etag.starts_with('"'));
443 assert!(etag.ends_with('"'));
444 assert!(etag.contains('-'));
445 }
446
447 #[test]
448 fn test_static_file_config() {
449 let config = StaticFileConfig::new("./public", "/assets")
450 .serve_index(true)
451 .index_file("index.html")
452 .etag(true)
453 .last_modified(true)
454 .max_age(7200)
455 .fallback("index.html");
456
457 assert_eq!(config.root, PathBuf::from("./public"));
458 assert_eq!(config.prefix, "/assets");
459 assert!(config.serve_index);
460 assert_eq!(config.index_file, "index.html");
461 assert!(config.etag);
462 assert!(config.last_modified);
463 assert_eq!(config.max_age, 7200);
464 assert_eq!(config.fallback, Some("index.html".to_string()));
465 }
466
467 #[test]
468 fn test_is_leap_year() {
469 assert!(is_leap_year(2000)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
474}