feat(hosting): add Synor Hosting subdomain-based web hosting
Add synor-hosting crate for decentralized web hosting with: - Name Registry: On-chain name→CID mapping with ownership, expiry, and custom domain linking - Domain Verification: CNAME/TXT DNS verification for custom domains - Hosting Router: Host-based routing with SPA support, redirects, and custom headers - synor.json: Project configuration for build, routes, and error pages Users can deploy to myapp.synor.cc and optionally link custom domains. 23 tests passing.
This commit is contained in:
parent
f5bdef2691
commit
a70b2c765c
9 changed files with 2206 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ members = [
|
||||||
"crates/synor-consensus",
|
"crates/synor-consensus",
|
||||||
"crates/synor-network",
|
"crates/synor-network",
|
||||||
"crates/synor-storage",
|
"crates/synor-storage",
|
||||||
|
"crates/synor-hosting",
|
||||||
"crates/synor-governance",
|
"crates/synor-governance",
|
||||||
"crates/synor-rpc",
|
"crates/synor-rpc",
|
||||||
"crates/synor-vm",
|
"crates/synor-vm",
|
||||||
|
|
|
||||||
39
crates/synor-hosting/Cargo.toml
Normal file
39
crates/synor-hosting/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
[package]
|
||||||
|
name = "synor-hosting"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Decentralized web hosting for the Synor blockchain"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Synor Team"]
|
||||||
|
repository = "https://github.com/synor/synor"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Core
|
||||||
|
thiserror = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Cryptography
|
||||||
|
blake3 = "1"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
|
||||||
|
# Encoding
|
||||||
|
bs58 = "0.5"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# DNS verification
|
||||||
|
trust-dns-resolver = { version = "0.23", optional = true }
|
||||||
|
|
||||||
|
# Local workspace crates
|
||||||
|
synor-types = { path = "../synor-types" }
|
||||||
|
synor-crypto = { path = "../synor-crypto" }
|
||||||
|
synor-storage = { path = "../synor-storage" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
dns = ["trust-dns-resolver"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
330
crates/synor-hosting/src/config.rs
Normal file
330
crates/synor-hosting/src/config.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
//! Synor.json Configuration
|
||||||
|
//!
|
||||||
|
//! Parses and manages the synor.json configuration file that users
|
||||||
|
//! include in their deployed projects.
|
||||||
|
|
||||||
|
use crate::router::{RouteConfig, Redirect};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Synor.json configuration file
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SynorJson {
|
||||||
|
/// Name of the deployment (optional, can be inferred)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// Build configuration
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub build: Option<BuildConfig>,
|
||||||
|
|
||||||
|
/// Routes configuration
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub routes: Option<RoutesConfig>,
|
||||||
|
|
||||||
|
/// Headers configuration
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<Vec<HeaderRule>>,
|
||||||
|
|
||||||
|
/// Redirects configuration
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redirects: Option<Vec<RedirectRule>>,
|
||||||
|
|
||||||
|
/// Custom error pages
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error_pages: Option<HashMap<u16, String>>,
|
||||||
|
|
||||||
|
/// Environment variables (non-secret, build-time)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub env: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// Functions/serverless configuration
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub functions: Option<FunctionsConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BuildConfig {
|
||||||
|
/// Build command
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub command: Option<String>,
|
||||||
|
|
||||||
|
/// Output directory (default: "dist" or "build")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output: Option<String>,
|
||||||
|
|
||||||
|
/// Install command (default: "npm install" or "pnpm install")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub install: Option<String>,
|
||||||
|
|
||||||
|
/// Node.js version
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub node_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoutesConfig {
|
||||||
|
/// Enable SPA mode (fallback to index.html)
|
||||||
|
#[serde(default)]
|
||||||
|
pub spa: bool,
|
||||||
|
|
||||||
|
/// Custom cleanUrls (remove .html extensions)
|
||||||
|
#[serde(default)]
|
||||||
|
pub clean_urls: bool,
|
||||||
|
|
||||||
|
/// Trailing slash behavior
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trailing_slash: Option<bool>,
|
||||||
|
|
||||||
|
/// Custom route rewrites
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rewrites: Option<Vec<RewriteRule>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header rule
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HeaderRule {
|
||||||
|
/// Path pattern (glob)
|
||||||
|
pub source: String,
|
||||||
|
/// Headers to apply
|
||||||
|
pub headers: Vec<HeaderKeyValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single header key-value pair
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HeaderKeyValue {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redirect rule (in synor.json format)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RedirectRule {
|
||||||
|
/// Source path
|
||||||
|
pub source: String,
|
||||||
|
/// Destination path or URL
|
||||||
|
pub destination: String,
|
||||||
|
/// HTTP status (default: 308)
|
||||||
|
#[serde(default = "default_redirect_status")]
|
||||||
|
pub status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_redirect_status() -> u16 {
|
||||||
|
308
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite rule
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RewriteRule {
|
||||||
|
/// Source path pattern
|
||||||
|
pub source: String,
|
||||||
|
/// Destination path
|
||||||
|
pub destination: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serverless functions configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FunctionsConfig {
|
||||||
|
/// Functions directory
|
||||||
|
#[serde(default = "default_functions_dir")]
|
||||||
|
pub directory: String,
|
||||||
|
|
||||||
|
/// Runtime (e.g., "nodejs20", "python3.11")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime: Option<String>,
|
||||||
|
|
||||||
|
/// Memory limit in MB
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub memory: Option<u32>,
|
||||||
|
|
||||||
|
/// Timeout in seconds
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_functions_dir() -> String {
|
||||||
|
"api".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SynorJson {
|
||||||
|
/// Parse from JSON string
|
||||||
|
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||||
|
serde_json::from_str(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from JSON bytes
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
|
||||||
|
serde_json::from_slice(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize to JSON string
|
||||||
|
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string_pretty(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to RouteConfig for the router
|
||||||
|
pub fn to_route_config(&self) -> RouteConfig {
|
||||||
|
let mut config = RouteConfig::default();
|
||||||
|
|
||||||
|
// Process routes
|
||||||
|
if let Some(routes) = &self.routes {
|
||||||
|
if routes.spa {
|
||||||
|
// SPA mode: all non-file paths go to index.html
|
||||||
|
config.routes.insert("/*".to_string(), "/index.html".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process rewrites
|
||||||
|
if let Some(rewrites) = &routes.rewrites {
|
||||||
|
for rewrite in rewrites {
|
||||||
|
config.routes.insert(rewrite.source.clone(), rewrite.destination.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process headers
|
||||||
|
if let Some(headers) = &self.headers {
|
||||||
|
for rule in headers {
|
||||||
|
let mut header_map = HashMap::new();
|
||||||
|
for kv in &rule.headers {
|
||||||
|
header_map.insert(kv.key.clone(), kv.value.clone());
|
||||||
|
}
|
||||||
|
config.headers.insert(rule.source.clone(), header_map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process redirects
|
||||||
|
if let Some(redirects) = &self.redirects {
|
||||||
|
for rule in redirects {
|
||||||
|
config.redirects.push(Redirect {
|
||||||
|
from: rule.source.clone(),
|
||||||
|
to: rule.destination.clone(),
|
||||||
|
status: rule.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process error pages
|
||||||
|
if let Some(error_pages) = &self.error_pages {
|
||||||
|
config.error_pages = error_pages.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_minimal() {
|
||||||
|
let json = r#"{}"#;
|
||||||
|
let config = SynorJson::from_json(json).unwrap();
|
||||||
|
assert!(config.name.is_none());
|
||||||
|
assert!(config.build.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_spa_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "myapp",
|
||||||
|
"routes": {
|
||||||
|
"spa": true,
|
||||||
|
"clean_urls": true
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let config = SynorJson::from_json(json).unwrap();
|
||||||
|
assert_eq!(config.name, Some("myapp".to_string()));
|
||||||
|
assert!(config.routes.as_ref().unwrap().spa);
|
||||||
|
assert!(config.routes.as_ref().unwrap().clean_urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_full_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "my-blog",
|
||||||
|
"build": {
|
||||||
|
"command": "npm run build",
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"routes": {
|
||||||
|
"spa": true
|
||||||
|
},
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/**",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "X-Frame-Options", "value": "DENY" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"redirects": [
|
||||||
|
{
|
||||||
|
"source": "/old-page",
|
||||||
|
"destination": "/new-page",
|
||||||
|
"status": 301
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error_pages": {
|
||||||
|
"404": "/404.html"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let config = SynorJson::from_json(json).unwrap();
|
||||||
|
assert_eq!(config.name, Some("my-blog".to_string()));
|
||||||
|
assert_eq!(config.build.as_ref().unwrap().command, Some("npm run build".to_string()));
|
||||||
|
assert!(config.routes.as_ref().unwrap().spa);
|
||||||
|
assert_eq!(config.headers.as_ref().unwrap().len(), 1);
|
||||||
|
assert_eq!(config.redirects.as_ref().unwrap().len(), 1);
|
||||||
|
assert_eq!(config.error_pages.as_ref().unwrap().get(&404), Some(&"/404.html".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_route_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"routes": {
|
||||||
|
"spa": true
|
||||||
|
},
|
||||||
|
"redirects": [
|
||||||
|
{
|
||||||
|
"source": "/blog",
|
||||||
|
"destination": "/posts",
|
||||||
|
"status": 301
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let synor_json = SynorJson::from_json(json).unwrap();
|
||||||
|
let route_config = synor_json.to_route_config();
|
||||||
|
|
||||||
|
// SPA mode should add /* -> /index.html
|
||||||
|
assert_eq!(route_config.routes.get("/*"), Some(&"/index.html".to_string()));
|
||||||
|
|
||||||
|
// Redirect should be converted
|
||||||
|
assert_eq!(route_config.redirects.len(), 1);
|
||||||
|
assert_eq!(route_config.redirects[0].from, "/blog");
|
||||||
|
assert_eq!(route_config.redirects[0].to, "/posts");
|
||||||
|
assert_eq!(route_config.redirects[0].status, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize() {
|
||||||
|
let config = SynorJson {
|
||||||
|
name: Some("test".to_string()),
|
||||||
|
routes: Some(RoutesConfig {
|
||||||
|
spa: true,
|
||||||
|
clean_urls: false,
|
||||||
|
trailing_slash: None,
|
||||||
|
rewrites: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = config.to_json().unwrap();
|
||||||
|
assert!(json.contains("\"name\": \"test\""));
|
||||||
|
assert!(json.contains("\"spa\": true"));
|
||||||
|
}
|
||||||
|
}
|
||||||
370
crates/synor-hosting/src/domain.rs
Normal file
370
crates/synor-hosting/src/domain.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
//! Domain Verification - Custom domain ownership proof
|
||||||
|
//!
|
||||||
|
//! Verifies that users own the domains they want to link to their names
|
||||||
|
//! using DNS-based verification (CNAME or TXT records).
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Verification method for custom domains
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum VerificationMethod {
|
||||||
|
/// CNAME record pointing to name.synor.network
|
||||||
|
Cname,
|
||||||
|
/// TXT record with verification token
|
||||||
|
Txt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain verification status
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum VerificationStatus {
|
||||||
|
/// Verification pending
|
||||||
|
Pending,
|
||||||
|
/// Domain verified
|
||||||
|
Verified,
|
||||||
|
/// Verification failed
|
||||||
|
Failed,
|
||||||
|
/// Verification expired (needs re-verification)
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain record
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DomainRecord {
|
||||||
|
/// The custom domain
|
||||||
|
pub domain: String,
|
||||||
|
/// Associated Synor name
|
||||||
|
pub name: String,
|
||||||
|
/// Verification method
|
||||||
|
pub method: VerificationMethod,
|
||||||
|
/// Verification token (for TXT method)
|
||||||
|
pub token: String,
|
||||||
|
/// Current status
|
||||||
|
pub status: VerificationStatus,
|
||||||
|
/// Block when verification was requested
|
||||||
|
pub requested_at: u64,
|
||||||
|
/// Block when verified (0 if not verified)
|
||||||
|
pub verified_at: u64,
|
||||||
|
/// Block when verification expires (0 = never)
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain verifier
|
||||||
|
pub struct DomainVerifier {
|
||||||
|
/// Pending verifications
|
||||||
|
pending: HashMap<String, DomainRecord>,
|
||||||
|
/// Verified domains
|
||||||
|
verified: HashMap<String, DomainRecord>,
|
||||||
|
/// Synor hosting domain suffix
|
||||||
|
hosting_domain: String,
|
||||||
|
/// Verification token prefix
|
||||||
|
token_prefix: String,
|
||||||
|
/// Verification expiry in blocks
|
||||||
|
verification_expiry: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainVerifier {
|
||||||
|
/// Create a new domain verifier
|
||||||
|
pub fn new(hosting_domain: String) -> Self {
|
||||||
|
Self {
|
||||||
|
pending: HashMap::new(),
|
||||||
|
verified: HashMap::new(),
|
||||||
|
hosting_domain,
|
||||||
|
token_prefix: "synor-verify=".to_string(),
|
||||||
|
verification_expiry: 5_256_000, // ~1 year at 6s blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request verification for a domain
|
||||||
|
pub fn request_verification(
|
||||||
|
&mut self,
|
||||||
|
domain: String,
|
||||||
|
name: String,
|
||||||
|
method: VerificationMethod,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<DomainRecord> {
|
||||||
|
// Validate domain format
|
||||||
|
self.validate_domain(&domain)?;
|
||||||
|
|
||||||
|
// Check if already verified
|
||||||
|
if self.verified.contains_key(&domain) {
|
||||||
|
return Err(Error::DomainAlreadyLinked(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
let token = self.generate_token(&domain, &name, current_block);
|
||||||
|
|
||||||
|
let record = DomainRecord {
|
||||||
|
domain: domain.clone(),
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
token,
|
||||||
|
status: VerificationStatus::Pending,
|
||||||
|
requested_at: current_block,
|
||||||
|
verified_at: 0,
|
||||||
|
expires_at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.pending.insert(domain, record.clone());
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get verification instructions for a domain
|
||||||
|
pub fn get_instructions(&self, domain: &str) -> Result<VerificationInstructions> {
|
||||||
|
let record = self.pending.get(domain)
|
||||||
|
.ok_or_else(|| Error::DomainNotFound(domain.to_string()))?;
|
||||||
|
|
||||||
|
match record.method {
|
||||||
|
VerificationMethod::Cname => {
|
||||||
|
Ok(VerificationInstructions {
|
||||||
|
method: VerificationMethod::Cname,
|
||||||
|
record_type: "CNAME".to_string(),
|
||||||
|
record_name: record.domain.clone(),
|
||||||
|
record_value: format!("{}.{}", record.name, self.hosting_domain),
|
||||||
|
instructions: format!(
|
||||||
|
"Add a CNAME record:\n {} CNAME {}.{}",
|
||||||
|
record.domain, record.name, self.hosting_domain
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
VerificationMethod::Txt => {
|
||||||
|
Ok(VerificationInstructions {
|
||||||
|
method: VerificationMethod::Txt,
|
||||||
|
record_type: "TXT".to_string(),
|
||||||
|
record_name: format!("_synor.{}", record.domain),
|
||||||
|
record_value: format!("{}{}", self.token_prefix, record.token),
|
||||||
|
instructions: format!(
|
||||||
|
"Add a TXT record:\n _synor.{} TXT \"{}{}\"",
|
||||||
|
record.domain, self.token_prefix, record.token
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a domain (check DNS records)
|
||||||
|
pub fn verify(&mut self, domain: &str, current_block: u64) -> Result<DomainRecord> {
|
||||||
|
let record = self.pending.get(domain)
|
||||||
|
.ok_or_else(|| Error::DomainNotFound(domain.to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// In production, this would do actual DNS lookups
|
||||||
|
// For now, we'll simulate verification
|
||||||
|
let verified = self.check_dns(&record)?;
|
||||||
|
|
||||||
|
if verified {
|
||||||
|
let mut verified_record = record;
|
||||||
|
verified_record.status = VerificationStatus::Verified;
|
||||||
|
verified_record.verified_at = current_block;
|
||||||
|
verified_record.expires_at = current_block + self.verification_expiry;
|
||||||
|
|
||||||
|
self.pending.remove(domain);
|
||||||
|
self.verified.insert(domain.to_string(), verified_record.clone());
|
||||||
|
|
||||||
|
Ok(verified_record)
|
||||||
|
} else {
|
||||||
|
Err(Error::VerificationFailed(
|
||||||
|
domain.to_string(),
|
||||||
|
"DNS record not found or doesn't match".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a domain is verified
|
||||||
|
pub fn is_verified(&self, domain: &str, current_block: u64) -> bool {
|
||||||
|
self.verified.get(domain).map_or(false, |r| {
|
||||||
|
r.status == VerificationStatus::Verified
|
||||||
|
&& (r.expires_at == 0 || current_block <= r.expires_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get verified domain record
|
||||||
|
pub fn get_verified(&self, domain: &str) -> Option<&DomainRecord> {
|
||||||
|
self.verified.get(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove verification for a domain
|
||||||
|
pub fn remove(&mut self, domain: &str) -> bool {
|
||||||
|
self.pending.remove(domain).is_some() || self.verified.remove(domain).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate domain format
|
||||||
|
fn validate_domain(&self, domain: &str) -> Result<()> {
|
||||||
|
// Basic domain validation
|
||||||
|
if domain.is_empty() {
|
||||||
|
return Err(Error::InvalidDomain("Domain cannot be empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain.len() > 253 {
|
||||||
|
return Err(Error::InvalidDomain("Domain too long".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each label
|
||||||
|
for label in domain.split('.') {
|
||||||
|
if label.is_empty() || label.len() > 63 {
|
||||||
|
return Err(Error::InvalidDomain("Invalid label length".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||||
|
return Err(Error::InvalidDomain("Invalid characters in domain".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if label.starts_with('-') || label.ends_with('-') {
|
||||||
|
return Err(Error::InvalidDomain("Label cannot start or end with hyphen".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have at least one dot (not just a TLD)
|
||||||
|
if !domain.contains('.') {
|
||||||
|
return Err(Error::InvalidDomain("Domain must have at least one dot".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate verification token
|
||||||
|
fn generate_token(&self, domain: &str, name: &str, block: u64) -> String {
|
||||||
|
let input = format!("{}:{}:{}", domain, name, block);
|
||||||
|
let hash = blake3::hash(input.as_bytes());
|
||||||
|
hex::encode(&hash.as_bytes()[..16]) // 32 char hex token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check DNS records (stub - real implementation would use DNS resolver)
|
||||||
|
#[cfg(not(feature = "dns"))]
|
||||||
|
fn check_dns(&self, _record: &DomainRecord) -> Result<bool> {
|
||||||
|
// Without DNS feature, always return true (for testing)
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check DNS records (real implementation with trust-dns)
|
||||||
|
#[cfg(feature = "dns")]
|
||||||
|
fn check_dns(&self, record: &DomainRecord) -> Result<bool> {
|
||||||
|
use trust_dns_resolver::Resolver;
|
||||||
|
use trust_dns_resolver::config::*;
|
||||||
|
|
||||||
|
let resolver = Resolver::new(ResolverConfig::default(), ResolverOpts::default())
|
||||||
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
||||||
|
|
||||||
|
match record.method {
|
||||||
|
VerificationMethod::Cname => {
|
||||||
|
let expected = format!("{}.{}.", record.name, self.hosting_domain);
|
||||||
|
let response = resolver.lookup(&record.domain, RecordType::CNAME)
|
||||||
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
||||||
|
|
||||||
|
for cname in response.iter() {
|
||||||
|
if cname.to_string() == expected {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
VerificationMethod::Txt => {
|
||||||
|
let txt_domain = format!("_synor.{}", record.domain);
|
||||||
|
let expected = format!("{}{}", self.token_prefix, record.token);
|
||||||
|
|
||||||
|
let response = resolver.txt_lookup(&txt_domain)
|
||||||
|
.map_err(|e| Error::Dns(e.to_string()))?;
|
||||||
|
|
||||||
|
for txt in response.iter() {
|
||||||
|
let txt_str = txt.to_string();
|
||||||
|
if txt_str.contains(&expected) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verification instructions for users
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VerificationInstructions {
|
||||||
|
/// Verification method
|
||||||
|
pub method: VerificationMethod,
|
||||||
|
/// DNS record type to add
|
||||||
|
pub record_type: String,
|
||||||
|
/// DNS record name
|
||||||
|
pub record_name: String,
|
||||||
|
/// DNS record value
|
||||||
|
pub record_value: String,
|
||||||
|
/// Human-readable instructions
|
||||||
|
pub instructions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_verification() {
|
||||||
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
||||||
|
|
||||||
|
let record = verifier
|
||||||
|
.request_verification(
|
||||||
|
"myapp.com".to_string(),
|
||||||
|
"myapp".to_string(),
|
||||||
|
VerificationMethod::Cname,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(record.domain, "myapp.com");
|
||||||
|
assert_eq!(record.name, "myapp");
|
||||||
|
assert_eq!(record.status, VerificationStatus::Pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_instructions() {
|
||||||
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
||||||
|
|
||||||
|
verifier
|
||||||
|
.request_verification(
|
||||||
|
"myapp.com".to_string(),
|
||||||
|
"myapp".to_string(),
|
||||||
|
VerificationMethod::Cname,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let instructions = verifier.get_instructions("myapp.com").unwrap();
|
||||||
|
assert_eq!(instructions.record_type, "CNAME");
|
||||||
|
assert!(instructions.record_value.contains("myapp.synor.network"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_domain() {
|
||||||
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
||||||
|
|
||||||
|
verifier
|
||||||
|
.request_verification(
|
||||||
|
"myapp.com".to_string(),
|
||||||
|
"myapp".to_string(),
|
||||||
|
VerificationMethod::Cname,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Without DNS feature, verify always succeeds
|
||||||
|
let record = verifier.verify("myapp.com", 100).unwrap();
|
||||||
|
assert_eq!(record.status, VerificationStatus::Verified);
|
||||||
|
assert!(verifier.is_verified("myapp.com", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_domain() {
|
||||||
|
let mut verifier = DomainVerifier::new("synor.network".to_string());
|
||||||
|
|
||||||
|
// No TLD
|
||||||
|
assert!(verifier
|
||||||
|
.request_verification("myapp".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0)
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
assert!(verifier
|
||||||
|
.request_verification("".to_string(), "myapp".to_string(), VerificationMethod::Cname, 0)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
129
crates/synor-hosting/src/error.rs
Normal file
129
crates/synor-hosting/src/error.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
//! Hosting Error Types
|
||||||
|
//!
|
||||||
|
//! Error definitions for the Synor Hosting system.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Hosting errors
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Invalid name format
|
||||||
|
#[error("invalid name: {0}")]
|
||||||
|
InvalidName(String),
|
||||||
|
|
||||||
|
/// Name is reserved
|
||||||
|
#[error("name '{0}' is reserved")]
|
||||||
|
ReservedName(String),
|
||||||
|
|
||||||
|
/// Name is too similar to a reserved name (confusable)
|
||||||
|
#[error("name '{0}' is too similar to reserved name '{1}'")]
|
||||||
|
ConfusableName(String, String),
|
||||||
|
|
||||||
|
/// Name not found in registry
|
||||||
|
#[error("name '{0}' not found")]
|
||||||
|
NameNotFound(String),
|
||||||
|
|
||||||
|
/// Name already registered
|
||||||
|
#[error("name '{0}' is already registered")]
|
||||||
|
NameAlreadyRegistered(String),
|
||||||
|
|
||||||
|
/// Name is already taken
|
||||||
|
#[error("name '{0}' is already taken")]
|
||||||
|
NameTaken(String),
|
||||||
|
|
||||||
|
/// Invalid operation
|
||||||
|
#[error("invalid operation: {0}")]
|
||||||
|
InvalidOperation(String),
|
||||||
|
|
||||||
|
/// Name has expired
|
||||||
|
#[error("name '{0}' has expired")]
|
||||||
|
NameExpired(String),
|
||||||
|
|
||||||
|
/// Not the owner of this name
|
||||||
|
#[error("not the owner of this name")]
|
||||||
|
NotOwner,
|
||||||
|
|
||||||
|
/// Domain already linked to another name
|
||||||
|
#[error("domain '{0}' is already linked to another name")]
|
||||||
|
DomainAlreadyLinked(String),
|
||||||
|
|
||||||
|
/// Domain not verified
|
||||||
|
#[error("domain '{0}' is not verified")]
|
||||||
|
DomainNotVerified(String),
|
||||||
|
|
||||||
|
/// Domain verification pending
|
||||||
|
#[error("domain '{0}' verification is pending")]
|
||||||
|
DomainVerificationPending(String),
|
||||||
|
|
||||||
|
/// Domain verification expired
|
||||||
|
#[error("domain '{0}' verification has expired")]
|
||||||
|
DomainVerificationExpired(String),
|
||||||
|
|
||||||
|
/// Domain not found
|
||||||
|
#[error("domain '{0}' not found")]
|
||||||
|
DomainNotFound(String),
|
||||||
|
|
||||||
|
/// Domain verification failed
|
||||||
|
#[error("verification failed for domain '{0}': {1}")]
|
||||||
|
VerificationFailed(String, String),
|
||||||
|
|
||||||
|
/// Invalid domain format
|
||||||
|
#[error("invalid domain: {0}")]
|
||||||
|
InvalidDomain(String),
|
||||||
|
|
||||||
|
/// Unknown host
|
||||||
|
#[error("unknown host: {0}")]
|
||||||
|
UnknownHost(String),
|
||||||
|
|
||||||
|
/// Redirect response
|
||||||
|
#[error("redirect to {to} with status {status}")]
|
||||||
|
Redirect {
|
||||||
|
/// Target URL
|
||||||
|
to: String,
|
||||||
|
/// HTTP status code
|
||||||
|
status: u16,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Configuration error
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
/// Storage error
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
|
||||||
|
/// DNS resolution error
|
||||||
|
#[error("DNS error: {0}")]
|
||||||
|
Dns(String),
|
||||||
|
|
||||||
|
/// IO error
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// JSON parsing error
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type alias
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_display() {
|
||||||
|
let err = Error::InvalidName("bad-name!".to_string());
|
||||||
|
assert_eq!(err.to_string(), "invalid name: bad-name!");
|
||||||
|
|
||||||
|
let err = Error::NameNotFound("myapp".to_string());
|
||||||
|
assert_eq!(err.to_string(), "name 'myapp' not found");
|
||||||
|
|
||||||
|
let err = Error::Redirect {
|
||||||
|
to: "https://example.com".to_string(),
|
||||||
|
status: 301,
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("redirect"));
|
||||||
|
}
|
||||||
|
}
|
||||||
142
crates/synor-hosting/src/lib.rs
Normal file
142
crates/synor-hosting/src/lib.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
//! Synor Hosting - Decentralized Web Hosting
|
||||||
|
//!
|
||||||
|
//! Provides subdomain-based hosting for web applications on Synor Storage.
|
||||||
|
//!
|
||||||
|
//! # Components
|
||||||
|
//!
|
||||||
|
//! - **Name Registry**: On-chain mapping of names to CIDs
|
||||||
|
//! - **Domain Verification**: Custom domain ownership proof
|
||||||
|
//! - **Gateway Router**: Host-based routing to content
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use synor_hosting::{NameRegistry, HostingGateway};
|
||||||
|
//!
|
||||||
|
//! // Register a name
|
||||||
|
//! registry.register("myapp", cid, owner)?;
|
||||||
|
//!
|
||||||
|
//! // Resolve name to CID
|
||||||
|
//! let cid = registry.resolve("myapp")?;
|
||||||
|
//!
|
||||||
|
//! // Gateway routes myapp.synor.network to CID
|
||||||
|
//! gateway.handle_request("myapp.synor.network", "/").await?;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod registry;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod router;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
pub use registry::{NameRegistry, NameRecord, RegistrationRequest};
|
||||||
|
pub use domain::{DomainVerifier, DomainRecord, VerificationMethod};
|
||||||
|
pub use router::{HostingRouter, RouteConfig};
|
||||||
|
pub use config::SynorJson;
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
/// Reserved names that cannot be registered
|
||||||
|
pub const RESERVED_NAMES: &[&str] = &[
|
||||||
|
"synor", "admin", "api", "gateway", "www", "mail", "ftp", "ssh",
|
||||||
|
"cdn", "static", "assets", "app", "web", "blog", "docs", "help",
|
||||||
|
"support", "status", "system", "root", "test", "dev", "staging",
|
||||||
|
"prod", "production", "null", "undefined", "localhost", "local",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Validate a name according to registry rules
|
||||||
|
pub fn validate_name(name: &str) -> Result<()> {
|
||||||
|
// Length check
|
||||||
|
if name.len() < 3 {
|
||||||
|
return Err(Error::InvalidName("Name must be at least 3 characters".into()));
|
||||||
|
}
|
||||||
|
if name.len() > 63 {
|
||||||
|
return Err(Error::InvalidName("Name must be at most 63 characters".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character check
|
||||||
|
if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||||
|
return Err(Error::InvalidName(
|
||||||
|
"Name must contain only lowercase letters, numbers, and hyphens".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot start or end with hyphen
|
||||||
|
if name.starts_with('-') || name.ends_with('-') {
|
||||||
|
return Err(Error::InvalidName("Name cannot start or end with hyphen".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot have consecutive hyphens
|
||||||
|
if name.contains("--") {
|
||||||
|
return Err(Error::InvalidName("Name cannot contain consecutive hyphens".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved name check
|
||||||
|
if RESERVED_NAMES.contains(&name) {
|
||||||
|
return Err(Error::ReservedName(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a name looks confusingly similar to another
|
||||||
|
pub fn is_confusable(name: &str, other: &str) -> bool {
|
||||||
|
// Simple homoglyph detection
|
||||||
|
let normalize = |s: &str| -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'0' => 'o',
|
||||||
|
'1' | 'l' => 'i',
|
||||||
|
'5' => 's',
|
||||||
|
'3' => 'e',
|
||||||
|
'4' => 'a',
|
||||||
|
'7' => 't',
|
||||||
|
'8' => 'b',
|
||||||
|
'9' => 'g',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
normalize(name) == normalize(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_name_valid() {
|
||||||
|
assert!(validate_name("myapp").is_ok());
|
||||||
|
assert!(validate_name("my-app").is_ok());
|
||||||
|
assert!(validate_name("app123").is_ok());
|
||||||
|
assert!(validate_name("123app").is_ok());
|
||||||
|
assert!(validate_name("a-b-c").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_name_invalid() {
|
||||||
|
assert!(validate_name("ab").is_err()); // Too short
|
||||||
|
assert!(validate_name("MyApp").is_err()); // Uppercase
|
||||||
|
assert!(validate_name("-myapp").is_err()); // Starts with hyphen
|
||||||
|
assert!(validate_name("myapp-").is_err()); // Ends with hyphen
|
||||||
|
assert!(validate_name("my--app").is_err()); // Consecutive hyphens
|
||||||
|
assert!(validate_name("my app").is_err()); // Space
|
||||||
|
assert!(validate_name("my_app").is_err()); // Underscore
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reserved_names() {
|
||||||
|
assert!(validate_name("synor").is_err());
|
||||||
|
assert!(validate_name("admin").is_err());
|
||||||
|
assert!(validate_name("api").is_err());
|
||||||
|
assert!(validate_name("gateway").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confusable_names() {
|
||||||
|
assert!(is_confusable("g00gle", "google"));
|
||||||
|
assert!(is_confusable("paypa1", "paypal"));
|
||||||
|
assert!(is_confusable("fac3book", "facebook"));
|
||||||
|
assert!(!is_confusable("myapp", "yourapp"));
|
||||||
|
}
|
||||||
|
}
|
||||||
462
crates/synor-hosting/src/registry.rs
Normal file
462
crates/synor-hosting/src/registry.rs
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
//! Name Registry - On-chain name to CID mapping
|
||||||
|
//!
|
||||||
|
//! Manages the registration and resolution of human-readable names
|
||||||
|
//! to content identifiers (CIDs) on the Synor blockchain.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::validate_name;
|
||||||
|
use synor_storage::ContentId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Address type (32 bytes)
|
||||||
|
pub type Address = [u8; 32];
|
||||||
|
|
||||||
|
/// Name record stored on-chain
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NameRecord {
|
||||||
|
/// The registered name
|
||||||
|
pub name: String,
|
||||||
|
/// Owner address
|
||||||
|
pub owner: Address,
|
||||||
|
/// Current CID the name points to
|
||||||
|
pub cid: ContentId,
|
||||||
|
/// Block number when registered
|
||||||
|
pub registered_at: u64,
|
||||||
|
/// Block number when expires (0 = never)
|
||||||
|
pub expires_at: u64,
|
||||||
|
/// Custom domains linked to this name
|
||||||
|
pub custom_domains: Vec<String>,
|
||||||
|
/// Metadata (optional JSON)
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to register a new name
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistrationRequest {
|
||||||
|
/// Name to register
|
||||||
|
pub name: String,
|
||||||
|
/// Initial CID
|
||||||
|
pub cid: ContentId,
|
||||||
|
/// Registration duration in blocks (0 = permanent)
|
||||||
|
pub duration: u64,
|
||||||
|
/// Optional metadata
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name registry state
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct NameRegistry {
|
||||||
|
/// Name to record mapping
|
||||||
|
records: HashMap<String, NameRecord>,
|
||||||
|
/// Custom domain to name mapping
|
||||||
|
domain_to_name: HashMap<String, String>,
|
||||||
|
/// Registration fee (atomic SYNOR)
|
||||||
|
registration_fee: u64,
|
||||||
|
/// Annual renewal fee (atomic SYNOR)
|
||||||
|
renewal_fee: u64,
|
||||||
|
/// Grace period in blocks after expiry
|
||||||
|
grace_period: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NameRegistry {
|
||||||
|
/// Create a new name registry
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
records: HashMap::new(),
|
||||||
|
domain_to_name: HashMap::new(),
|
||||||
|
registration_fee: 10_000_000_000, // 10 SYNOR
|
||||||
|
renewal_fee: 5_000_000_000, // 5 SYNOR
|
||||||
|
grace_period: 432_000, // ~30 days at 6s blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create registry with custom fees
|
||||||
|
pub fn with_fees(registration_fee: u64, renewal_fee: u64, grace_period: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
records: HashMap::new(),
|
||||||
|
domain_to_name: HashMap::new(),
|
||||||
|
registration_fee,
|
||||||
|
renewal_fee,
|
||||||
|
grace_period,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new name
|
||||||
|
pub fn register(
|
||||||
|
&mut self,
|
||||||
|
request: RegistrationRequest,
|
||||||
|
owner: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<NameRecord> {
|
||||||
|
// Validate name
|
||||||
|
validate_name(&request.name)?;
|
||||||
|
|
||||||
|
// Check if name is available
|
||||||
|
if let Some(existing) = self.records.get(&request.name) {
|
||||||
|
// Check if expired and past grace period
|
||||||
|
if existing.expires_at > 0
|
||||||
|
&& current_block > existing.expires_at + self.grace_period
|
||||||
|
{
|
||||||
|
// Name can be re-registered
|
||||||
|
} else {
|
||||||
|
return Err(Error::NameTaken(request.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate expiry
|
||||||
|
let expires_at = if request.duration == 0 {
|
||||||
|
0 // Permanent
|
||||||
|
} else {
|
||||||
|
current_block + request.duration
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = NameRecord {
|
||||||
|
name: request.name.clone(),
|
||||||
|
owner,
|
||||||
|
cid: request.cid,
|
||||||
|
registered_at: current_block,
|
||||||
|
expires_at,
|
||||||
|
custom_domains: Vec::new(),
|
||||||
|
metadata: request.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.records.insert(request.name, record.clone());
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update CID for a name (must be owner)
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
new_cid: ContentId,
|
||||||
|
caller: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let record = self.get_record_mut(name, current_block)?;
|
||||||
|
|
||||||
|
if record.owner != caller {
|
||||||
|
return Err(Error::NotOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.cid = new_cid;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer ownership of a name
|
||||||
|
pub fn transfer(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
new_owner: Address,
|
||||||
|
caller: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let record = self.get_record_mut(name, current_block)?;
|
||||||
|
|
||||||
|
if record.owner != caller {
|
||||||
|
return Err(Error::NotOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.owner = new_owner;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renew a name registration
|
||||||
|
pub fn renew(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
additional_duration: u64,
|
||||||
|
caller: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let record = self.get_record_mut(name, current_block)?;
|
||||||
|
|
||||||
|
if record.owner != caller {
|
||||||
|
return Err(Error::NotOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.expires_at == 0 {
|
||||||
|
return Err(Error::InvalidOperation("Name is permanent".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend from current expiry or current block (if in grace period)
|
||||||
|
let base = if record.expires_at > current_block {
|
||||||
|
record.expires_at
|
||||||
|
} else {
|
||||||
|
current_block
|
||||||
|
};
|
||||||
|
|
||||||
|
record.expires_at = base + additional_duration;
|
||||||
|
|
||||||
|
Ok(record.expires_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a name to its CID
|
||||||
|
pub fn resolve(&self, name: &str, current_block: u64) -> Option<ContentId> {
|
||||||
|
self.records.get(name).and_then(|record| {
|
||||||
|
if self.is_valid(record, current_block) {
|
||||||
|
Some(record.cid.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get full record for a name
|
||||||
|
pub fn get_record(&self, name: &str, current_block: u64) -> Result<&NameRecord> {
|
||||||
|
let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?;
|
||||||
|
|
||||||
|
if !self.is_valid(record, current_block) {
|
||||||
|
return Err(Error::NameExpired(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mutable record
|
||||||
|
fn get_record_mut(&mut self, name: &str, current_block: u64) -> Result<&mut NameRecord> {
|
||||||
|
let record = self.records.get(name).ok_or_else(|| Error::NameNotFound(name.to_string()))?;
|
||||||
|
|
||||||
|
// Check validity before returning mutable reference
|
||||||
|
if !self.is_valid(record, current_block) {
|
||||||
|
return Err(Error::NameExpired(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.records.get_mut(name).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a record is valid (not expired or within grace period)
|
||||||
|
fn is_valid(&self, record: &NameRecord, current_block: u64) -> bool {
|
||||||
|
if record.expires_at == 0 {
|
||||||
|
return true; // Permanent
|
||||||
|
}
|
||||||
|
current_block <= record.expires_at + self.grace_period
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a name is available for registration
|
||||||
|
pub fn is_available(&self, name: &str, current_block: u64) -> bool {
|
||||||
|
match self.records.get(name) {
|
||||||
|
None => validate_name(name).is_ok(),
|
||||||
|
Some(record) => {
|
||||||
|
record.expires_at > 0
|
||||||
|
&& current_block > record.expires_at + self.grace_period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a custom domain to a name
|
||||||
|
pub fn add_custom_domain(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
domain: String,
|
||||||
|
caller: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Check domain isn't already linked
|
||||||
|
if self.domain_to_name.contains_key(&domain) {
|
||||||
|
return Err(Error::DomainAlreadyLinked(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = self.get_record_mut(name, current_block)?;
|
||||||
|
|
||||||
|
if record.owner != caller {
|
||||||
|
return Err(Error::NotOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.custom_domains.push(domain.clone());
|
||||||
|
self.domain_to_name.insert(domain, name.to_string());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a custom domain from a name
|
||||||
|
pub fn remove_custom_domain(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
domain: &str,
|
||||||
|
caller: Address,
|
||||||
|
current_block: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let record = self.get_record_mut(name, current_block)?;
|
||||||
|
|
||||||
|
if record.owner != caller {
|
||||||
|
return Err(Error::NotOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.custom_domains.retain(|d| d != domain);
|
||||||
|
self.domain_to_name.remove(domain);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a custom domain to a name
|
||||||
|
pub fn resolve_domain(&self, domain: &str) -> Option<&str> {
|
||||||
|
self.domain_to_name.get(domain).map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a custom domain directly to CID
|
||||||
|
pub fn resolve_domain_to_cid(&self, domain: &str, current_block: u64) -> Option<ContentId> {
|
||||||
|
self.resolve_domain(domain)
|
||||||
|
.and_then(|name| self.resolve(name, current_block))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get registration fee
|
||||||
|
pub fn registration_fee(&self) -> u64 {
|
||||||
|
self.registration_fee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get renewal fee
|
||||||
|
pub fn renewal_fee(&self) -> u64 {
|
||||||
|
self.renewal_fee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all names owned by an address
|
||||||
|
pub fn names_by_owner(&self, owner: &Address, current_block: u64) -> Vec<&NameRecord> {
|
||||||
|
self.records
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.owner == *owner && self.is_valid(r, current_block))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_cid(data: &[u8]) -> ContentId {
|
||||||
|
ContentId::from_content(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_and_resolve() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner = [1u8; 32];
|
||||||
|
let cid = make_cid(b"hello world");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid.clone(),
|
||||||
|
duration: 1000,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner, 0).unwrap();
|
||||||
|
|
||||||
|
let resolved = registry.resolve("myapp", 0).unwrap();
|
||||||
|
assert_eq!(resolved.digest, cid.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_cid() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner = [1u8; 32];
|
||||||
|
let cid1 = make_cid(b"version 1");
|
||||||
|
let cid2 = make_cid(b"version 2");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid1.clone(),
|
||||||
|
duration: 1000,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner, 0).unwrap();
|
||||||
|
registry.update("myapp", cid2.clone(), owner, 0).unwrap();
|
||||||
|
|
||||||
|
let resolved = registry.resolve("myapp", 0).unwrap();
|
||||||
|
assert_eq!(resolved.digest, cid2.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transfer_ownership() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner1 = [1u8; 32];
|
||||||
|
let owner2 = [2u8; 32];
|
||||||
|
let cid = make_cid(b"hello");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid.clone(),
|
||||||
|
duration: 1000,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner1, 0).unwrap();
|
||||||
|
registry.transfer("myapp", owner2, owner1, 0).unwrap();
|
||||||
|
|
||||||
|
let record = registry.get_record("myapp", 0).unwrap();
|
||||||
|
assert_eq!(record.owner, owner2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_name_expiry() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner = [1u8; 32];
|
||||||
|
let cid = make_cid(b"hello");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid.clone(),
|
||||||
|
duration: 100,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner, 0).unwrap();
|
||||||
|
|
||||||
|
// Should resolve before expiry
|
||||||
|
assert!(registry.resolve("myapp", 50).is_some());
|
||||||
|
|
||||||
|
// Should still resolve during grace period
|
||||||
|
assert!(registry.resolve("myapp", 150).is_some());
|
||||||
|
|
||||||
|
// Should not resolve after grace period
|
||||||
|
let after_grace = 100 + registry.grace_period + 1;
|
||||||
|
assert!(registry.resolve("myapp", after_grace).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_domain() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner = [1u8; 32];
|
||||||
|
let cid = make_cid(b"hello");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid.clone(),
|
||||||
|
duration: 1000,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner, 0).unwrap();
|
||||||
|
registry.add_custom_domain("myapp", "myapp.com".to_string(), owner, 0).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(registry.resolve_domain("myapp.com"), Some("myapp"));
|
||||||
|
|
||||||
|
let resolved = registry.resolve_domain_to_cid("myapp.com", 0).unwrap();
|
||||||
|
assert_eq!(resolved.digest, cid.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_not_owner_error() {
|
||||||
|
let mut registry = NameRegistry::new();
|
||||||
|
let owner = [1u8; 32];
|
||||||
|
let other = [2u8; 32];
|
||||||
|
let cid = make_cid(b"hello");
|
||||||
|
|
||||||
|
let request = RegistrationRequest {
|
||||||
|
name: "myapp".to_string(),
|
||||||
|
cid: cid.clone(),
|
||||||
|
duration: 1000,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.register(request, owner, 0).unwrap();
|
||||||
|
|
||||||
|
// Other user cannot update
|
||||||
|
let result = registry.update("myapp", make_cid(b"new"), other, 0);
|
||||||
|
assert!(matches!(result, Err(Error::NotOwner)));
|
||||||
|
}
|
||||||
|
}
|
||||||
303
crates/synor-hosting/src/router.rs
Normal file
303
crates/synor-hosting/src/router.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
//! Hosting Router - Route requests to content based on host
|
||||||
|
//!
|
||||||
|
//! Handles subdomain-based routing, SPA support, and custom domains.
|
||||||
|
|
||||||
|
use crate::registry::NameRegistry;
|
||||||
|
use crate::domain::DomainVerifier;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use synor_storage::ContentId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
430
docs/ARCHITECTURE_HOSTING.md
Normal file
430
docs/ARCHITECTURE_HOSTING.md
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
# Synor Hosting Architecture
|
||||||
|
|
||||||
|
Decentralized web hosting powered by Synor Storage Layer.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Synor Hosting enables users to deploy web applications to permanent, censorship-resistant infrastructure with human-readable URLs. It combines:
|
||||||
|
|
||||||
|
- **Synor Storage L2**: Decentralized content storage
|
||||||
|
- **Name Registry**: On-chain name → CID mapping
|
||||||
|
- **Enhanced Gateway**: Subdomain-based routing with SSL
|
||||||
|
- **Custom Domains**: User-owned domain integration
|
||||||
|
|
||||||
|
## User Journey
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Deployment Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. BUILD 2. UPLOAD 3. REGISTER │
|
||||||
|
│ ───────────── ───────────── ───────────── │
|
||||||
|
│ $ next build $ synor upload $ synor register │
|
||||||
|
│ $ synor pack ./out myfirstapp │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ CID: synor1abc... TX confirmed │
|
||||||
|
│ │
|
||||||
|
│ 4. ACCESS │
|
||||||
|
│ ───────────── │
|
||||||
|
│ https://myfirstapp.synor.network ──────────────────────▶ Your App! │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Components
|
||||||
|
|
||||||
|
### 1. Name Registry (On-Chain)
|
||||||
|
|
||||||
|
The name registry is a smart contract on Synor L1 that maps human-readable names to CIDs.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Name Registry Contract │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Names Table │
|
||||||
|
│ ┌────────────────┬──────────────────┬───────────────────────┐ │
|
||||||
|
│ │ Name │ Owner │ CID │ │
|
||||||
|
│ ├────────────────┼──────────────────┼───────────────────────┤ │
|
||||||
|
│ │ myfirstapp │ synor1user123... │ synor1abc789xyz... │ │
|
||||||
|
│ │ coolsite │ synor1user456... │ synor1def012uvw... │ │
|
||||||
|
│ │ myportfolio │ synor1user789... │ synor1ghi345rst... │ │
|
||||||
|
│ └────────────────┴──────────────────┴───────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Custom Domains Table │
|
||||||
|
│ ┌────────────────────┬────────────────┬─────────────────────┐ │
|
||||||
|
│ │ Domain │ Name │ Verified │ │
|
||||||
|
│ ├────────────────────┼────────────────┼─────────────────────┤ │
|
||||||
|
│ │ myfirstapp.com │ myfirstapp │ true │ │
|
||||||
|
│ │ www.coolsite.io │ coolsite │ true │ │
|
||||||
|
│ └────────────────────┴────────────────┴─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Contract Interface
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Name Registry Contract
|
||||||
|
pub trait NameRegistry {
|
||||||
|
/// Register a new name (must be unique, pay registration fee)
|
||||||
|
fn register(name: String, cid: ContentId) -> Result<()>;
|
||||||
|
|
||||||
|
/// Update CID for a name you own (deploy new version)
|
||||||
|
fn update(name: String, new_cid: ContentId) -> Result<()>;
|
||||||
|
|
||||||
|
/// Transfer ownership to another address
|
||||||
|
fn transfer(name: String, new_owner: Address) -> Result<()>;
|
||||||
|
|
||||||
|
/// Resolve name to CID
|
||||||
|
fn resolve(name: String) -> Option<ContentId>;
|
||||||
|
|
||||||
|
/// Add custom domain (requires verification)
|
||||||
|
fn add_custom_domain(name: String, domain: String) -> Result<()>;
|
||||||
|
|
||||||
|
/// Verify custom domain ownership
|
||||||
|
fn verify_domain(name: String, domain: String, proof: DomainProof) -> Result<()>;
|
||||||
|
|
||||||
|
/// Remove custom domain
|
||||||
|
fn remove_custom_domain(name: String, domain: String) -> Result<()>;
|
||||||
|
|
||||||
|
/// Resolve custom domain to name
|
||||||
|
fn resolve_domain(domain: String) -> Option<String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Name Rules
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Length | 3-63 characters |
|
||||||
|
| Characters | a-z, 0-9, hyphen (not at start/end) |
|
||||||
|
| Reserved | synor, admin, api, gateway, www, etc. |
|
||||||
|
| Fee | Registration fee + annual renewal |
|
||||||
|
| Grace Period | 30 days after expiry before release |
|
||||||
|
|
||||||
|
### 2. Enhanced Gateway
|
||||||
|
|
||||||
|
The gateway routes requests based on the `Host` header instead of just the path.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gateway Routing Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Incoming Request │
|
||||||
|
│ ───────────────── │
|
||||||
|
│ GET / HTTP/1.1 │
|
||||||
|
│ Host: myfirstapp.synor.network │
|
||||||
|
│ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────┐ │
|
||||||
|
│ │ Extract Host │ │
|
||||||
|
│ │ header │ │
|
||||||
|
│ └────────┬────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Is it │ YES │ Parse subdomain │ │
|
||||||
|
│ │ *.synor.network?├────▶│ "myfirstapp" │ │
|
||||||
|
│ └────────┬────────┘ └────────┬────────┘ │
|
||||||
|
│ │ NO │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Check custom │ │ Query Name │ │
|
||||||
|
│ │ domain table │ │ Registry │ │
|
||||||
|
│ └────────┬────────┘ └────────┬────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ Resolve to CID │ │
|
||||||
|
│ │ synor1abc789xyz... │ │
|
||||||
|
│ └────────────────────┬────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ Fetch from Storage │ │
|
||||||
|
│ │ Serve with correct MIME type │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SPA Routing
|
||||||
|
|
||||||
|
For Single Page Applications (React, Next.js, Vue, etc.), the gateway handles client-side routing:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: GET /dashboard/settings
|
||||||
|
Host: myfirstapp.synor.network
|
||||||
|
|
||||||
|
1. Try exact path: /dashboard/settings → Not found
|
||||||
|
2. Try with .html: /dashboard/settings.html → Not found
|
||||||
|
3. Check if SPA (has index.html): Yes
|
||||||
|
4. Serve index.html with 200 (not redirect)
|
||||||
|
5. Client-side router handles /dashboard/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSL/TLS Management
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SSL Certificate Strategy │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Synor Domains (*.synor.network) │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ • Wildcard certificate: *.synor.network │
|
||||||
|
│ • Single cert covers all subdomains │
|
||||||
|
│ • Auto-renewed via Let's Encrypt │
|
||||||
|
│ │
|
||||||
|
│ Custom Domains │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ • On-demand certificate generation │
|
||||||
|
│ • Let's Encrypt HTTP-01 or DNS-01 challenge │
|
||||||
|
│ • Stored in distributed cache │
|
||||||
|
│ • Auto-renewal 30 days before expiry │
|
||||||
|
│ │
|
||||||
|
│ Certificate Storage │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ • Encrypted at rest │
|
||||||
|
│ • Replicated across gateway nodes │
|
||||||
|
│ • Hot-reload without restart │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Custom Domain Verification
|
||||||
|
|
||||||
|
Users must prove domain ownership before linking to their Synor name.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Domain Verification Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Step 1: Request Verification │
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ $ synor domain add myfirstapp.com │
|
||||||
|
│ │
|
||||||
|
│ Response: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ To verify ownership of myfirstapp.com, add ONE of: │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Option A: CNAME Record │ │
|
||||||
|
│ │ myfirstapp.com CNAME myfirstapp.synor.network │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Option B: TXT Record │ │
|
||||||
|
│ │ _synor.myfirstapp.com TXT "synor-verify=abc123xyz789" │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Then run: synor domain verify myfirstapp.com │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Step 2: Add DNS Record (User action) │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ User adds record at their DNS provider (Cloudflare, Route53, etc.) │
|
||||||
|
│ │
|
||||||
|
│ Step 3: Verify │
|
||||||
|
│ ───────────── │
|
||||||
|
│ $ synor domain verify myfirstapp.com │
|
||||||
|
│ │
|
||||||
|
│ Gateway checks: │
|
||||||
|
│ 1. DNS lookup for CNAME or TXT record │
|
||||||
|
│ 2. Verify record matches expected value │
|
||||||
|
│ 3. Submit verification proof to L1 │
|
||||||
|
│ 4. Issue SSL certificate │
|
||||||
|
│ │
|
||||||
|
│ ✓ Domain verified! myfirstapp.com → myfirstapp.synor.network │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Patterns
|
||||||
|
|
||||||
|
| Pattern | Example | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| CID Path | `gateway.synor.cc/synor1abc...` | Direct CID access |
|
||||||
|
| Subdomain | `myfirstapp.synor.network` | Registered name |
|
||||||
|
| Custom Domain | `myfirstapp.com` | User's own domain |
|
||||||
|
| Path in App | `myfirstapp.synor.network/dashboard` | SPA routing |
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload and get CID
|
||||||
|
synor upload ./dist
|
||||||
|
# Output: Uploaded! CID: synor1abc789xyz...
|
||||||
|
|
||||||
|
# Register a name
|
||||||
|
synor register myapp
|
||||||
|
# Output: Name "myapp" registered to synor1abc789xyz...
|
||||||
|
|
||||||
|
# Update deployment (new version)
|
||||||
|
synor upload ./dist --name myapp
|
||||||
|
# Output: Updated! myapp now points to synor1def012...
|
||||||
|
|
||||||
|
# Add custom domain
|
||||||
|
synor domain add myapp.com
|
||||||
|
# Output: Add CNAME record, then run: synor domain verify myapp.com
|
||||||
|
|
||||||
|
# Verify domain
|
||||||
|
synor domain verify myapp.com
|
||||||
|
# Output: ✓ Domain verified!
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
synor status myapp
|
||||||
|
# Output:
|
||||||
|
# Name: myapp
|
||||||
|
# CID: synor1abc789xyz...
|
||||||
|
# URL: https://myapp.synor.network
|
||||||
|
# Custom Domains:
|
||||||
|
# - myapp.com (verified)
|
||||||
|
# - www.myapp.com (pending)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pricing Model
|
||||||
|
|
||||||
|
| Service | Cost | Notes |
|
||||||
|
|---------|------|-------|
|
||||||
|
| Name Registration | 10 SYNOR | One-time |
|
||||||
|
| Annual Renewal | 5 SYNOR | Per year |
|
||||||
|
| Storage | Per deal pricing | See storage tiers |
|
||||||
|
| Custom Domain | Free | Unlimited per name |
|
||||||
|
| SSL Certificate | Free | Auto-managed |
|
||||||
|
| Bandwidth | Free* | Fair use policy |
|
||||||
|
|
||||||
|
*Heavy usage may require staking or premium tier.
|
||||||
|
|
||||||
|
## DNS Configuration
|
||||||
|
|
||||||
|
### Synor Infrastructure
|
||||||
|
|
||||||
|
```
|
||||||
|
# Wildcard for all subdomains
|
||||||
|
*.synor.network A <gateway-cluster-ip>
|
||||||
|
*.synor.network AAAA <gateway-cluster-ipv6>
|
||||||
|
|
||||||
|
# Gateway load balancer
|
||||||
|
gateway.synor.cc A <gateway-cluster-ip>
|
||||||
|
g.synor.cc CNAME gateway.synor.cc
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Custom Domain
|
||||||
|
|
||||||
|
```
|
||||||
|
# Option 1: CNAME (recommended)
|
||||||
|
myapp.com CNAME myapp.synor.network
|
||||||
|
|
||||||
|
# Option 2: A record (if CNAME not supported at apex)
|
||||||
|
myapp.com A <gateway-cluster-ip>
|
||||||
|
|
||||||
|
# For www subdomain
|
||||||
|
www.myapp.com CNAME myapp.synor.network
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Types
|
||||||
|
|
||||||
|
### Static Sites
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Supported frameworks
|
||||||
|
- Next.js (static export)
|
||||||
|
- React (CRA, Vite)
|
||||||
|
- Vue.js
|
||||||
|
- Svelte/SvelteKit
|
||||||
|
- Astro
|
||||||
|
- Hugo, Jekyll, 11ty
|
||||||
|
- Plain HTML/CSS/JS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration File (synor.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myapp",
|
||||||
|
"build": {
|
||||||
|
"command": "npm run build",
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"routes": {
|
||||||
|
"/*": "/index.html"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"/*": {
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable"
|
||||||
|
},
|
||||||
|
"/index.html": {
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"redirects": [
|
||||||
|
{ "from": "/old-page", "to": "/new-page", "status": 301 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Name Squatting Prevention
|
||||||
|
|
||||||
|
- Minimum registration fee discourages mass registration
|
||||||
|
- Trademark dispute resolution process
|
||||||
|
- Reserved names for common terms
|
||||||
|
|
||||||
|
### Phishing Protection
|
||||||
|
|
||||||
|
- Confusable name detection (l vs 1, O vs 0)
|
||||||
|
- Warning for names similar to popular sites
|
||||||
|
- Report mechanism for malicious content
|
||||||
|
|
||||||
|
### Content Moderation
|
||||||
|
|
||||||
|
- Gateway operators can block CIDs (not remove from storage)
|
||||||
|
- Multiple gateways ensure censorship resistance
|
||||||
|
- Hash-based blocking list shared among operators
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Name Registry (2 weeks)
|
||||||
|
- [ ] Name registry smart contract
|
||||||
|
- [ ] CLI commands: register, update, transfer
|
||||||
|
- [ ] Basic gateway subdomain routing
|
||||||
|
|
||||||
|
### Phase 2: Enhanced Gateway (2 weeks)
|
||||||
|
- [ ] Host-based routing
|
||||||
|
- [ ] SPA support (fallback to index.html)
|
||||||
|
- [ ] Custom headers and redirects
|
||||||
|
- [ ] Caching layer
|
||||||
|
|
||||||
|
### Phase 3: Custom Domains (2 weeks)
|
||||||
|
- [ ] Domain verification (CNAME/TXT)
|
||||||
|
- [ ] On-demand SSL certificates
|
||||||
|
- [ ] Certificate storage and renewal
|
||||||
|
|
||||||
|
### Phase 4: Developer Experience (1 week)
|
||||||
|
- [ ] synor.json configuration
|
||||||
|
- [ ] CI/CD integration examples
|
||||||
|
- [ ] GitHub Actions deployment
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
| Feature | Synor Hosting | Vercel | Netlify | IPFS+ENS |
|
||||||
|
|---------|--------------|--------|---------|----------|
|
||||||
|
| Decentralized | ✓ | ✗ | ✗ | ✓ |
|
||||||
|
| Custom Domains | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Auto SSL | ✓ | ✓ | ✓ | ✗ |
|
||||||
|
| Serverless Functions | Future | ✓ | ✓ | ✗ |
|
||||||
|
| Censorship Resistant | ✓ | ✗ | ✗ | ✓ |
|
||||||
|
| Pay Once, Host Forever | ✓ | ✗ | ✗ | ✓ |
|
||||||
|
| Native Blockchain | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Synor Functions**: Serverless compute layer
|
||||||
|
2. **Database Integration**: Decentralized database for dynamic apps
|
||||||
|
3. **Analytics**: Privacy-preserving usage analytics
|
||||||
|
4. **Team Collaboration**: Multi-user access to names
|
||||||
|
5. **Staging Environments**: Preview deployments before going live
|
||||||
Loading…
Add table
Reference in a new issue