web_server_abstraction/
static_files.rs

1//! Static file serving middleware and utilities.
2
3use crate::core::Handler;
4use crate::error::WebServerError;
5use crate::types::{Request, Response, StatusCode};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9/// Static file serving configuration
10#[derive(Debug, Clone)]
11pub struct StaticFileConfig {
12    /// Root directory for static files
13    pub root_dir: PathBuf,
14    /// URL prefix for static files (e.g., "/static")
15    pub url_prefix: String,
16    /// Enable directory listing
17    pub show_index: bool,
18    /// Default index files to serve
19    pub index_files: Vec<String>,
20    /// Enable content compression
21    pub compress: bool,
22    /// Enable caching headers
23    pub cache: bool,
24    /// Cache max-age in seconds
25    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, // 1 hour
38        }
39    }
40}
41
42/// Static file handler
43#[derive(Debug, Clone, Default)]
44pub struct StaticFileHandler {
45    config: StaticFileConfig,
46}
47
48impl StaticFileHandler {
49    /// Create a new static file handler
50    pub fn new(config: StaticFileConfig) -> Self {
51        Self { config }
52    }
53
54    /// Set root directory
55    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    /// Set URL prefix
61    pub fn url_prefix(mut self, prefix: impl Into<String>) -> Self {
62        self.config.url_prefix = prefix.into();
63        self
64    }
65
66    /// Enable/disable directory listing
67    pub fn show_index(mut self, show: bool) -> Self {
68        self.config.show_index = show;
69        self
70    }
71
72    /// Enable/disable caching
73    pub fn cache(mut self, cache: bool) -> Self {
74        self.config.cache = cache;
75        self
76    }
77
78    /// Set cache max-age
79    pub fn cache_max_age(mut self, max_age: u32) -> Self {
80        self.config.cache_max_age = max_age;
81        self
82    }
83
84    /// Serve a static file
85    async fn serve_file(&self, file_path: &Path) -> Result<Response, WebServerError> {
86        // Check if file exists and is readable
87        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        // Read file content
96        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        // Set content type based on file extension
103        if let Some(content_type) = mime_type_for_file(file_path) {
104            response = response.header("Content-Type", content_type);
105        }
106
107        // Set cache headers
108        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    /// Serve directory index
121    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        // Try to find index files first
131        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        // Generate directory listing
139        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        // Add parent directory link if not root
151        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        // Read directory entries
160        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        // Sort entries
171        entries_vec.sort_by_key(|a| a.file_name());
172
173        // Add directory entries
174        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    /// Handle a static file request
207    pub async fn handle(&self, request: Request) -> Result<Response, WebServerError> {
208        let url_path = request.uri.path();
209
210        // Check if URL starts with our prefix
211        if !url_path.starts_with(&self.config.url_prefix) {
212            return Ok(Response::new(StatusCode::NOT_FOUND).body("Not found"));
213        }
214
215        // Remove prefix to get relative path
216        let relative_path = url_path
217            .strip_prefix(&self.config.url_prefix)
218            .unwrap_or(url_path)
219            .trim_start_matches('/');
220
221        // Construct full file path
222        let file_path = self.config.root_dir.join(relative_path);
223
224        // Security check - ensure path is within root directory
225        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
239/// Create a function-based handler for static files
240impl 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
249/// Generate ETag for a file
250async 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
267/// Get MIME type for a file based on extension
268fn 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
298/// Static file serving middleware
299pub fn static_files(config: StaticFileConfig) -> Arc<StaticFileHandler> {
300    Arc::new(StaticFileHandler::new(config))
301}
302
303/// Create static file handler with default config
304pub 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
311/// Create static file handler with custom prefix
312pub 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}