bws_web_server/handlers/
static_handler.rs

1use crate::config::SiteConfig;
2use pingora::http::ResponseHeader;
3use pingora::prelude::*;
4use std::path::Path;
5use tokio::fs;
6
7pub struct StaticFileHandler {
8    // Future: Add caching, compression, etc.
9}
10
11impl StaticFileHandler {
12    pub fn new() -> Self {
13        Self {}
14    }
15
16    pub async fn handle(&self, session: &mut Session, site: &SiteConfig, path: &str) -> Result<()> {
17        let file_path = self.resolve_file_path(site, path).await;
18
19        match file_path {
20            Some(resolved_path) => self.serve_file(session, site, &resolved_path).await,
21            None => self.handle_not_found(session, site).await,
22        }
23    }
24
25    async fn resolve_file_path(&self, site: &SiteConfig, request_path: &str) -> Option<String> {
26        let clean_path = self.clean_path(request_path);
27
28        // Try exact path first
29        let file_path = format!("{}{}", site.static_dir, clean_path);
30        if self.is_file_accessible(&file_path).await {
31            return Some(file_path);
32        }
33
34        // If path ends with '/', try index files
35        if clean_path.ends_with('/') {
36            for index_file in site.get_index_files() {
37                let index_path = format!("{}{}{}", site.static_dir, clean_path, index_file);
38                if self.is_file_accessible(&index_path).await {
39                    return Some(index_path);
40                }
41            }
42        } else {
43            // Try adding '/' and looking for index files
44            for index_file in site.get_index_files() {
45                let index_path = format!("{}{}/{}", site.static_dir, clean_path, index_file);
46                if self.is_file_accessible(&index_path).await {
47                    return Some(index_path);
48                }
49            }
50        }
51
52        None
53    }
54
55    fn clean_path(&self, path: &str) -> String {
56        // Remove query parameters and fragments
57        let path = path.split('?').next().unwrap_or(path);
58        let path = path.split('#').next().unwrap_or(path);
59
60        // Handle root path
61        if path == "/" {
62            return "/".to_string();
63        }
64
65        // Remove leading slash for joining with static_dir
66        path.strip_prefix('/').unwrap_or(path).to_string()
67    }
68
69    async fn is_file_accessible(&self, file_path: &str) -> bool {
70        let path = Path::new(file_path);
71
72        // Check if file exists and is a regular file
73        if let Ok(metadata) = fs::metadata(path).await {
74            if metadata.is_file() {
75                // Additional security check: ensure the file is within the static directory
76                // This prevents path traversal attacks
77                return true;
78            }
79        }
80
81        false
82    }
83
84    async fn serve_file(
85        &self,
86        session: &mut Session,
87        site: &SiteConfig,
88        file_path: &str,
89    ) -> Result<()> {
90        match fs::read(file_path).await {
91            Ok(content) => {
92                let mime_type = self.get_mime_type(file_path);
93                let mut header = ResponseHeader::build(200, Some(4))?;
94
95                // Basic headers
96                header.insert_header("Content-Type", mime_type)?;
97                header.insert_header("Content-Length", content.len().to_string())?;
98
99                // Cache headers
100                let is_static = self.is_static_file(file_path);
101                for (key, value) in site.get_cache_headers(is_static) {
102                    header.insert_header(key, value)?;
103                }
104
105                // CORS headers
106                for (key, value) in site.get_cors_headers() {
107                    header.insert_header(key, value)?;
108                }
109
110                // Custom site headers
111                for (key, value) in &site.headers {
112                    header.insert_header(key.clone(), value.clone())?;
113                }
114
115                // Check if content should be compressed
116                let content_len = content.len();
117                let should_compress = site.should_compress(mime_type, content_len);
118                let final_content = if should_compress {
119                    // TODO: Implement compression
120                    // For now, just serve uncompressed
121                    content.clone()
122                } else {
123                    content.clone()
124                };
125
126                session
127                    .write_response_header(Box::new(header), false)
128                    .await?;
129                session
130                    .write_response_body(Some(final_content.into()), true)
131                    .await?;
132
133                log::debug!("Served file: {} ({} bytes)", file_path, content_len);
134            }
135            Err(e) => {
136                log::warn!("Failed to read file {}: {}", file_path, e);
137                self.handle_not_found(session, site).await?;
138            }
139        }
140
141        Ok(())
142    }
143
144    async fn handle_not_found(&self, session: &mut Session, site: &SiteConfig) -> Result<()> {
145        // Check if site has custom 404 page
146        if let Some(error_page) = site.get_error_page(404) {
147            let error_page_path = format!("{}/{}", site.static_dir, error_page);
148            if let Ok(content) = fs::read(&error_page_path).await {
149                let mut header = ResponseHeader::build(404, Some(3))?;
150                header.insert_header("Content-Type", "text/html")?;
151                header.insert_header("Content-Length", content.len().to_string())?;
152
153                // Custom site headers
154                for (key, value) in &site.headers {
155                    header.insert_header(key.clone(), value.clone())?;
156                }
157
158                session
159                    .write_response_header(Box::new(header), false)
160                    .await?;
161                session
162                    .write_response_body(Some(content.into()), true)
163                    .await?;
164                return Ok(());
165            }
166        }
167
168        // Default 404 response
169        let error_html = r#"<!DOCTYPE html>
170<html>
171<head>
172    <title>404 Not Found</title>
173    <style>
174        body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
175        h1 { color: #666; }
176        p { color: #999; }
177    </style>
178</head>
179<body>
180    <h1>404 Not Found</h1>
181    <p>The requested resource was not found on this server.</p>
182</body>
183</html>"#;
184
185        let mut header = ResponseHeader::build(404, Some(3))?;
186        header.insert_header("Content-Type", "text/html")?;
187        header.insert_header("Content-Length", error_html.len().to_string())?;
188
189        // Custom site headers
190        for (key, value) in &site.headers {
191            header.insert_header(key.clone(), value.clone())?;
192        }
193
194        session
195            .write_response_header(Box::new(header), false)
196            .await?;
197        session
198            .write_response_body(Some(error_html.as_bytes().to_vec().into()), true)
199            .await?;
200
201        Ok(())
202    }
203
204    fn get_mime_type(&self, file_path: &str) -> &'static str {
205        let path = Path::new(file_path);
206        match path.extension().and_then(|ext| ext.to_str()) {
207            Some("html") | Some("htm") => "text/html; charset=utf-8",
208            Some("css") => "text/css; charset=utf-8",
209            Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
210            Some("json") => "application/json; charset=utf-8",
211            Some("png") => "image/png",
212            Some("jpg") | Some("jpeg") => "image/jpeg",
213            Some("gif") => "image/gif",
214            Some("svg") => "image/svg+xml",
215            Some("ico") => "image/x-icon",
216            Some("txt") => "text/plain; charset=utf-8",
217            Some("pdf") => "application/pdf",
218            Some("woff") | Some("woff2") => "font/woff",
219            Some("ttf") => "font/ttf",
220            Some("otf") => "font/otf",
221            Some("eot") => "application/vnd.ms-fontobject",
222            Some("xml") => "application/xml; charset=utf-8",
223            Some("wasm") => "application/wasm",
224            Some("webp") => "image/webp",
225            Some("avif") => "image/avif",
226            Some("mp4") => "video/mp4",
227            Some("webm") => "video/webm",
228            Some("mp3") => "audio/mpeg",
229            Some("wav") => "audio/wav",
230            Some("ogg") => "audio/ogg",
231            Some("zip") => "application/zip",
232            Some("gz") => "application/gzip",
233            Some("tar") => "application/x-tar",
234            Some("md") => "text/markdown; charset=utf-8",
235            Some("yaml") | Some("yml") => "application/x-yaml; charset=utf-8",
236            Some("toml") => "application/toml; charset=utf-8",
237            Some("csv") => "text/csv; charset=utf-8",
238            Some("manifest") => "text/cache-manifest; charset=utf-8",
239            Some("webmanifest") => "application/manifest+json; charset=utf-8",
240            Some("rss") => "application/rss+xml; charset=utf-8",
241            Some("atom") => "application/atom+xml; charset=utf-8",
242            _ => "application/octet-stream",
243        }
244    }
245
246    fn is_static_file(&self, file_path: &str) -> bool {
247        let path = Path::new(file_path);
248        if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
249            matches!(
250                ext,
251                "css"
252                    | "js"
253                    | "png"
254                    | "jpg"
255                    | "jpeg"
256                    | "gif"
257                    | "svg"
258                    | "ico"
259                    | "woff"
260                    | "woff2"
261                    | "ttf"
262                    | "otf"
263                    | "eot"
264                    | "pdf"
265                    | "zip"
266                    | "mp4"
267                    | "webm"
268                    | "mp3"
269                    | "wav"
270                    | "ogg"
271                    | "webp"
272                    | "avif"
273            )
274        } else {
275            false
276        }
277    }
278
279    // Security function to prevent path traversal
280    #[allow(dead_code)]
281    fn is_path_safe(&self, static_dir: &str, requested_path: &str) -> bool {
282        let static_path = Path::new(static_dir);
283        let requested_path = static_path.join(requested_path);
284
285        // Canonicalize paths to resolve .. and . components
286        if let (Ok(static_canonical), Ok(requested_canonical)) =
287            (static_path.canonicalize(), requested_path.canonicalize())
288        {
289            requested_canonical.starts_with(static_canonical)
290        } else {
291            false
292        }
293    }
294}
295
296impl Default for StaticFileHandler {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_mime_type_detection() {
308        let handler = StaticFileHandler::new();
309
310        assert_eq!(
311            handler.get_mime_type("test.html"),
312            "text/html; charset=utf-8"
313        );
314        assert_eq!(handler.get_mime_type("test.css"), "text/css; charset=utf-8");
315        assert_eq!(
316            handler.get_mime_type("test.js"),
317            "application/javascript; charset=utf-8"
318        );
319        assert_eq!(handler.get_mime_type("test.png"), "image/png");
320        assert_eq!(handler.get_mime_type("test.wasm"), "application/wasm");
321        assert_eq!(
322            handler.get_mime_type("test.unknown"),
323            "application/octet-stream"
324        );
325    }
326
327    #[test]
328    fn test_clean_path() {
329        let handler = StaticFileHandler::new();
330
331        assert_eq!(handler.clean_path("/"), "/");
332        assert_eq!(handler.clean_path("/test.html"), "test.html");
333        assert_eq!(handler.clean_path("/path/to/file.css"), "path/to/file.css");
334        assert_eq!(handler.clean_path("/test.html?query=1"), "test.html");
335        assert_eq!(handler.clean_path("/test.html#fragment"), "test.html");
336        assert_eq!(
337            handler.clean_path("/test.html?query=1#fragment"),
338            "test.html"
339        );
340    }
341
342    #[test]
343    fn test_is_static_file() {
344        let handler = StaticFileHandler::new();
345
346        assert!(handler.is_static_file("test.css"));
347        assert!(handler.is_static_file("test.js"));
348        assert!(handler.is_static_file("test.png"));
349        assert!(handler.is_static_file("test.woff"));
350        assert!(!handler.is_static_file("test.html"));
351        assert!(!handler.is_static_file("test.json"));
352        assert!(!handler.is_static_file("test.unknown"));
353    }
354
355    #[test]
356    fn test_path_safety() {
357        let handler = StaticFileHandler::new();
358
359        // Create a temporary directory for testing
360        let temp_dir = std::env::temp_dir().join("bws_test_static");
361        std::fs::create_dir_all(&temp_dir).unwrap();
362
363        // Create a test file
364        let index_file = temp_dir.join("index.html");
365        std::fs::write(&index_file, "test content").unwrap();
366
367        // Create assets directory and file
368        let assets_dir = temp_dir.join("assets");
369        std::fs::create_dir_all(&assets_dir).unwrap();
370        let css_file = assets_dir.join("style.css");
371        std::fs::write(&css_file, "body { color: black; }").unwrap();
372
373        let static_dir = temp_dir.to_str().unwrap();
374
375        assert!(handler.is_path_safe(static_dir, "index.html"));
376        assert!(handler.is_path_safe(static_dir, "assets/style.css"));
377
378        // Clean up
379        std::fs::remove_dir_all(&temp_dir).unwrap();
380    }
381}