atproto blogging
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}