this repo has no description
1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::time::{Duration, Instant};
5use tokio::sync::RwLock;
6use tracing::{debug, error, info, warn};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DidDocument {
10 pub id: String,
11 #[serde(default)]
12 pub service: Vec<DidService>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct DidService {
18 pub id: String,
19 #[serde(rename = "type")]
20 pub service_type: String,
21 pub service_endpoint: String,
22}
23
24#[derive(Clone)]
25struct CachedAppView {
26 url: String,
27 did: String,
28 resolved_at: Instant,
29}
30
31pub struct AppViewRegistry {
32 namespace_to_did: HashMap<String, String>,
33 did_cache: RwLock<HashMap<String, CachedAppView>>,
34 client: Client,
35 cache_ttl: Duration,
36 plc_directory_url: String,
37}
38
39impl Clone for AppViewRegistry {
40 fn clone(&self) -> Self {
41 Self {
42 namespace_to_did: self.namespace_to_did.clone(),
43 did_cache: RwLock::new(HashMap::new()),
44 client: self.client.clone(),
45 cache_ttl: self.cache_ttl,
46 plc_directory_url: self.plc_directory_url.clone(),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct ResolvedAppView {
53 pub url: String,
54 pub did: String,
55}
56
57impl AppViewRegistry {
58 pub fn new() -> Self {
59 let mut namespace_to_did = HashMap::new();
60
61 let bsky_did = std::env::var("APPVIEW_DID_BSKY")
62 .unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
63 namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone());
64 namespace_to_did.insert("com.atproto".to_string(), bsky_did);
65
66 for (key, value) in std::env::vars() {
67 if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") {
68 let namespace = namespace.to_lowercase().replace('_', ".");
69 if namespace != "bsky" {
70 namespace_to_did.insert(namespace, value);
71 }
72 }
73 }
74
75 let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS")
76 .ok()
77 .and_then(|v| v.parse().ok())
78 .unwrap_or(300);
79
80 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
81 .unwrap_or_else(|_| "https://plc.directory".to_string());
82
83 let client = Client::builder()
84 .timeout(Duration::from_secs(10))
85 .connect_timeout(Duration::from_secs(5))
86 .pool_max_idle_per_host(10)
87 .build()
88 .unwrap_or_else(|_| Client::new());
89
90 info!(
91 "AppView registry initialized with {} namespace mappings",
92 namespace_to_did.len()
93 );
94 for (ns, did) in &namespace_to_did {
95 debug!(" {} -> {}", ns, did);
96 }
97
98 Self {
99 namespace_to_did,
100 did_cache: RwLock::new(HashMap::new()),
101 client,
102 cache_ttl: Duration::from_secs(cache_ttl_secs),
103 plc_directory_url,
104 }
105 }
106
107 pub fn register_namespace(&mut self, namespace: &str, did: &str) {
108 info!("Registering AppView: {} -> {}", namespace, did);
109 self.namespace_to_did
110 .insert(namespace.to_string(), did.to_string());
111 }
112
113 pub async fn get_appview_for_method(&self, method: &str) -> Option<ResolvedAppView> {
114 let namespace = self.extract_namespace(method)?;
115 self.get_appview_for_namespace(&namespace).await
116 }
117
118 pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option<ResolvedAppView> {
119 let did = self.get_did_for_namespace(namespace)?;
120 self.resolve_appview_did(&did).await
121 }
122
123 pub fn get_did_for_namespace(&self, namespace: &str) -> Option<String> {
124 if let Some(did) = self.namespace_to_did.get(namespace) {
125 return Some(did.clone());
126 }
127
128 let mut parts: Vec<&str> = namespace.split('.').collect();
129 while !parts.is_empty() {
130 let prefix = parts.join(".");
131 if let Some(did) = self.namespace_to_did.get(&prefix) {
132 return Some(did.clone());
133 }
134 parts.pop();
135 }
136
137 None
138 }
139
140 pub async fn resolve_appview_did(&self, did: &str) -> Option<ResolvedAppView> {
141 {
142 let cache = self.did_cache.read().await;
143 if let Some(cached) = cache.get(did) {
144 if cached.resolved_at.elapsed() < self.cache_ttl {
145 return Some(ResolvedAppView {
146 url: cached.url.clone(),
147 did: cached.did.clone(),
148 });
149 }
150 }
151 }
152
153 let resolved = self.resolve_did_internal(did).await?;
154
155 {
156 let mut cache = self.did_cache.write().await;
157 cache.insert(
158 did.to_string(),
159 CachedAppView {
160 url: resolved.url.clone(),
161 did: resolved.did.clone(),
162 resolved_at: Instant::now(),
163 },
164 );
165 }
166
167 Some(resolved)
168 }
169
170 async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedAppView> {
171 let did_doc = if did.starts_with("did:web:") {
172 self.resolve_did_web(did).await
173 } else if did.starts_with("did:plc:") {
174 self.resolve_did_plc(did).await
175 } else {
176 warn!("Unsupported DID method: {}", did);
177 return None;
178 };
179
180 let doc = match did_doc {
181 Ok(doc) => doc,
182 Err(e) => {
183 error!("Failed to resolve DID {}: {}", did, e);
184 return None;
185 }
186 };
187
188 self.extract_appview_endpoint(&doc)
189 }
190
191 async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
192 let host = did
193 .strip_prefix("did:web:")
194 .ok_or("Invalid did:web format")?;
195
196 let (host, path) = if host.contains(':') {
197 let decoded = host.replace("%3A", ":");
198 let parts: Vec<&str> = decoded.splitn(2, '/').collect();
199 if parts.len() > 1 {
200 (parts[0].to_string(), format!("/{}", parts[1]))
201 } else {
202 (decoded, String::new())
203 }
204 } else {
205 let parts: Vec<&str> = host.splitn(2, ':').collect();
206 if parts.len() > 1 && parts[1].contains('/') {
207 let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
208 if path_parts.len() > 1 {
209 (
210 format!("{}:{}", parts[0], path_parts[0]),
211 format!("/{}", path_parts[1]),
212 )
213 } else {
214 (host.to_string(), String::new())
215 }
216 } else {
217 (host.to_string(), String::new())
218 }
219 };
220
221 let scheme =
222 if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':')
223 {
224 "http"
225 } else {
226 "https"
227 };
228
229 let url = if path.is_empty() {
230 format!("{}://{}/.well-known/did.json", scheme, host)
231 } else {
232 format!("{}://{}{}/did.json", scheme, host, path)
233 };
234
235 debug!("Resolving did:web {} via {}", did, url);
236
237 let resp = self
238 .client
239 .get(&url)
240 .send()
241 .await
242 .map_err(|e| format!("HTTP request failed: {}", e))?;
243
244 if !resp.status().is_success() {
245 return Err(format!("HTTP {}", resp.status()));
246 }
247
248 resp.json::<DidDocument>()
249 .await
250 .map_err(|e| format!("Failed to parse DID document: {}", e))
251 }
252
253 async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
254 let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
255
256 debug!("Resolving did:plc {} via {}", did, url);
257
258 let resp = self
259 .client
260 .get(&url)
261 .send()
262 .await
263 .map_err(|e| format!("HTTP request failed: {}", e))?;
264
265 if resp.status() == reqwest::StatusCode::NOT_FOUND {
266 return Err("DID not found".to_string());
267 }
268
269 if !resp.status().is_success() {
270 return Err(format!("HTTP {}", resp.status()));
271 }
272
273 resp.json::<DidDocument>()
274 .await
275 .map_err(|e| format!("Failed to parse DID document: {}", e))
276 }
277
278 fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option<ResolvedAppView> {
279 for service in &doc.service {
280 if service.service_type == "AtprotoAppView"
281 || service.id.contains("atproto_appview")
282 || service.id.ends_with("#bsky_appview")
283 {
284 return Some(ResolvedAppView {
285 url: service.service_endpoint.clone(),
286 did: doc.id.clone(),
287 });
288 }
289 }
290
291 for service in &doc.service {
292 if service.service_type.contains("AppView") || service.id.contains("appview") {
293 return Some(ResolvedAppView {
294 url: service.service_endpoint.clone(),
295 did: doc.id.clone(),
296 });
297 }
298 }
299
300 if let Some(service) = doc.service.first() {
301 if service.service_endpoint.starts_with("http") {
302 warn!(
303 "No explicit AppView service found for {}, using first service: {}",
304 doc.id, service.service_endpoint
305 );
306 return Some(ResolvedAppView {
307 url: service.service_endpoint.clone(),
308 did: doc.id.clone(),
309 });
310 }
311 }
312
313 if doc.id.starts_with("did:web:") {
314 let host = doc.id.strip_prefix("did:web:")?;
315 let decoded_host = host.replace("%3A", ":");
316 let base_host = decoded_host.split('/').next()?;
317 let scheme = if base_host.starts_with("localhost")
318 || base_host.starts_with("127.0.0.1")
319 || base_host.contains(':')
320 {
321 "http"
322 } else {
323 "https"
324 };
325 warn!(
326 "No service found for {}, deriving URL from DID: {}://{}",
327 doc.id, scheme, base_host
328 );
329 return Some(ResolvedAppView {
330 url: format!("{}://{}", scheme, base_host),
331 did: doc.id.clone(),
332 });
333 }
334
335 None
336 }
337
338 fn extract_namespace(&self, method: &str) -> Option<String> {
339 let parts: Vec<&str> = method.split('.').collect();
340 if parts.len() >= 2 {
341 Some(format!("{}.{}", parts[0], parts[1]))
342 } else {
343 None
344 }
345 }
346
347 pub fn list_namespaces(&self) -> Vec<(String, String)> {
348 self.namespace_to_did
349 .iter()
350 .map(|(k, v)| (k.clone(), v.clone()))
351 .collect()
352 }
353
354 pub async fn invalidate_cache(&self, did: &str) {
355 let mut cache = self.did_cache.write().await;
356 cache.remove(did);
357 }
358
359 pub async fn invalidate_all_cache(&self) {
360 let mut cache = self.did_cache.write().await;
361 cache.clear();
362 }
363}
364
365impl Default for AppViewRegistry {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
372 registry.get_appview_for_method(method).await.map(|r| r.url)
373}
374
375pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
376 registry.get_appview_for_method(method).await.map(|r| r.did)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn test_extract_namespace() {
385 let registry = AppViewRegistry::new();
386 assert_eq!(
387 registry.extract_namespace("app.bsky.actor.getProfile"),
388 Some("app.bsky".to_string())
389 );
390 assert_eq!(
391 registry.extract_namespace("com.atproto.repo.createRecord"),
392 Some("com.atproto".to_string())
393 );
394 assert_eq!(
395 registry.extract_namespace("com.whtwnd.blog.getPost"),
396 Some("com.whtwnd".to_string())
397 );
398 assert_eq!(registry.extract_namespace("invalid"), None);
399 }
400
401 #[test]
402 fn test_get_did_for_namespace() {
403 let mut registry = AppViewRegistry::new();
404 registry.register_namespace("com.whtwnd", "did:web:whtwnd.com");
405
406 assert!(registry.get_did_for_namespace("app.bsky").is_some());
407 assert_eq!(
408 registry.get_did_for_namespace("com.whtwnd"),
409 Some("did:web:whtwnd.com".to_string())
410 );
411 assert!(registry.get_did_for_namespace("unknown.namespace").is_none());
412 }
413}