use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DidDocument { pub id: String, #[serde(default)] pub service: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DidService { pub id: String, #[serde(rename = "type")] pub service_type: String, pub service_endpoint: String, } #[derive(Clone)] struct CachedAppView { url: String, did: String, resolved_at: Instant, } pub struct AppViewRegistry { namespace_to_did: HashMap, did_cache: RwLock>, client: Client, cache_ttl: Duration, plc_directory_url: String, } impl Clone for AppViewRegistry { fn clone(&self) -> Self { Self { namespace_to_did: self.namespace_to_did.clone(), did_cache: RwLock::new(HashMap::new()), client: self.client.clone(), cache_ttl: self.cache_ttl, plc_directory_url: self.plc_directory_url.clone(), } } } #[derive(Debug, Clone)] pub struct ResolvedAppView { pub url: String, pub did: String, } impl AppViewRegistry { pub fn new() -> Self { let mut namespace_to_did = HashMap::new(); let bsky_did = std::env::var("APPVIEW_DID_BSKY") .unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone()); namespace_to_did.insert("com.atproto".to_string(), bsky_did); for (key, value) in std::env::vars() { if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") { let namespace = namespace.to_lowercase().replace('_', "."); if namespace != "bsky" { namespace_to_did.insert(namespace, value); } } } let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(300); let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") .unwrap_or_else(|_| "https://plc.directory".to_string()); let client = Client::builder() .timeout(Duration::from_secs(10)) .connect_timeout(Duration::from_secs(5)) .pool_max_idle_per_host(10) .build() .unwrap_or_else(|_| Client::new()); info!( "AppView registry initialized with {} namespace mappings", namespace_to_did.len() ); for (ns, did) in &namespace_to_did { debug!(" {} -> {}", ns, did); } Self { namespace_to_did, did_cache: RwLock::new(HashMap::new()), client, cache_ttl: Duration::from_secs(cache_ttl_secs), plc_directory_url, } } pub fn register_namespace(&mut self, namespace: &str, did: &str) { info!("Registering AppView: {} -> {}", namespace, did); self.namespace_to_did .insert(namespace.to_string(), did.to_string()); } pub async fn get_appview_for_method(&self, method: &str) -> Option { let namespace = self.extract_namespace(method)?; self.get_appview_for_namespace(&namespace).await } pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option { let did = self.get_did_for_namespace(namespace)?; self.resolve_appview_did(&did).await } pub fn get_did_for_namespace(&self, namespace: &str) -> Option { if let Some(did) = self.namespace_to_did.get(namespace) { return Some(did.clone()); } let mut parts: Vec<&str> = namespace.split('.').collect(); while !parts.is_empty() { let prefix = parts.join("."); if let Some(did) = self.namespace_to_did.get(&prefix) { return Some(did.clone()); } parts.pop(); } None } pub async fn resolve_appview_did(&self, did: &str) -> Option { { let cache = self.did_cache.read().await; if let Some(cached) = cache.get(did) { if cached.resolved_at.elapsed() < self.cache_ttl { return Some(ResolvedAppView { url: cached.url.clone(), did: cached.did.clone(), }); } } } let resolved = self.resolve_did_internal(did).await?; { let mut cache = self.did_cache.write().await; cache.insert( did.to_string(), CachedAppView { url: resolved.url.clone(), did: resolved.did.clone(), resolved_at: Instant::now(), }, ); } Some(resolved) } async fn resolve_did_internal(&self, did: &str) -> Option { let did_doc = if did.starts_with("did:web:") { self.resolve_did_web(did).await } else if did.starts_with("did:plc:") { self.resolve_did_plc(did).await } else { warn!("Unsupported DID method: {}", did); return None; }; let doc = match did_doc { Ok(doc) => doc, Err(e) => { error!("Failed to resolve DID {}: {}", did, e); return None; } }; self.extract_appview_endpoint(&doc) } async fn resolve_did_web(&self, did: &str) -> Result { let host = did .strip_prefix("did:web:") .ok_or("Invalid did:web format")?; let (host, path) = if host.contains(':') { let decoded = host.replace("%3A", ":"); let parts: Vec<&str> = decoded.splitn(2, '/').collect(); if parts.len() > 1 { (parts[0].to_string(), format!("/{}", parts[1])) } else { (decoded, String::new()) } } else { let parts: Vec<&str> = host.splitn(2, ':').collect(); if parts.len() > 1 && parts[1].contains('/') { let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); if path_parts.len() > 1 { ( format!("{}:{}", parts[0], path_parts[0]), format!("/{}", path_parts[1]), ) } else { (host.to_string(), String::new()) } } else { (host.to_string(), String::new()) } }; let scheme = if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') { "http" } else { "https" }; let url = if path.is_empty() { format!("{}://{}/.well-known/did.json", scheme, host) } else { format!("{}://{}{}/did.json", scheme, host, path) }; debug!("Resolving did:web {} via {}", did, url); let resp = self .client .get(&url) .send() .await .map_err(|e| format!("HTTP request failed: {}", e))?; if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } resp.json::() .await .map_err(|e| format!("Failed to parse DID document: {}", e)) } async fn resolve_did_plc(&self, did: &str) -> Result { let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); debug!("Resolving did:plc {} via {}", did, url); let resp = self .client .get(&url) .send() .await .map_err(|e| format!("HTTP request failed: {}", e))?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Err("DID not found".to_string()); } if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); } resp.json::() .await .map_err(|e| format!("Failed to parse DID document: {}", e)) } fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option { for service in &doc.service { if service.service_type == "AtprotoAppView" || service.id.contains("atproto_appview") || service.id.ends_with("#bsky_appview") { return Some(ResolvedAppView { url: service.service_endpoint.clone(), did: doc.id.clone(), }); } } for service in &doc.service { if service.service_type.contains("AppView") || service.id.contains("appview") { return Some(ResolvedAppView { url: service.service_endpoint.clone(), did: doc.id.clone(), }); } } if let Some(service) = doc.service.first() { if service.service_endpoint.starts_with("http") { warn!( "No explicit AppView service found for {}, using first service: {}", doc.id, service.service_endpoint ); return Some(ResolvedAppView { url: service.service_endpoint.clone(), did: doc.id.clone(), }); } } if doc.id.starts_with("did:web:") { let host = doc.id.strip_prefix("did:web:")?; let decoded_host = host.replace("%3A", ":"); let base_host = decoded_host.split('/').next()?; let scheme = if base_host.starts_with("localhost") || base_host.starts_with("127.0.0.1") || base_host.contains(':') { "http" } else { "https" }; warn!( "No service found for {}, deriving URL from DID: {}://{}", doc.id, scheme, base_host ); return Some(ResolvedAppView { url: format!("{}://{}", scheme, base_host), did: doc.id.clone(), }); } None } fn extract_namespace(&self, method: &str) -> Option { let parts: Vec<&str> = method.split('.').collect(); if parts.len() >= 2 { Some(format!("{}.{}", parts[0], parts[1])) } else { None } } pub fn list_namespaces(&self) -> Vec<(String, String)> { self.namespace_to_did .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect() } pub async fn invalidate_cache(&self, did: &str) { let mut cache = self.did_cache.write().await; cache.remove(did); } pub async fn invalidate_all_cache(&self) { let mut cache = self.did_cache.write().await; cache.clear(); } } impl Default for AppViewRegistry { fn default() -> Self { Self::new() } } pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option { registry.get_appview_for_method(method).await.map(|r| r.url) } pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option { registry.get_appview_for_method(method).await.map(|r| r.did) } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_namespace() { let registry = AppViewRegistry::new(); assert_eq!( registry.extract_namespace("app.bsky.actor.getProfile"), Some("app.bsky".to_string()) ); assert_eq!( registry.extract_namespace("com.atproto.repo.createRecord"), Some("com.atproto".to_string()) ); assert_eq!( registry.extract_namespace("com.whtwnd.blog.getPost"), Some("com.whtwnd".to_string()) ); assert_eq!(registry.extract_namespace("invalid"), None); } #[test] fn test_get_did_for_namespace() { let mut registry = AppViewRegistry::new(); registry.register_namespace("com.whtwnd", "did:web:whtwnd.com"); assert!(registry.get_did_for_namespace("app.bsky").is_some()); assert_eq!( registry.get_did_for_namespace("com.whtwnd"), Some("did:web:whtwnd.com".to_string()) ); assert!(registry.get_did_for_namespace("unknown.namespace").is_none()); } }