rustapi_core/
static_files.rs

1//! Static file serving for RustAPI
2//!
3//! This module provides types for serving static files from a directory.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use rustapi_rs::prelude::*;
9//!
10//! RustApi::new()
11//!     .serve_static("/assets", "./static")
12//!     .serve_static("/uploads", "./uploads")
13//!     .run("127.0.0.1:8080")
14//!     .await
15//! ```
16
17use 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
26/// MIME type detection based on file extension
27fn mime_type_for_extension(extension: &str) -> &'static str {
28    match extension.to_lowercase().as_str() {
29        // Text
30        "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        // Images
40        "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        // Fonts
50        "woff" => "font/woff",
51        "woff2" => "font/woff2",
52        "ttf" => "font/ttf",
53        "otf" => "font/otf",
54        "eot" => "application/vnd.ms-fontobject",
55
56        // Audio/Video
57        "mp3" => "audio/mpeg",
58        "wav" => "audio/wav",
59        "ogg" => "audio/ogg",
60        "mp4" => "video/mp4",
61        "webm" => "video/webm",
62
63        // Documents
64        "pdf" => "application/pdf",
65        "zip" => "application/zip",
66        "tar" => "application/x-tar",
67        "gz" => "application/gzip",
68
69        // WebAssembly
70        "wasm" => "application/wasm",
71
72        // Default
73        _ => "application/octet-stream",
74    }
75}
76
77/// Calculate ETag from file metadata
78fn 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
86/// Format system time as HTTP date (RFC 7231)
87fn 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    // Simple HTTP date formatting
96    // In production, you'd use a proper date formatting library
97    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    // Calculate day of week and date (simplified)
104    let days_since_epoch = days;
105    let day_of_week = (days_since_epoch + 4) % 7; // Jan 1, 1970 was Thursday
106    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    // Calculate year, month, day (simplified leap year handling)
112    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/// Static file serving configuration
152#[derive(Clone)]
153pub struct StaticFileConfig {
154    /// Root directory for static files
155    pub root: PathBuf,
156    /// URL path prefix
157    pub prefix: String,
158    /// Whether to serve index.html for directories
159    pub serve_index: bool,
160    /// Index file name (default: "index.html")
161    pub index_file: String,
162    /// Enable ETag headers
163    pub etag: bool,
164    /// Enable Last-Modified headers
165    pub last_modified: bool,
166    /// Cache-Control max-age in seconds (0 = no caching)
167    pub max_age: u64,
168    /// Fallback file for SPA routing (e.g., "index.html")
169    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, // 1 hour
182            fallback: None,
183        }
184    }
185}
186
187impl StaticFileConfig {
188    /// Create a new static file configuration
189    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    /// Set whether to serve index.html for directories
198    pub fn serve_index(mut self, enabled: bool) -> Self {
199        self.serve_index = enabled;
200        self
201    }
202
203    /// Set the index file name
204    pub fn index_file(mut self, name: impl Into<String>) -> Self {
205        self.index_file = name.into();
206        self
207    }
208
209    /// Enable or disable ETag headers
210    pub fn etag(mut self, enabled: bool) -> Self {
211        self.etag = enabled;
212        self
213    }
214
215    /// Enable or disable Last-Modified headers
216    pub fn last_modified(mut self, enabled: bool) -> Self {
217        self.last_modified = enabled;
218        self
219    }
220
221    /// Set Cache-Control max-age in seconds
222    pub fn max_age(mut self, seconds: u64) -> Self {
223        self.max_age = seconds;
224        self
225    }
226
227    /// Set a fallback file for SPA routing
228    pub fn fallback(mut self, file: impl Into<String>) -> Self {
229        self.fallback = Some(file.into());
230        self
231    }
232}
233
234/// Static file response
235pub struct StaticFile {
236    #[allow(dead_code)]
237    path: PathBuf,
238    #[allow(dead_code)]
239    config: StaticFileConfig,
240}
241
242impl StaticFile {
243    /// Create a new static file response
244    pub fn new(path: impl Into<PathBuf>, config: StaticFileConfig) -> Self {
245        Self {
246            path: path.into(),
247            config,
248        }
249    }
250
251    /// Serve a file from a path relative to the root
252    pub async fn serve(
253        relative_path: &str,
254        config: &StaticFileConfig,
255    ) -> Result<Response, ApiError> {
256        // Sanitize path to prevent directory traversal
257        let clean_path = sanitize_path(relative_path);
258        let file_path = config.root.join(&clean_path);
259
260        // Check if it's a directory
261        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        // Try to serve the file
272        match Self::serve_file(&file_path, config).await {
273            Ok(response) => Ok(response),
274            Err(_) if config.fallback.is_some() => {
275                // Try fallback
276                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    /// Serve a specific file
284    async fn serve_file(path: &Path, config: &StaticFileConfig) -> Result<Response, ApiError> {
285        // Check if file exists
286        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        // Read file
295        let content = fs::read(path)
296            .await
297            .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
298
299        // Determine content type
300        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
301        let content_type = mime_type_for_extension(extension);
302
303        // Build response
304        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        // Add ETag
310        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        // Add Last-Modified
318        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        // Add Cache-Control
326        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
339/// Sanitize a file path to prevent directory traversal
340fn sanitize_path(path: &str) -> String {
341    // Remove leading slashes
342    let path = path.trim_start_matches('/');
343
344    // Split and filter out dangerous components
345    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
353/// Create a handler for serving static files
354///
355/// # Example
356///
357/// ```rust,ignore
358/// use rustapi_core::static_files::{static_handler, StaticFileConfig};
359///
360/// let config = StaticFileConfig::new("./public", "/assets");
361/// let handler = static_handler(config);
362/// ```
363pub 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            // Strip prefix from path
376            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
386/// Create a static file serving route
387///
388/// This is the main function for adding static file serving to RustAPI.
389///
390/// # Arguments
391///
392/// * `prefix` - URL path prefix (e.g., "/static")
393/// * `root` - File system root directory
394///
395/// # Example
396///
397/// ```rust,ignore
398/// use rustapi_core::static_files::serve_dir;
399///
400/// // The handler can be used with a catch-all route
401/// let config = serve_dir("/static", "./public");
402/// ```
403pub 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)); // Divisible by 400
470        assert!(!is_leap_year(1900)); // Divisible by 100 but not 400
471        assert!(is_leap_year(2024)); // Divisible by 4 but not 100
472        assert!(!is_leap_year(2023)); // Not divisible by 4
473    }
474}