307 lines
8.9 KiB
Rust
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());
|
|
}
|
|
}
|