synor/crates/synor-hosting/src/router.rs
2026-02-02 05:58:22 +05:30

307 lines
8.9 KiB
Rust

//! Hosting Router - Route requests to content based on host
//!
//! Handles subdomain-based routing, SPA support, and custom domains.
use crate::domain::DomainVerifier;
use crate::error::{Error, Result};
use crate::registry::NameRegistry;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use synor_storage::ContentId;
/// Route configuration for a hosted site
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteConfig {
/// Fallback routes (pattern -> target)
/// e.g., "/*" -> "/index.html" for SPAs
pub routes: HashMap<String, String>,
/// Custom headers (pattern -> headers)
pub headers: HashMap<String, HashMap<String, String>>,
/// Redirects (from -> (to, status))
pub redirects: Vec<Redirect>,
/// Error pages (status -> path)
pub error_pages: HashMap<u16, String>,
}
impl Default for RouteConfig {
fn default() -> Self {
let mut routes = HashMap::new();
// Default SPA routing
routes.insert("/*".to_string(), "/index.html".to_string());
Self {
routes,
headers: HashMap::new(),
redirects: Vec::new(),
error_pages: HashMap::new(),
}
}
}
/// Redirect rule
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Redirect {
/// Source path (can include wildcards)
pub from: String,
/// Target path or URL
pub to: String,
/// HTTP status code (301, 302, 307, 308)
pub status: u16,
}
/// Resolved route result
#[derive(Debug, Clone)]
pub struct ResolvedRoute {
/// CID of the content
pub cid: ContentId,
/// Path within the content
pub path: String,
/// Additional headers to set
pub headers: HashMap<String, String>,
/// Is this a fallback route (SPA)?
pub is_fallback: bool,
}
/// Hosting router
pub struct HostingRouter {
/// Synor hosting domain (e.g., "synor.network")
hosting_domain: String,
/// Name registry for subdomain resolution
registry: NameRegistry,
/// Domain verifier for custom domains
verifier: DomainVerifier,
/// Per-name route configurations
configs: HashMap<String, RouteConfig>,
/// Current block number (for expiry checks)
current_block: u64,
}
impl HostingRouter {
/// Create a new hosting router
pub fn new(hosting_domain: String) -> Self {
Self {
hosting_domain: hosting_domain.clone(),
registry: NameRegistry::new(),
verifier: DomainVerifier::new(hosting_domain),
configs: HashMap::new(),
current_block: 0,
}
}
/// Set current block number
pub fn set_block(&mut self, block: u64) {
self.current_block = block;
}
/// Get the name registry
pub fn registry(&self) -> &NameRegistry {
&self.registry
}
/// Get mutable name registry
pub fn registry_mut(&mut self) -> &mut NameRegistry {
&mut self.registry
}
/// Get the domain verifier
pub fn verifier(&self) -> &DomainVerifier {
&self.verifier
}
/// Get mutable domain verifier
pub fn verifier_mut(&mut self) -> &mut DomainVerifier {
&mut self.verifier
}
/// Set route configuration for a name
pub fn set_config(&mut self, name: &str, config: RouteConfig) {
self.configs.insert(name.to_string(), config);
}
/// Route a request based on host and path
pub fn route(&self, host: &str, path: &str) -> Result<ResolvedRoute> {
// Parse host to determine name
let name = self.parse_host(host)?;
// Resolve name to CID
let cid = self
.registry
.resolve(&name, self.current_block)
.ok_or_else(|| Error::NameNotFound(name.clone()))?;
// Get route config (or default)
let config = self.configs.get(&name).cloned().unwrap_or_default();
// Check for redirects
if let Some(redirect) = self.find_redirect(path, &config.redirects) {
return Err(Error::Redirect {
to: redirect.to.clone(),
status: redirect.status,
});
}
// Resolve path
let (resolved_path, is_fallback) = self.resolve_path(path, &config);
// Get headers for this path
let headers = self.get_headers(&resolved_path, &config);
Ok(ResolvedRoute {
cid,
path: resolved_path,
headers,
is_fallback,
})
}
/// Parse host header to extract name
fn parse_host(&self, host: &str) -> Result<String> {
// Remove port if present
let host = host.split(':').next().unwrap_or(host);
// Check if it's a subdomain of hosting domain
if let Some(subdomain) = host.strip_suffix(&format!(".{}", self.hosting_domain)) {
// Validate subdomain is a valid name
crate::validate_name(subdomain)?;
return Ok(subdomain.to_string());
}
// Check if it's a custom domain
if let Some(name) = self.registry.resolve_domain(host) {
// Verify domain is still verified
if self.verifier.is_verified(host, self.current_block) {
return Ok(name.to_string());
} else {
return Err(Error::DomainNotVerified(host.to_string()));
}
}
Err(Error::UnknownHost(host.to_string()))
}
/// Resolve path using route config
fn resolve_path(&self, path: &str, config: &RouteConfig) -> (String, bool) {
// Normalize path
let path = if path.is_empty() || path == "/" {
"/index.html".to_string()
} else {
path.to_string()
};
// Check for exact match in routes (would be file existence check in real impl)
// For now, assume files with extensions exist, others need fallback
if path.contains('.') {
return (path, false);
}
// Check route patterns
for (pattern, target) in &config.routes {
if self.matches_pattern(&path, pattern) {
return (target.clone(), true);
}
}
// Default: return path as-is
(path, false)
}
/// Check if path matches a route pattern
fn matches_pattern(&self, path: &str, pattern: &str) -> bool {
if pattern == "/*" {
return true;
}
if pattern.ends_with("/*") {
let prefix = pattern.trim_end_matches("/*");
return path.starts_with(prefix);
}
path == pattern
}
/// Find matching redirect
fn find_redirect<'a>(&self, path: &str, redirects: &'a [Redirect]) -> Option<&'a Redirect> {
for redirect in redirects {
if self.matches_pattern(path, &redirect.from) {
return Some(redirect);
}
}
None
}
/// Get headers for a path
fn get_headers(&self, path: &str, config: &RouteConfig) -> HashMap<String, String> {
let mut headers = HashMap::new();
// Find matching header patterns
for (pattern, pattern_headers) in &config.headers {
if self.matches_pattern(path, pattern) {
headers.extend(pattern_headers.clone());
}
}
headers
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::RegistrationRequest;
use synor_storage::ContentId;
fn make_cid(data: &[u8]) -> ContentId {
ContentId::from_content(data)
}
#[test]
fn test_route_subdomain() {
let mut router = HostingRouter::new("synor.network".to_string());
let owner = [1u8; 32];
let cid = make_cid(b"my app content");
// Register name
let request = RegistrationRequest {
name: "myapp".to_string(),
cid: cid.clone(),
duration: 1000,
metadata: None,
};
router.registry_mut().register(request, owner, 0).unwrap();
// Route request
let resolved = router.route("myapp.synor.network", "/").unwrap();
assert_eq!(resolved.cid.digest, cid.digest);
assert_eq!(resolved.path, "/index.html");
}
#[test]
fn test_spa_routing() {
let mut router = HostingRouter::new("synor.network".to_string());
let owner = [1u8; 32];
let cid = make_cid(b"spa content");
let request = RegistrationRequest {
name: "myapp".to_string(),
cid: cid.clone(),
duration: 1000,
metadata: None,
};
router.registry_mut().register(request, owner, 0).unwrap();
// Deep path should fallback to index.html
let resolved = router
.route("myapp.synor.network", "/dashboard/settings")
.unwrap();
assert_eq!(resolved.path, "/index.html");
assert!(resolved.is_fallback);
}
#[test]
fn test_unknown_host() {
let router = HostingRouter::new("synor.network".to_string());
let result = router.route("unknown.synor.network", "/");
assert!(result.is_err());
}
}