bws_web_server/
lib.rs

1// a function read file content
2use async_trait::async_trait;
3use pingora::http::ResponseHeader;
4use pingora::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fs;
8
9#[derive(Debug, Deserialize, Serialize, Clone)]
10pub struct ServerConfig {
11    pub server: ServerInfo,
12    pub sites: Vec<SiteConfig>,
13}
14
15#[derive(Debug, Deserialize, Serialize, Clone)]
16pub struct ServerInfo {
17    pub name: String,
18}
19
20#[derive(Debug, Deserialize, Serialize, Clone)]
21pub struct SiteConfig {
22    pub name: String,
23    pub hostname: String,
24    pub port: u16,
25    pub static_dir: String,
26    #[serde(default)]
27    pub default: bool,
28    #[serde(default)]
29    pub api_only: bool,
30    #[serde(default)]
31    pub headers: HashMap<String, String>,
32}
33
34impl ServerConfig {
35    pub fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
36        let content = fs::read_to_string(path)?;
37        let config: ServerConfig = toml::from_str(&content)?;
38        Ok(config)
39    }
40
41    pub fn find_site_by_host_port(&self, host: &str, port: u16) -> Option<&SiteConfig> {
42        // First try to match both hostname and port
43        for site in &self.sites {
44            if site.hostname == host && site.port == port {
45                return Some(site);
46            }
47        }
48
49        // Then try to match just the port (for cases where hostname might not match exactly)
50        for site in &self.sites {
51            if site.port == port {
52                return Some(site);
53            }
54        }
55
56        // Finally, return the default site if no match
57        self.sites.iter().find(|site| site.default)
58    }
59}
60
61pub fn read_file_content(file_path: &str) -> std::io::Result<String> {
62    fs::read_to_string(file_path)
63}
64
65pub fn read_file_bytes(file_path: &str) -> std::io::Result<Vec<u8>> {
66    fs::read(file_path)
67}
68
69pub fn get_mime_type(file_path: &str) -> &'static str {
70    let path = std::path::Path::new(file_path);
71    match path.extension().and_then(|ext| ext.to_str()) {
72        Some("html") | Some("htm") => "text/html",
73        Some("css") => "text/css",
74        Some("js") | Some("mjs") => "application/javascript",
75        Some("json") => "application/json",
76        Some("png") => "image/png",
77        Some("jpg") | Some("jpeg") => "image/jpeg",
78        Some("gif") => "image/gif",
79        Some("svg") => "image/svg+xml",
80        Some("ico") => "image/x-icon",
81        Some("txt") => "text/plain",
82        Some("pdf") => "application/pdf",
83        Some("woff") | Some("woff2") => "font/woff",
84        Some("ttf") => "font/ttf",
85        Some("otf") => "font/otf",
86        Some("eot") => "application/vnd.ms-fontobject",
87        Some("xml") => "application/xml",
88        Some("wasm") => "application/wasm",
89        Some("webp") => "image/webp",
90        Some("avif") => "image/avif",
91        Some("mp4") => "video/mp4",
92        Some("webm") => "video/webm",
93        Some("mp3") => "audio/mpeg",
94        Some("wav") => "audio/wav",
95        Some("ogg") => "audio/ogg",
96        Some("zip") => "application/zip",
97        Some("gz") => "application/gzip",
98        Some("tar") => "application/x-tar",
99        Some("md") => "text/markdown",
100        Some("yaml") | Some("yml") => "application/x-yaml",
101        Some("toml") => "application/toml",
102        _ => "application/octet-stream",
103    }
104}
105
106pub struct WebServerService {
107    config: ServerConfig,
108}
109
110impl WebServerService {
111    pub fn new(config: ServerConfig) -> Self {
112        WebServerService { config }
113    }
114
115    pub fn get_config(&self) -> &ServerConfig {
116        &self.config
117    }
118}
119
120#[async_trait]
121impl ProxyHttp for WebServerService {
122    type CTX = Option<SiteConfig>;
123
124    fn new_ctx(&self) -> Self::CTX {
125        None
126    }
127
128    async fn upstream_peer(
129        &self,
130        _session: &mut Session,
131        _ctx: &mut Self::CTX,
132    ) -> Result<Box<HttpPeer>> {
133        // Since we're handling requests locally, we don't need an upstream peer
134        // This should not be called since we handle everything in request_filter
135        Err(Error::new(ErrorType::InternalError).into_down())
136    }
137
138    async fn request_filter(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<bool> {
139        // Extract host and port information
140        let host_header = session
141            .req_header()
142            .headers
143            .get("Host")
144            .and_then(|h| h.to_str().ok())
145            .unwrap_or("localhost");
146
147        // Parse host and port
148        let (hostname, port) = if let Some(pos) = host_header.find(':') {
149            let hostname = &host_header[..pos];
150            let port_str = &host_header[pos + 1..];
151            let port = port_str.parse::<u16>().unwrap_or(8080);
152            (hostname, port)
153        } else {
154            // Default to 8080 if no port specified
155            (host_header, 8080)
156        };
157
158        // Find the matching site configuration and store it in context
159        let site_config = self.config.find_site_by_host_port(hostname, port);
160        *ctx = site_config.cloned();
161
162        let path = session.req_header().uri.path().to_string();
163
164        // Log the incoming request with site info
165        if let Some(site) = ctx.as_ref() {
166            log::info!(
167                "Incoming request: {} {} (site: {}, static_dir: {})",
168                session.req_header().method,
169                session.req_header().uri,
170                site.name,
171                site.static_dir
172            );
173        } else {
174            log::warn!(
175                "No site configuration found for {}:{}, using default",
176                hostname,
177                port
178            );
179        }
180
181        match path.as_str() {
182            "/api/health" => {
183                self.handle_health(session, ctx.as_ref()).await?;
184                Ok(true)
185            }
186            "/api/file" => {
187                self.handle_file_content(session, ctx.as_ref()).await?;
188                Ok(true)
189            }
190            "/api/sites" => {
191                self.handle_sites_info(session, ctx.as_ref()).await?;
192                Ok(true)
193            }
194            "/" => {
195                // Serve static index.html from the site's static directory
196                if let Some(site) = ctx.as_ref() {
197                    let file_path = format!("{}/index.html", site.static_dir);
198                    self.handle_static_file(session, &file_path, ctx.as_ref())
199                        .await?;
200                } else {
201                    self.handle_404(session, ctx.as_ref()).await?;
202                }
203                Ok(true)
204            }
205            path if path.starts_with("/static/") => {
206                // Serve static files from the site's static directory
207                if let Some(site) = ctx.as_ref() {
208                    let file_path = format!("{}{}", site.static_dir, &path[7..]); // Remove "/static" prefix
209                    self.handle_static_file(session, &file_path, ctx.as_ref())
210                        .await?;
211                } else {
212                    self.handle_404(session, ctx.as_ref()).await?;
213                }
214                Ok(true)
215            }
216            path if path.ends_with(".html") => {
217                // Serve HTML files from the site's static directory
218                if let Some(site) = ctx.as_ref() {
219                    let file_path = format!("{}{}", site.static_dir, path);
220                    self.handle_static_file(session, &file_path, ctx.as_ref())
221                        .await?;
222                } else {
223                    self.handle_404(session, ctx.as_ref()).await?;
224                }
225                Ok(true)
226            }
227            _ => {
228                // Try to serve any other path as a static file
229                if let Some(site) = ctx.as_ref() {
230                    let file_path = format!("{}{}", site.static_dir, path);
231
232                    // Check if it's a potentially valid file (has extension or is a known file)
233                    if self.is_static_file_request(&path) {
234                        self.handle_static_file(session, &file_path, ctx.as_ref())
235                            .await?;
236                    } else {
237                        // For directory requests, try to serve index.html from that directory
238                        let index_path = if path.ends_with('/') {
239                            format!("{}{}index.html", site.static_dir, path)
240                        } else {
241                            format!("{}{}/index.html", site.static_dir, path)
242                        };
243
244                        // Check if index.html exists in the requested directory
245                        if std::path::Path::new(&index_path).exists() {
246                            self.handle_static_file(session, &index_path, ctx.as_ref())
247                                .await?;
248                        } else {
249                            // If no index.html, try the original file path anyway
250                            self.handle_static_file(session, &file_path, ctx.as_ref())
251                                .await?;
252                        }
253                    }
254                } else {
255                    self.handle_404(session, ctx.as_ref()).await?;
256                }
257                Ok(true)
258            }
259        }
260    }
261}
262
263impl WebServerService {
264    /// Apply site-specific headers to the response header
265    fn apply_site_headers(
266        &self,
267        header: &mut ResponseHeader,
268        site_config: Option<&SiteConfig>,
269    ) -> Result<()> {
270        if let Some(site) = site_config {
271            for (key, value) in &site.headers {
272                header.insert_header(key.clone(), value.clone())?;
273            }
274        }
275        Ok(())
276    }
277
278    /// Check if a path looks like a static file request
279    fn is_static_file_request(&self, path: &str) -> bool {
280        // Check if path has a file extension
281        if let Some(last_segment) = path.split('/').next_back() {
282            if last_segment.contains('.') {
283                return true;
284            }
285        }
286
287        // Check for common static file patterns
288        path.contains("/css/")
289            || path.contains("/js/")
290            || path.contains("/images/")
291            || path.contains("/img/")
292            || path.contains("/assets/")
293            || path.contains("/fonts/")
294            || path.ends_with(".css")
295            || path.ends_with(".js")
296            || path.ends_with(".png")
297            || path.ends_with(".jpg")
298            || path.ends_with(".jpeg")
299            || path.ends_with(".gif")
300            || path.ends_with(".svg")
301            || path.ends_with(".ico")
302            || path.ends_with(".woff")
303            || path.ends_with(".woff2")
304            || path.ends_with(".ttf")
305            || path.ends_with(".pdf")
306            || path.ends_with(".txt")
307            || path.ends_with(".xml")
308            || path.ends_with(".json")
309    }
310
311    async fn handle_static_file(
312        &self,
313        session: &mut Session,
314        file_path: &str,
315        site_config: Option<&SiteConfig>,
316    ) -> Result<()> {
317        match read_file_bytes(file_path) {
318            Ok(content) => {
319                let mime_type = get_mime_type(file_path);
320                let mut header = ResponseHeader::build(200, Some(4))?;
321                header.insert_header("Content-Type", mime_type)?;
322                header.insert_header("Content-Length", content.len().to_string())?;
323
324                // Add cache headers for static files
325                if file_path.starts_with("static/") {
326                    header.insert_header("Cache-Control", "public, max-age=3600")?;
327                }
328
329                // Apply site-specific headers
330                self.apply_site_headers(&mut header, site_config)?;
331
332                session
333                    .write_response_header(Box::new(header), false)
334                    .await?;
335                session
336                    .write_response_body(Some(content.into()), true)
337                    .await?;
338            }
339            Err(e) => {
340                log::warn!("Failed to read static file {}: {}", file_path, e);
341
342                // Return 404 for missing static files
343                let error_response = serde_json::json!({
344                    "error": "File Not Found",
345                    "message": format!("The requested file '{}' was not found", file_path),
346                    "status": 404
347                });
348
349                let response_body = error_response.to_string();
350                let response_bytes = response_body.into_bytes();
351                let mut header = ResponseHeader::build(404, Some(4))?;
352                header.insert_header("Content-Type", "application/json")?;
353                header.insert_header("Content-Length", response_bytes.len().to_string())?;
354
355                // Apply site-specific headers even for error responses
356                self.apply_site_headers(&mut header, site_config)?;
357
358                session
359                    .write_response_header(Box::new(header), false)
360                    .await?;
361                session
362                    .write_response_body(Some(response_bytes.into()), true)
363                    .await?;
364            }
365        }
366
367        Ok(())
368    }
369
370    async fn handle_sites_info(
371        &self,
372        session: &mut Session,
373        site_config: Option<&SiteConfig>,
374    ) -> Result<()> {
375        let response = serde_json::json!({
376            "server": self.config.server.name,
377            "sites": self.config.sites.iter().map(|site| serde_json::json!({
378                "name": site.name,
379                "hostname": site.hostname,
380                "port": site.port,
381                "static_dir": site.static_dir,
382                "default": site.default,
383                "api_only": site.api_only,
384                "headers": site.headers,
385                "url": format!("http://{}:{}", site.hostname, site.port)
386            })).collect::<Vec<_>>(),
387            "total_sites": self.config.sites.len()
388        });
389
390        let response_body = response.to_string();
391        let response_bytes = response_body.into_bytes();
392        let mut header = ResponseHeader::build(200, Some(4))?;
393        header.insert_header("Content-Type", "application/json")?;
394        header.insert_header("Content-Length", response_bytes.len().to_string())?;
395
396        // Apply site-specific headers
397        self.apply_site_headers(&mut header, site_config)?;
398
399        session
400            .write_response_header(Box::new(header), false)
401            .await?;
402        session
403            .write_response_body(Some(response_bytes.into()), true)
404            .await?;
405
406        Ok(())
407    }
408
409    async fn handle_health(
410        &self,
411        session: &mut Session,
412        site_config: Option<&SiteConfig>,
413    ) -> Result<()> {
414        let response = serde_json::json!({
415            "status": "ok",
416            "timestamp": chrono::Utc::now().to_rfc3339(),
417            "service": "bws-web-server"
418        });
419
420        let response_body = response.to_string();
421        let response_bytes = response_body.into_bytes();
422        let mut header = ResponseHeader::build(200, Some(4))?;
423        header.insert_header("Content-Type", "application/json")?;
424        header.insert_header("Content-Length", response_bytes.len().to_string())?;
425
426        // Apply site-specific headers
427        self.apply_site_headers(&mut header, site_config)?;
428
429        session
430            .write_response_header(Box::new(header), false)
431            .await?;
432        session
433            .write_response_body(Some(response_bytes.into()), true)
434            .await?;
435
436        Ok(())
437    }
438
439    async fn handle_file_content(
440        &self,
441        session: &mut Session,
442        site_config: Option<&SiteConfig>,
443    ) -> Result<()> {
444        // Extract file path from query parameters
445        let query = session.req_header().uri.query().unwrap_or("");
446        let file_path = query
447            .split('&')
448            .find(|param| param.starts_with("path="))
449            .and_then(|param| param.split('=').nth(1))
450            .unwrap_or("");
451
452        if file_path.is_empty() {
453            let error_response = serde_json::json!({
454                "error": "Missing 'path' query parameter",
455                "example": "/api/file?path=Cargo.toml"
456            });
457
458            let response_body = error_response.to_string();
459            let response_bytes = response_body.into_bytes();
460            let mut header = ResponseHeader::build(400, Some(4))?;
461            header.insert_header("Content-Type", "application/json")?;
462            header.insert_header("Content-Length", response_bytes.len().to_string())?;
463
464            // Apply site-specific headers
465            self.apply_site_headers(&mut header, site_config)?;
466
467            session
468                .write_response_header(Box::new(header), false)
469                .await?;
470            session
471                .write_response_body(Some(response_bytes.into()), true)
472                .await?;
473
474            return Ok(());
475        }
476
477        match read_file_content(file_path) {
478            Ok(content) => {
479                let response = serde_json::json!({
480                    "file_path": file_path,
481                    "content": content,
482                    "size": content.len()
483                });
484
485                let response_body = response.to_string();
486                let response_bytes = response_body.into_bytes();
487                let mut header = ResponseHeader::build(200, Some(4))?;
488                header.insert_header("Content-Type", "application/json")?;
489                header.insert_header("Content-Length", response_bytes.len().to_string())?;
490
491                // Apply site-specific headers
492                self.apply_site_headers(&mut header, site_config)?;
493
494                session
495                    .write_response_header(Box::new(header), false)
496                    .await?;
497                session
498                    .write_response_body(Some(response_bytes.into()), true)
499                    .await?;
500            }
501            Err(e) => {
502                let error_response = serde_json::json!({
503                    "error": format!("Failed to read file: {}", e),
504                    "file_path": file_path
505                });
506
507                let response_body = error_response.to_string();
508                let response_bytes = response_body.into_bytes();
509                let mut header = ResponseHeader::build(404, Some(4))?;
510                header.insert_header("Content-Type", "application/json")?;
511                header.insert_header("Content-Length", response_bytes.len().to_string())?;
512
513                // Apply site-specific headers
514                self.apply_site_headers(&mut header, site_config)?;
515
516                session
517                    .write_response_header(Box::new(header), false)
518                    .await?;
519                session
520                    .write_response_body(Some(response_bytes.into()), true)
521                    .await?;
522            }
523        }
524
525        Ok(())
526    }
527
528    async fn handle_404(
529        &self,
530        session: &mut Session,
531        site_config: Option<&SiteConfig>,
532    ) -> Result<()> {
533        let error_response = serde_json::json!({
534            "error": "Not Found",
535            "message": "The requested endpoint does not exist",
536            "available_endpoints": ["/", "/api/health", "/api/file"]
537        });
538
539        let response_body = error_response.to_string();
540        let response_bytes = response_body.into_bytes();
541        let mut header = ResponseHeader::build(404, Some(4))?;
542        header.insert_header("Content-Type", "application/json")?;
543        header.insert_header("Content-Length", response_bytes.len().to_string())?;
544
545        // Apply site-specific headers
546        self.apply_site_headers(&mut header, site_config)?;
547
548        session
549            .write_response_header(Box::new(header), false)
550            .await?;
551        session
552            .write_response_body(Some(response_bytes.into()), true)
553            .await?;
554
555        Ok(())
556    }
557}