1use crate::config::SiteConfig;
2use pingora::http::ResponseHeader;
3use pingora::prelude::*;
4use std::path::Path;
5use tokio::fs;
6
7pub struct StaticFileHandler {
8 }
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 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 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 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 let path = path.split('?').next().unwrap_or(path);
58 let path = path.split('#').next().unwrap_or(path);
59
60 if path == "/" {
62 return "/".to_string();
63 }
64
65 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 if let Ok(metadata) = fs::metadata(path).await {
74 if metadata.is_file() {
75 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 header.insert_header("Content-Type", mime_type)?;
97 header.insert_header("Content-Length", content.len().to_string())?;
98
99 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 for (key, value) in site.get_cors_headers() {
107 header.insert_header(key, value)?;
108 }
109
110 for (key, value) in &site.headers {
112 header.insert_header(key.clone(), value.clone())?;
113 }
114
115 let content_len = content.len();
117 let should_compress = site.should_compress(mime_type, content_len);
118 let final_content = if should_compress {
119 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 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 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 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 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 #[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 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 let temp_dir = std::env::temp_dir().join("bws_test_static");
361 std::fs::create_dir_all(&temp_dir).unwrap();
362
363 let index_file = temp_dir.join("index.html");
365 std::fs::write(&index_file, "test content").unwrap();
366
367 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 std::fs::remove_dir_all(&temp_dir).unwrap();
380 }
381}