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.
325 lines
11 KiB
Rust
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"));
|
|
}
|
|
}
|