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}