1use crate::config::SiteConfig;
2use crate::middleware::compression::{CompressionMethod, CompressionMiddleware};
3use pingora::http::ResponseHeader;
4use pingora::prelude::*;
5use std::path::Path;
6use tokio::fs;
7
8pub struct StaticFileHandler {
10 }
12
13impl StaticFileHandler {
14 pub fn new() -> Self {
16 Self {}
17 }
18
19 pub async fn handle(&self, session: &mut Session, site: &SiteConfig, path: &str) -> Result<()> {
22 let file_path = self.resolve_file_path(site, path).await;
23
24 match file_path {
25 Some(resolved_path) => self.serve_file(session, site, &resolved_path).await,
26 None => self.handle_not_found(session, site).await,
27 }
28 }
29
30 async fn resolve_file_path(&self, site: &SiteConfig, request_path: &str) -> Option<String> {
32 let clean_path = self.clean_path(request_path);
33
34 if !self.is_path_safe(&site.static_dir, &clean_path) {
36 log::warn!("Blocked path traversal attempt: {}", request_path);
37 return None;
38 }
39
40 let file_path = format!("{}/{}", site.static_dir, clean_path);
42 if self.is_file_accessible(&file_path).await {
43 return Some(file_path);
44 }
45
46 if clean_path.ends_with('/') || clean_path.is_empty() {
48 for index_file in site.get_index_files() {
49 let index_path = format!("{}/{}{}", site.static_dir, clean_path, index_file);
50 if self.is_file_accessible(&index_path).await {
51 return Some(index_path);
52 }
53 }
54 } else {
55 for index_file in site.get_index_files() {
57 let index_path = format!("{}/{}/{}", site.static_dir, clean_path, index_file);
58 if self.is_file_accessible(&index_path).await {
59 return Some(index_path);
60 }
61 }
62 }
63
64 None
65 }
66
67 fn clean_path(&self, path: &str) -> String {
69 let path = path.split('?').next().unwrap_or(path);
71 let path = path.split('#').next().unwrap_or(path);
72
73 if path == "/" {
75 return "".to_string();
76 }
77
78 let clean = path.strip_prefix('/').unwrap_or(path);
80
81 let clean = clean.replace('\\', "/"); let clean = clean.replace("//", "/"); let components: Vec<&str> = clean
87 .split('/')
88 .filter(|component| {
89 !component.is_empty()
90 && *component != "."
91 && *component != ".."
92 && !component.contains('\0') })
94 .collect();
95
96 components.join("/")
97 }
98
99 async fn is_file_accessible(&self, file_path: &str) -> bool {
101 let path = Path::new(file_path);
102
103 if let Ok(metadata) = fs::metadata(path).await {
105 if metadata.is_file() {
106 const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
108 if metadata.len() > MAX_FILE_SIZE {
109 log::warn!(
110 "File too large, rejecting: {} ({} bytes)",
111 file_path,
112 metadata.len()
113 );
114 return false;
115 }
116 return true;
117 }
118 }
119
120 false
121 }
122
123 async fn serve_file(
125 &self,
126 session: &mut Session,
127 site: &SiteConfig,
128 file_path: &str,
129 ) -> Result<()> {
130 match fs::read(file_path).await {
131 Ok(content) => {
132 let mime_type = self.get_mime_type(file_path);
133 let mut header = ResponseHeader::build(200, Some(4))?;
134
135 header.insert_header("Content-Type", mime_type)?;
137 header.insert_header("Content-Length", content.len().to_string())?;
138
139 let is_static = self.is_static_file(file_path);
141 for (key, value) in site.get_cache_headers(is_static) {
142 header.insert_header(key, value)?;
143 }
144
145 for (key, value) in site.get_cors_headers() {
147 header.insert_header(key, value)?;
148 }
149
150 for (key, value) in &site.headers {
152 header.insert_header(key.clone(), value.clone())?;
153 }
154
155 let content_len = content.len();
157 let compression_middleware = CompressionMiddleware::new(site.compression.clone());
158
159 let (final_content, encoding) = if compression_middleware
160 .should_compress(mime_type, content_len)
161 {
162 let accept_encoding = session
164 .req_header()
165 .headers
166 .get("accept-encoding")
167 .and_then(|h| h.to_str().ok());
168
169 let compression_method =
170 compression_middleware.get_best_compression(accept_encoding);
171
172 match compression_middleware.compress(&content, compression_method.clone()) {
173 Ok(compressed_content) => {
174 log::debug!(
175 "Compressed {} bytes to {} bytes using {:?} ({}% reduction)",
176 content_len,
177 compressed_content.len(),
178 compression_method,
179 ((content_len - compressed_content.len()) * 100) / content_len
180 );
181 (compressed_content.to_vec(), Some(compression_method))
182 }
183 Err(e) => {
184 log::warn!("Compression failed: {}, serving uncompressed", e);
185 (content, None)
186 }
187 }
188 } else {
189 (content, None)
190 };
191
192 header.remove_header("Content-Length");
194 header.insert_header("Content-Length", final_content.len().to_string())?;
195
196 if let Some(method) = encoding {
197 if !matches!(method, CompressionMethod::None) {
198 header.insert_header("Content-Encoding", method.as_str())?;
199 header.insert_header("Vary", "Accept-Encoding")?;
200 }
201 }
202
203 session
204 .write_response_header(Box::new(header), false)
205 .await?;
206
207 let final_content_len = final_content.len();
208 session
209 .write_response_body(Some(final_content.into()), true)
210 .await?;
211
212 log::debug!(
213 "Served file: {} ({} -> {} bytes)",
214 file_path,
215 content_len,
216 final_content_len
217 );
218 }
219 Err(e) => {
220 log::warn!("Failed to read file {}: {}", file_path, e);
221 self.handle_not_found(session, site).await?;
222 }
223 }
224
225 Ok(())
226 }
227
228 async fn handle_not_found(&self, session: &mut Session, site: &SiteConfig) -> Result<()> {
229 if let Some(error_page) = site.get_error_page(404) {
231 let error_page_path = format!("{}/{}", site.static_dir, error_page);
232 if let Ok(content) = fs::read(&error_page_path).await {
233 let mut header = ResponseHeader::build(404, Some(3))?;
234 header.insert_header("Content-Type", "text/html")?;
235 header.insert_header("Content-Length", content.len().to_string())?;
236
237 for (key, value) in &site.headers {
239 header.insert_header(key.clone(), value.clone())?;
240 }
241
242 session
243 .write_response_header(Box::new(header), false)
244 .await?;
245 session
246 .write_response_body(Some(content.into()), true)
247 .await?;
248 return Ok(());
249 }
250 }
251
252 let error_html = r#"<!DOCTYPE html>
254<html>
255<head>
256 <title>404 Not Found</title>
257 <style>
258 body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
259 h1 { color: #666; }
260 p { color: #999; }
261 </style>
262</head>
263<body>
264 <h1>404 Not Found</h1>
265 <p>The requested resource was not found on this server.</p>
266</body>
267</html>"#;
268
269 let mut header = ResponseHeader::build(404, Some(3))?;
270 header.insert_header("Content-Type", "text/html")?;
271 header.insert_header("Content-Length", error_html.len().to_string())?;
272
273 for (key, value) in &site.headers {
275 header.insert_header(key.clone(), value.clone())?;
276 }
277
278 session
279 .write_response_header(Box::new(header), false)
280 .await?;
281 session
282 .write_response_body(Some(error_html.as_bytes().to_vec().into()), true)
283 .await?;
284
285 Ok(())
286 }
287
288 fn get_mime_type(&self, file_path: &str) -> &'static str {
289 let path = Path::new(file_path);
290 match path.extension().and_then(|ext| ext.to_str()) {
291 Some("html") | Some("htm") => "text/html; charset=utf-8",
292 Some("css") => "text/css; charset=utf-8",
293 Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
294 Some("json") => "application/json; charset=utf-8",
295 Some("png") => "image/png",
296 Some("jpg") | Some("jpeg") => "image/jpeg",
297 Some("gif") => "image/gif",
298 Some("svg") => "image/svg+xml",
299 Some("ico") => "image/x-icon",
300 Some("txt") => "text/plain; charset=utf-8",
301 Some("pdf") => "application/pdf",
302 Some("woff") | Some("woff2") => "font/woff",
303 Some("ttf") => "font/ttf",
304 Some("otf") => "font/otf",
305 Some("eot") => "application/vnd.ms-fontobject",
306 Some("xml") => "application/xml; charset=utf-8",
307 Some("wasm") => "application/wasm",
308 Some("webp") => "image/webp",
309 Some("avif") => "image/avif",
310 Some("mp4") => "video/mp4",
311 Some("webm") => "video/webm",
312 Some("mp3") => "audio/mpeg",
313 Some("wav") => "audio/wav",
314 Some("ogg") => "audio/ogg",
315 Some("zip") => "application/zip",
316 Some("gz") => "application/gzip",
317 Some("tar") => "application/x-tar",
318 Some("md") => "text/markdown; charset=utf-8",
319 Some("yaml") | Some("yml") => "application/x-yaml; charset=utf-8",
320 Some("toml") => "application/toml; charset=utf-8",
321 Some("csv") => "text/csv; charset=utf-8",
322 Some("manifest") => "text/cache-manifest; charset=utf-8",
323 Some("webmanifest") => "application/manifest+json; charset=utf-8",
324 Some("rss") => "application/rss+xml; charset=utf-8",
325 Some("atom") => "application/atom+xml; charset=utf-8",
326 _ => "application/octet-stream",
327 }
328 }
329
330 fn is_static_file(&self, file_path: &str) -> bool {
331 let path = Path::new(file_path);
332 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
333 matches!(
334 ext,
335 "css"
336 | "js"
337 | "png"
338 | "jpg"
339 | "jpeg"
340 | "gif"
341 | "svg"
342 | "ico"
343 | "woff"
344 | "woff2"
345 | "ttf"
346 | "otf"
347 | "eot"
348 | "pdf"
349 | "zip"
350 | "mp4"
351 | "webm"
352 | "mp3"
353 | "wav"
354 | "ogg"
355 | "webp"
356 | "avif"
357 )
358 } else {
359 false
360 }
361 }
362
363 fn is_path_safe(&self, static_dir: &str, requested_path: &str) -> bool {
365 let static_path = Path::new(static_dir);
366 let requested_path = static_path.join(requested_path);
367
368 if let (Ok(static_canonical), Ok(requested_canonical)) =
370 (static_path.canonicalize(), requested_path.canonicalize())
371 {
372 requested_canonical.starts_with(static_canonical)
373 } else {
374 let static_abs = match std::fs::canonicalize(static_path) {
377 Ok(path) => path,
378 Err(_) => return false,
379 };
380
381 let mut check_path = requested_path.clone();
383 while let Some(parent) = check_path.parent() {
384 if let Ok(parent_canonical) = parent.canonicalize() {
385 return parent_canonical.starts_with(&static_abs);
386 }
387 check_path = parent.to_path_buf();
388 }
389
390 false
391 }
392 }
393}
394
395impl Default for StaticFileHandler {
396 fn default() -> Self {
397 Self::new()
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn test_mime_type_detection() {
407 let handler = StaticFileHandler::new();
408
409 assert_eq!(
410 handler.get_mime_type("test.html"),
411 "text/html; charset=utf-8"
412 );
413 assert_eq!(handler.get_mime_type("test.css"), "text/css; charset=utf-8");
414 assert_eq!(
415 handler.get_mime_type("test.js"),
416 "application/javascript; charset=utf-8"
417 );
418 assert_eq!(handler.get_mime_type("test.png"), "image/png");
419 assert_eq!(handler.get_mime_type("test.wasm"), "application/wasm");
420 assert_eq!(
421 handler.get_mime_type("test.unknown"),
422 "application/octet-stream"
423 );
424 }
425
426 #[test]
427 fn test_clean_path() {
428 let handler = StaticFileHandler::new();
429
430 assert_eq!(handler.clean_path("/"), ""); assert_eq!(handler.clean_path("/test.html"), "test.html");
432 assert_eq!(handler.clean_path("/path/to/file.css"), "path/to/file.css");
433 assert_eq!(handler.clean_path("/test.html?query=1"), "test.html");
434 assert_eq!(handler.clean_path("/test.html#fragment"), "test.html");
435 assert_eq!(
436 handler.clean_path("/test.html?query=1#fragment"),
437 "test.html"
438 );
439
440 assert_eq!(handler.clean_path("/../../../etc/passwd"), "etc/passwd");
443 assert_eq!(handler.clean_path("/test/../../../secret"), "test/secret"); assert_eq!(handler.clean_path("./test.html"), "test.html"); assert_eq!(handler.clean_path("/./test/../file.css"), "test/file.css"); assert_eq!(handler.clean_path("/test\0.html"), ""); assert_eq!(handler.clean_path("//test//file.js"), "test/file.js");
452
453 assert_eq!(
455 handler.clean_path("/path\\to\\file.css"),
456 "path/to/file.css"
457 );
458 }
459
460 #[test]
461 fn test_is_static_file() {
462 let handler = StaticFileHandler::new();
463
464 assert!(handler.is_static_file("test.css"));
465 assert!(handler.is_static_file("test.js"));
466 assert!(handler.is_static_file("test.png"));
467 assert!(handler.is_static_file("test.woff"));
468 assert!(!handler.is_static_file("test.html"));
469 assert!(!handler.is_static_file("test.json"));
470 assert!(!handler.is_static_file("test.unknown"));
471 }
472
473 #[test]
474 fn test_path_safety() {
475 let handler = StaticFileHandler::new();
476
477 let temp_dir = std::env::temp_dir().join("bws_test_static");
479 std::fs::create_dir_all(&temp_dir).unwrap();
480
481 let index_file = temp_dir.join("index.html");
483 std::fs::write(&index_file, "test content").unwrap();
484
485 let assets_dir = temp_dir.join("assets");
487 std::fs::create_dir_all(&assets_dir).unwrap();
488 let css_file = assets_dir.join("style.css");
489 std::fs::write(&css_file, "body { color: black; }").unwrap();
490
491 let static_dir = temp_dir.to_str().unwrap();
492
493 assert!(handler.is_path_safe(static_dir, "index.html"));
494 assert!(handler.is_path_safe(static_dir, "assets/style.css"));
495
496 std::fs::remove_dir_all(&temp_dir).unwrap();
498 }
499}