synor/crates/synor-hosting/src/server/handler.rs
Gulshan Yadav e23a56049c feat(hosting): add hosting gateway server with Docker deployment
Add HTTP server for Synor Hosting with:

- server/mod.rs: Gateway server using axum
- server/handler.rs: Request routing to storage, content type detection
- server/middleware.rs: Token bucket rate limiting, cache control, metrics
- server/ssl.rs: Let's Encrypt auto-provisioning (stub)
- bin/hosting-gateway.rs: CLI binary with env var config

Docker deployment:
- docker/hosting-gateway/Dockerfile: Multi-stage build
- docker/hosting-gateway/Caddyfile: Wildcard HTTPS for *.synor.cc
- docker-compose.hosting.yml: Full hosting stack with Caddy

37 tests passing.
2026-01-10 12:45:26 +05:30

325 lines
11 KiB
Rust

//! 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<Output = Response<Body>>;
}
/// Handle incoming request
pub async fn handle_request(
State(state): State<Arc<GatewayState>>,
Host(host): Host,
request: Request<Body>,
) -> 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<u8>, Option<String>), 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<Body> {
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<Body> {
let body = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>404 - Not Found</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }}
.container {{ text-align: center; padding: 40px; }}
h1 {{ font-size: 72px; margin: 0; }}
p {{ font-size: 18px; opacity: 0.8; }}
a {{ color: white; text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<p>The site <strong>{}</strong> was not found.</p>
<p><a href="https://synor.cc">Deploy your own site on Synor</a></p>
</div>
</body>
</html>"#,
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<Body> {
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<Body> {
let body = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Domain Not Verified</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
background: #f5f5f5; color: #333; }}
.container {{ text-align: center; padding: 40px; max-width: 500px; }}
h1 {{ font-size: 24px; color: #e74c3c; }}
p {{ font-size: 16px; line-height: 1.6; }}
code {{ background: #eee; padding: 2px 6px; border-radius: 4px; }}
</style>
</head>
<body>
<div class="container">
<h1>Domain Not Verified</h1>
<p>The domain <strong>{}</strong> has not been verified.</p>
<p>If you own this domain, please complete DNS verification in your Synor dashboard.</p>
</div>
</body>
</html>"#,
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<Body> {
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"));
}
}