at main 182 lines 6.4 kB view raw
1//! Axum middleware for host-based routing. 2 3use std::sync::Arc; 4 5use axum::{ 6 extract::Request, 7 http::header::HOST, 8 middleware::Next, 9 response::{IntoResponse, Redirect, Response}, 10}; 11use jacquard::IntoStatic; 12use jacquard::client::AgentSessionExt; 13use jacquard::smol_str::SmolStr; 14use jacquard::smol_str::ToSmolStr; 15 16use crate::{ 17 env::WEAVER_APP_DOMAIN, 18 fetch::Fetcher, 19 host_mode::{CustomDomainContext, HostContext, SubdomainContext}, 20 subdomain_app::{extract_subdomain, lookup_subdomain_context}, 21}; 22 23/// Reserved subdomains that should not be used for notebooks. 24const RESERVED_SUBDOMAINS: &[&str] = &[ 25 "www", "api", "admin", "app", "auth", "cdn", "alpha", "beta", "staging", "index", 26]; 27 28/// Prefix for encoded publication hostnames (nb-{encoded}.weaver.sh). 29const ENCODED_PUBLICATION_PREFIX: &str = "nb-"; 30 31/// Middleware that resolves host context and inserts it into request extensions. 32/// 33/// This handles: 34/// - Encoded publication hostnames (nb-xxx.pub.weaver.sh) → redirect to real domain 35/// - Subdomain routing (notebook.weaver.sh) → SubdomainContext 36/// - Custom domain routing (myblog.com) → CustomDomainContext 37/// - Main domain (weaver.sh) → MainDomain 38pub async fn host_context_middleware( 39 mut req: Request, 40 next: Next, 41 fetcher: Arc<Fetcher>, 42) -> Response { 43 // Extract everything we need from the request BEFORE any async calls. 44 // The body type isn't Sync, so we can't hold &req across await points. 45 let host = extract_host(&req); 46 let path = req.uri().path().to_string(); 47 let query = req 48 .uri() 49 .query() 50 .map(|q| format!("?{}", q)) 51 .unwrap_or_default(); 52 53 let Some(host) = host else { 54 tracing::warn!("No Host header in request"); 55 req.extensions_mut().insert(HostContext::MainDomain); 56 return next.run(req).await; 57 }; 58 59 // Strip port if present. 60 let host_str = host.split(':').next().unwrap_or(&host).to_string(); 61 62 // Check if this is a weaver domain subdomain. 63 if let Some(subdomain) = extract_subdomain(&host_str, WEAVER_APP_DOMAIN) { 64 // Check for encoded publication hostname (nb-xxx.pub.weaver.sh). 65 if let Some(redirect_url) = 66 handle_encoded_publication(&subdomain, &path, &query, fetcher.clone()).await 67 { 68 return Redirect::permanent(&redirect_url).into_response(); 69 } 70 71 // Check for reserved subdomains. 72 if RESERVED_SUBDOMAINS.contains(&subdomain.as_str()) { 73 tracing::debug!(subdomain, "Reserved subdomain, using main domain"); 74 req.extensions_mut().insert(HostContext::MainDomain); 75 return next.run(req).await; 76 } 77 78 // Try to look up notebook for this subdomain. 79 if let Some(ctx) = lookup_subdomain_context(&fetcher, &subdomain).await { 80 tracing::debug!(subdomain, "Resolved subdomain context"); 81 req.extensions_mut().insert(HostContext::Subdomain(ctx)); 82 return next.run(req).await; 83 } 84 85 // Subdomain not found, fall through to main domain. 86 tracing::warn!(subdomain, "Subdomain not found, using main domain"); 87 req.extensions_mut().insert(HostContext::MainDomain); 88 return next.run(req).await; 89 } 90 91 // Not a weaver subdomain - check for custom domain. 92 if let Some(ctx) = lookup_custom_domain(fetcher.clone(), &host_str).await { 93 tracing::debug!(domain = host_str, "Resolved custom domain context"); 94 req.extensions_mut().insert(HostContext::CustomDomain(ctx)); 95 return next.run(req).await; 96 } 97 98 // Unknown domain, use main domain routing. 99 tracing::debug!(host = host_str, "Unknown host, using main domain"); 100 req.extensions_mut().insert(HostContext::MainDomain); 101 next.run(req).await 102} 103 104/// Extract the Host header from the request. 105fn extract_host(req: &Request) -> Option<String> { 106 req.headers() 107 .get(HOST)? 108 .to_str() 109 .ok() 110 .map(|s| s.to_string()) 111} 112 113/// Handle encoded publication hostname (nb-{encoded}.weaver.sh). 114/// 115/// Returns Some(redirect_url) if this is an encoded hostname that should redirect. 116async fn handle_encoded_publication( 117 subdomain: &str, 118 path: &str, 119 query: &str, 120 fetcher: Arc<Fetcher>, 121) -> Option<String> { 122 // Check if subdomain matches pattern: nb-{encoded} 123 if !subdomain.starts_with(ENCODED_PUBLICATION_PREFIX) { 124 return None; 125 } 126 127 // Decode the publication subdomain. 128 let Some((did, rkey)) = weaver_common::domain_encoding::decode_publication_subdomain(subdomain) 129 else { 130 tracing::warn!(subdomain, "Failed to decode publication subdomain"); 131 return None; 132 }; 133 134 // Look up the publication to get the real domain. 135 let domain = lookup_publication_domain(fetcher, did.as_str(), rkey.as_str()).await?; 136 137 Some(format!("https://{}{}{}", domain, path, query)) 138} 139 140/// Look up publication domain by DID and rkey. 141async fn lookup_publication_domain(fetcher: Arc<Fetcher>, did: &str, rkey: &str) -> Option<String> { 142 use jacquard::types::string::Uri; 143 use weaver_api::site_standard::publication::Publication; 144 145 let uri_str = format!("at://{}/site.standard.publication/{}", did, rkey); 146 let uri = Publication::uri(&uri_str).ok()?; 147 148 let record = fetcher.fetch_record(&uri).await.ok()?; 149 150 // Extract domain from the already-parsed publication URL. 151 match &record.value.url { 152 Uri::Https(url) | Uri::Wss(url) => url.host_str().map(|h| h.to_string()), 153 _ => None, 154 } 155} 156 157/// Look up custom domain context. 158async fn lookup_custom_domain(fetcher: Arc<Fetcher>, domain: &str) -> Option<CustomDomainContext> { 159 use jacquard::prelude::XrpcClient; 160 use weaver_api::sh_weaver::domain::resolve_by_domain::ResolveByDomain; 161 162 let output = fetcher 163 .send(ResolveByDomain::new().domain(domain).build()) 164 .await 165 .ok()? 166 .into_output() 167 .ok()?; 168 169 let pub_view = output.publication; 170 171 Some(CustomDomainContext { 172 domain: SmolStr::new(domain), 173 owner: jacquard::types::string::AtIdentifier::Did( 174 jacquard::types::string::Did::new(&pub_view.did) 175 .ok()? 176 .into_static(), 177 ), 178 publication_rkey: pub_view.rkey.to_smolstr(), 179 publication_name: pub_view.name.to_smolstr(), 180 notebook_uri: pub_view.notebook_uri.map(|u| u.to_smolstr()), 181 }) 182}