1use 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 for site in &self.sites {
44 if site.hostname == host && site.port == port {
45 return Some(site);
46 }
47 }
48
49 for site in &self.sites {
51 if site.port == port {
52 return Some(site);
53 }
54 }
55
56 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 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 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 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 (host_header, 8080)
156 };
157
158 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 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 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 if let Some(site) = ctx.as_ref() {
208 let file_path = format!("{}{}", site.static_dir, &path[7..]); 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 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 if let Some(site) = ctx.as_ref() {
230 let file_path = format!("{}{}", site.static_dir, path);
231
232 if self.is_static_file_request(&path) {
234 self.handle_static_file(session, &file_path, ctx.as_ref())
235 .await?;
236 } else {
237 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 if std::path::Path::new(&index_path).exists() {
246 self.handle_static_file(session, &index_path, ctx.as_ref())
247 .await?;
248 } else {
249 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 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 fn is_static_file_request(&self, path: &str) -> bool {
280 if let Some(last_segment) = path.split('/').next_back() {
282 if last_segment.contains('.') {
283 return true;
284 }
285 }
286
287 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 if file_path.starts_with("static/") {
326 header.insert_header("Cache-Control", "public, max-age=3600")?;
327 }
328
329 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 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 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 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 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 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 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 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 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 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}