this repo has no description
1use reqwest::Client; 2use serde::{Deserialize, Serialize}; 3use std::collections::HashMap; 4use std::sync::Arc; 5use std::time::{Duration, Instant}; 6use tokio::sync::RwLock; 7use tracing::{debug, error, info, warn}; 8 9#[derive(Debug, Clone, Serialize, Deserialize)] 10pub struct DidDocument { 11 pub id: String, 12 #[serde(default)] 13 pub service: Vec<DidService>, 14} 15 16#[derive(Debug, Clone, Serialize, Deserialize)] 17#[serde(rename_all = "camelCase")] 18pub struct DidService { 19 pub id: String, 20 #[serde(rename = "type")] 21 pub service_type: String, 22 pub service_endpoint: String, 23} 24 25#[derive(Clone)] 26struct CachedDid { 27 url: String, 28 did: String, 29 resolved_at: Instant, 30} 31 32#[derive(Debug, Clone)] 33pub struct ResolvedService { 34 pub url: String, 35 pub did: String, 36} 37 38pub struct DidResolver { 39 did_cache: RwLock<HashMap<String, CachedDid>>, 40 client: Client, 41 cache_ttl: Duration, 42 plc_directory_url: String, 43} 44 45impl Clone for DidResolver { 46 fn clone(&self) -> Self { 47 Self { 48 did_cache: RwLock::new(HashMap::new()), 49 client: self.client.clone(), 50 cache_ttl: self.cache_ttl, 51 plc_directory_url: self.plc_directory_url.clone(), 52 } 53 } 54} 55 56impl DidResolver { 57 pub fn new() -> Self { 58 let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS") 59 .ok() 60 .and_then(|v| v.parse().ok()) 61 .unwrap_or(300); 62 63 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 64 .unwrap_or_else(|_| "https://plc.directory".to_string()); 65 66 let client = Client::builder() 67 .timeout(Duration::from_secs(10)) 68 .connect_timeout(Duration::from_secs(5)) 69 .pool_max_idle_per_host(10) 70 .build() 71 .unwrap_or_else(|_| Client::new()); 72 73 info!("DID resolver initialized"); 74 75 Self { 76 did_cache: RwLock::new(HashMap::new()), 77 client, 78 cache_ttl: Duration::from_secs(cache_ttl_secs), 79 plc_directory_url, 80 } 81 } 82 83 pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> { 84 { 85 let cache = self.did_cache.read().await; 86 if let Some(cached) = cache.get(did) 87 && cached.resolved_at.elapsed() < self.cache_ttl 88 { 89 return Some(ResolvedService { 90 url: cached.url.clone(), 91 did: cached.did.clone(), 92 }); 93 } 94 } 95 96 let resolved = self.resolve_did_internal(did).await?; 97 98 { 99 let mut cache = self.did_cache.write().await; 100 cache.insert( 101 did.to_string(), 102 CachedDid { 103 url: resolved.url.clone(), 104 did: resolved.did.clone(), 105 resolved_at: Instant::now(), 106 }, 107 ); 108 } 109 110 Some(resolved) 111 } 112 113 pub async fn refresh_did(&self, did: &str) -> Option<ResolvedService> { 114 { 115 let mut cache = self.did_cache.write().await; 116 cache.remove(did); 117 } 118 self.resolve_did(did).await 119 } 120 121 async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedService> { 122 let did_doc = if did.starts_with("did:web:") { 123 self.resolve_did_web(did).await 124 } else if did.starts_with("did:plc:") { 125 self.resolve_did_plc(did).await 126 } else { 127 warn!("Unsupported DID method: {}", did); 128 return None; 129 }; 130 131 let doc = match did_doc { 132 Ok(doc) => doc, 133 Err(e) => { 134 error!("Failed to resolve DID {}: {}", did, e); 135 return None; 136 } 137 }; 138 139 self.extract_service_endpoint(&doc) 140 } 141 142 async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> { 143 let host = did 144 .strip_prefix("did:web:") 145 .ok_or("Invalid did:web format")?; 146 147 let (host, path) = if host.contains(':') { 148 let decoded = host.replace("%3A", ":"); 149 let parts: Vec<&str> = decoded.splitn(2, '/').collect(); 150 if parts.len() > 1 { 151 (parts[0].to_string(), format!("/{}", parts[1])) 152 } else { 153 (decoded, String::new()) 154 } 155 } else { 156 let parts: Vec<&str> = host.splitn(2, ':').collect(); 157 if parts.len() > 1 && parts[1].contains('/') { 158 let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); 159 if path_parts.len() > 1 { 160 ( 161 format!("{}:{}", parts[0], path_parts[0]), 162 format!("/{}", path_parts[1]), 163 ) 164 } else { 165 (host.to_string(), String::new()) 166 } 167 } else { 168 (host.to_string(), String::new()) 169 } 170 }; 171 172 let scheme = 173 if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') 174 { 175 "http" 176 } else { 177 "https" 178 }; 179 180 let url = if path.is_empty() { 181 format!("{}://{}/.well-known/did.json", scheme, host) 182 } else { 183 format!("{}://{}{}/did.json", scheme, host, path) 184 }; 185 186 debug!("Resolving did:web {} via {}", did, url); 187 188 let resp = self 189 .client 190 .get(&url) 191 .send() 192 .await 193 .map_err(|e| format!("HTTP request failed: {}", e))?; 194 195 if !resp.status().is_success() { 196 return Err(format!("HTTP {}", resp.status())); 197 } 198 199 resp.json::<DidDocument>() 200 .await 201 .map_err(|e| format!("Failed to parse DID document: {}", e)) 202 } 203 204 async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> { 205 let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); 206 207 debug!("Resolving did:plc {} via {}", did, url); 208 209 let resp = self 210 .client 211 .get(&url) 212 .send() 213 .await 214 .map_err(|e| format!("HTTP request failed: {}", e))?; 215 216 if resp.status() == reqwest::StatusCode::NOT_FOUND { 217 return Err("DID not found".to_string()); 218 } 219 220 if !resp.status().is_success() { 221 return Err(format!("HTTP {}", resp.status())); 222 } 223 224 resp.json::<DidDocument>() 225 .await 226 .map_err(|e| format!("Failed to parse DID document: {}", e)) 227 } 228 229 fn extract_service_endpoint(&self, doc: &DidDocument) -> Option<ResolvedService> { 230 for service in &doc.service { 231 if service.service_type == "AtprotoAppView" 232 || service.id.contains("atproto_appview") 233 || service.id.ends_with("#bsky_appview") 234 { 235 return Some(ResolvedService { 236 url: service.service_endpoint.clone(), 237 did: doc.id.clone(), 238 }); 239 } 240 } 241 242 for service in &doc.service { 243 if service.service_type.contains("AppView") || service.id.contains("appview") { 244 return Some(ResolvedService { 245 url: service.service_endpoint.clone(), 246 did: doc.id.clone(), 247 }); 248 } 249 } 250 251 if let Some(service) = doc.service.first() 252 && service.service_endpoint.starts_with("http") 253 { 254 warn!( 255 "No explicit AppView service found for {}, using first service: {}", 256 doc.id, service.service_endpoint 257 ); 258 return Some(ResolvedService { 259 url: service.service_endpoint.clone(), 260 did: doc.id.clone(), 261 }); 262 } 263 264 if doc.id.starts_with("did:web:") { 265 let host = doc.id.strip_prefix("did:web:")?; 266 let decoded_host = host.replace("%3A", ":"); 267 let base_host = decoded_host.split('/').next()?; 268 let scheme = if base_host.starts_with("localhost") 269 || base_host.starts_with("127.0.0.1") 270 || base_host.contains(':') 271 { 272 "http" 273 } else { 274 "https" 275 }; 276 warn!( 277 "No service found for {}, deriving URL from DID: {}://{}", 278 doc.id, scheme, base_host 279 ); 280 return Some(ResolvedService { 281 url: format!("{}://{}", scheme, base_host), 282 did: doc.id.clone(), 283 }); 284 } 285 286 None 287 } 288 289 pub async fn invalidate_cache(&self, did: &str) { 290 let mut cache = self.did_cache.write().await; 291 cache.remove(did); 292 } 293} 294 295impl Default for DidResolver { 296 fn default() -> Self { 297 Self::new() 298 } 299} 300 301pub fn create_did_resolver() -> Arc<DidResolver> { 302 Arc::new(DidResolver::new()) 303}