//! Request Handler //! //! Handles incoming HTTP requests by routing based on Host header //! and fetching content from Synor Storage. use super::GatewayState; use crate::error::Error; use axum::{ body::Body, extract::{Host, State}, http::{header, Request, Response, StatusCode, Uri}, response::IntoResponse, }; use std::sync::Arc; /// Content types by extension const CONTENT_TYPES: &[(&str, &str)] = &[ (".html", "text/html; charset=utf-8"), (".htm", "text/html; charset=utf-8"), (".css", "text/css; charset=utf-8"), (".js", "application/javascript; charset=utf-8"), (".mjs", "application/javascript; charset=utf-8"), (".json", "application/json; charset=utf-8"), (".xml", "application/xml; charset=utf-8"), (".svg", "image/svg+xml"), (".png", "image/png"), (".jpg", "image/jpeg"), (".jpeg", "image/jpeg"), (".gif", "image/gif"), (".webp", "image/webp"), (".avif", "image/avif"), (".ico", "image/x-icon"), (".woff", "font/woff"), (".woff2", "font/woff2"), (".ttf", "font/ttf"), (".otf", "font/otf"), (".eot", "application/vnd.ms-fontobject"), (".wasm", "application/wasm"), (".pdf", "application/pdf"), (".zip", "application/zip"), (".txt", "text/plain; charset=utf-8"), (".md", "text/markdown; charset=utf-8"), (".mp4", "video/mp4"), (".webm", "video/webm"), (".mp3", "audio/mpeg"), (".ogg", "audio/ogg"), ]; /// Request handler trait pub trait RequestHandler { fn handle(&self, host: &str, path: &str) -> impl std::future::Future>; } /// Handle incoming request pub async fn handle_request( State(state): State>, Host(host): Host, request: Request, ) -> impl IntoResponse { let path = request.uri().path(); let client_ip = request .headers() .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); // Rate limiting if !state.rate_limiter.check(client_ip) { return rate_limited_response(); } // Log request if state.config.enable_logging { eprintln!("[{}] {} {}", client_ip, host, path); } // Route the request let router = state.router.read().await; match router.route(&host, path) { Ok(resolved) => { // Fetch content from storage gateway match fetch_content(&state, &resolved.cid.to_string(), &resolved.path).await { Ok((content, original_content_type)) => { let content_type = if original_content_type.is_some() { original_content_type.unwrap() } else { guess_content_type(&resolved.path).to_string() }; let mut builder = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type); // Add custom headers from route config for (key, value) in &resolved.headers { builder = builder.header(key.as_str(), value.as_str()); } // Add cache headers if resolved.is_fallback { // SPA fallback - don't cache builder = builder.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"); } else if is_immutable_asset(&resolved.path) { // Hashed assets - cache forever builder = builder.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable"); } else { // Regular assets - cache with revalidation builder = builder.header( header::CACHE_CONTROL, format!("public, max-age={}", state.config.cache_ttl), ); } // Security headers builder = builder .header("X-Content-Type-Options", "nosniff") .header("X-Frame-Options", "SAMEORIGIN") .header("Referrer-Policy", "strict-origin-when-cross-origin"); builder.body(Body::from(content)).unwrap() } Err(e) => { eprintln!("Error fetching content: {}", e); not_found_response(&host) } } } Err(Error::Redirect { to, status }) => { redirect_response(&to, status) } Err(Error::NameNotFound(_)) | Err(Error::UnknownHost(_)) => { not_found_response(&host) } Err(Error::DomainNotVerified(domain)) => { domain_not_verified_response(&domain) } Err(e) => { eprintln!("Routing error: {}", e); error_response(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") } } } /// Fetch content from storage gateway async fn fetch_content( state: &GatewayState, cid: &str, path: &str, ) -> Result<(Vec, Option), Error> { let url = format!("{}/{}{}", state.config.storage_gateway_url, cid, path); let response = state .http_client .get(&url) .send() .await .map_err(|e| Error::Storage(e.to_string()))?; if !response.status().is_success() { return Err(Error::Storage(format!( "Storage returned status {}", response.status() ))); } let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let bytes = response .bytes() .await .map_err(|e| Error::Storage(e.to_string()))?; Ok((bytes.to_vec(), content_type)) } /// Guess content type from path extension fn guess_content_type(path: &str) -> &'static str { for (ext, content_type) in CONTENT_TYPES { if path.ends_with(ext) { return content_type; } } "application/octet-stream" } /// Check if path looks like an immutable asset (with hash in filename) fn is_immutable_asset(path: &str) -> bool { // Common patterns: main.abc123.js, styles.abc123.css, image.abc123.png let parts: Vec<&str> = path.split('.').collect(); if parts.len() >= 3 { let potential_hash = parts[parts.len() - 2]; // Check if it looks like a hash (8+ hex or alphanumeric chars) potential_hash.len() >= 8 && potential_hash.chars().all(|c| c.is_ascii_alphanumeric()) } else { false } } /// Create a rate limited response fn rate_limited_response() -> Response { Response::builder() .status(StatusCode::TOO_MANY_REQUESTS) .header(header::CONTENT_TYPE, "application/json") .header("Retry-After", "60") .body(Body::from(r#"{"error":"Rate limit exceeded"}"#)) .unwrap() } /// Create a not found response fn not_found_response(host: &str) -> Response { let body = format!( r#" 404 - Not Found

404

The site {} was not found.

Deploy your own site on Synor

"#, host ); Response::builder() .status(StatusCode::NOT_FOUND) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .body(Body::from(body)) .unwrap() } /// Create a redirect response fn redirect_response(to: &str, status: u16) -> Response { let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::TEMPORARY_REDIRECT); Response::builder() .status(status_code) .header(header::LOCATION, to) .body(Body::empty()) .unwrap() } /// Create a domain not verified response fn domain_not_verified_response(domain: &str) -> Response { let body = format!( r#" Domain Not Verified

Domain Not Verified

The domain {} has not been verified.

If you own this domain, please complete DNS verification in your Synor dashboard.

"#, domain ); Response::builder() .status(StatusCode::FORBIDDEN) .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .body(Body::from(body)) .unwrap() } /// Create an error response fn error_response(status: StatusCode, message: &str) -> Response { Response::builder() .status(status) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(format!(r#"{{"error":"{}"}}"#, message))) .unwrap() } #[cfg(test)] mod tests { use super::*; #[test] fn test_guess_content_type() { assert_eq!(guess_content_type("/index.html"), "text/html; charset=utf-8"); assert_eq!(guess_content_type("/style.css"), "text/css; charset=utf-8"); assert_eq!(guess_content_type("/app.js"), "application/javascript; charset=utf-8"); assert_eq!(guess_content_type("/logo.png"), "image/png"); assert_eq!(guess_content_type("/unknown.xyz"), "application/octet-stream"); } #[test] fn test_is_immutable_asset() { assert!(is_immutable_asset("/main.abc12345.js")); assert!(is_immutable_asset("/styles.def67890.css")); assert!(!is_immutable_asset("/index.html")); assert!(!is_immutable_asset("/app.js")); assert!(!is_immutable_asset("/image.png")); } }