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}