//! 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, /// Custom headers (pattern -> headers) pub headers: HashMap>, /// Redirects (from -> (to, status)) pub redirects: Vec, /// Error pages (status -> path) pub error_pages: HashMap, } 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, /// 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, /// 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 { // 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 { // 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 { 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()); } }