1use async_trait::async_trait;
4use did_ma::Document;
5#[cfg(not(target_arch = "wasm32"))]
6use std::collections::HashMap;
7#[cfg(not(target_arch = "wasm32"))]
8use std::sync::atomic::{AtomicU64, Ordering};
9#[cfg(not(target_arch = "wasm32"))]
10use std::sync::Mutex;
11use std::time::Duration;
12#[cfg(not(target_arch = "wasm32"))]
13use std::time::Instant;
14
15#[async_trait]
20pub trait DidResolver: Send + Sync {
21 async fn resolve(&self, did: &str) -> crate::error::Result<Document>;
22
23 fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
27
28 fn cache_ttls(&self) -> Option<(Duration, Duration)> {
30 None
31 }
32}
33
34#[cfg(not(target_arch = "wasm32"))]
38pub struct GatewayResolver {
39 gateways: Vec<String>,
40 client: reqwest::Client,
41 positive_ttl_secs: AtomicU64,
42 negative_ttl_secs: AtomicU64,
43 localhost_cooldown: Duration,
44 cache: Mutex<HashMap<String, CacheEntry>>,
45 localhost_blocked_until: Mutex<Option<Instant>>,
46}
47
48#[cfg(not(target_arch = "wasm32"))]
49#[derive(Clone)]
50struct CacheEntry {
51 expires_at: Instant,
52 value: CacheValue,
53}
54
55#[cfg(not(target_arch = "wasm32"))]
56#[derive(Clone)]
57enum CacheValue {
58 Hit(String),
59 Miss(String),
60}
61
62#[cfg(not(target_arch = "wasm32"))]
63impl GatewayResolver {
64 const LOCALHOST_GATEWAY: &'static str = "http://127.0.0.1:8080/";
65 const DEFAULT_PUBLIC_GATEWAYS: [&'static str; 2] = ["https://dweb.link/", "https://w3s.link/"];
66
67 pub fn new(gateway_url: impl Into<String>) -> Self {
68 let primary = normalize_gateway_url(&gateway_url.into());
69
70 let mut gateways = Vec::new();
71 push_gateway(&mut gateways, Self::LOCALHOST_GATEWAY);
72 push_gateway(&mut gateways, &primary);
73 for fallback in Self::DEFAULT_PUBLIC_GATEWAYS {
74 push_gateway(&mut gateways, fallback);
75 }
76
77 let client = reqwest::Client::builder()
78 .timeout(Duration::from_secs(4))
79 .build()
80 .unwrap_or_else(|_| reqwest::Client::new());
81
82 Self {
83 gateways,
84 client,
85 positive_ttl_secs: AtomicU64::new(60),
86 negative_ttl_secs: AtomicU64::new(10),
87 localhost_cooldown: Duration::from_secs(20),
88 cache: Mutex::new(HashMap::new()),
89 localhost_blocked_until: Mutex::new(None),
90 }
91 }
92
93 #[must_use]
94 pub fn with_cache_ttls(self, positive_ttl: Duration, negative_ttl: Duration) -> Self {
95 self.set_cache_ttls_inner(positive_ttl, negative_ttl);
96 self
97 }
98
99 fn set_cache_ttls_inner(&self, positive_ttl: Duration, negative_ttl: Duration) {
100 self.positive_ttl_secs
101 .store(positive_ttl.as_secs(), Ordering::Relaxed);
102 self.negative_ttl_secs
103 .store(negative_ttl.as_secs(), Ordering::Relaxed);
104 }
105
106 fn positive_ttl(&self) -> Duration {
107 Duration::from_secs(self.positive_ttl_secs.load(Ordering::Relaxed))
108 }
109
110 fn negative_ttl(&self) -> Duration {
111 Duration::from_secs(self.negative_ttl_secs.load(Ordering::Relaxed))
112 }
113
114 #[must_use]
115 pub fn with_localhost_cooldown(mut self, cooldown: Duration) -> Self {
116 self.localhost_cooldown = cooldown;
117 self
118 }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122#[async_trait]
123impl DidResolver for GatewayResolver {
124 async fn resolve(&self, did: &str) -> crate::error::Result<Document> {
125 let parsed = did_ma::Did::try_from(did).map_err(crate::error::Error::Validation)?;
126 let did_key = did.to_string();
127 let positive_ttl = self.positive_ttl();
128 let negative_ttl = self.negative_ttl();
129 let cache_hit_enabled = !positive_ttl.is_zero();
130 let cache_miss_enabled = !negative_ttl.is_zero();
131
132 if let Some(cached) = self.read_cache(&did_key, cache_hit_enabled, cache_miss_enabled) {
133 return match cached {
134 CacheValue::Hit(body) => {
135 Document::unmarshal(&body).map_err(|e| crate::error::Error::Resolution {
136 did: did_key,
137 detail: format!("cached document parse failed: {e}"),
138 })
139 }
140 CacheValue::Miss(detail) => Err(crate::error::Error::Resolution {
141 did: did_key,
142 detail,
143 }),
144 };
145 }
146
147 let mut errors = Vec::new();
148 let now = Instant::now();
149
150 for gateway in &self.gateways {
151 if is_localhost_gateway(gateway) && self.localhost_is_blocked(now) {
152 errors.push(format!("{} -> skipped (cooldown)", gateway));
153 continue;
154 }
155
156 let url = format!("{}ipns/{}", gateway, parsed.ipns);
157
158 let response = match self.client.get(&url).send().await {
159 Ok(response) => response,
160 Err(err) => {
161 if is_localhost_gateway(gateway) {
162 self.block_localhost_until(Some(now + self.localhost_cooldown));
163 }
164 errors.push(format!("{url} -> {err}"));
165 continue;
166 }
167 };
168
169 if !response.status().is_success() {
170 if is_localhost_gateway(gateway) {
171 self.block_localhost_until(Some(now + self.localhost_cooldown));
172 }
173 errors.push(format!("{url} -> HTTP {}", response.status()));
174 continue;
175 }
176
177 let body = match response.text().await {
178 Ok(body) => body,
179 Err(err) => {
180 if is_localhost_gateway(gateway) {
181 self.block_localhost_until(Some(now + self.localhost_cooldown));
182 }
183 errors.push(format!("{url} -> {err}"));
184 continue;
185 }
186 };
187
188 let doc = match Document::unmarshal(&body) {
189 Ok(doc) => doc,
190 Err(err) => {
191 errors.push(format!("{url} -> invalid DID document: {err}"));
192 continue;
193 }
194 };
195
196 if is_localhost_gateway(gateway) {
197 self.block_localhost_until(None);
198 }
199
200 if cache_hit_enabled {
201 self.write_cache(did_key.clone(), CacheValue::Hit(body), now + positive_ttl);
202 }
203 return Ok(doc);
204 }
205
206 let detail = format!("all gateways failed: {}", errors.join(" | "));
207 if cache_miss_enabled {
208 self.write_cache(
209 did_key.clone(),
210 CacheValue::Miss(detail.clone()),
211 now + negative_ttl,
212 );
213 }
214
215 Err(crate::error::Error::Resolution {
216 did: did_key,
217 detail,
218 })
219 }
220
221 fn set_cache_ttls(&self, positive_ttl: Duration, negative_ttl: Duration) {
222 self.set_cache_ttls_inner(positive_ttl, negative_ttl);
223 }
224
225 fn cache_ttls(&self) -> Option<(Duration, Duration)> {
226 Some((self.positive_ttl(), self.negative_ttl()))
227 }
228}
229
230#[cfg(not(target_arch = "wasm32"))]
231impl GatewayResolver {
232 fn read_cache(
233 &self,
234 did: &str,
235 cache_hit_enabled: bool,
236 cache_miss_enabled: bool,
237 ) -> Option<CacheValue> {
238 if !cache_hit_enabled && !cache_miss_enabled {
239 return None;
240 }
241
242 let mut cache = self.cache.lock().ok()?;
243 let entry = cache.get(did).cloned()?;
244 if entry.expires_at <= Instant::now() {
245 cache.remove(did);
246 return None;
247 }
248
249 match entry.value {
250 CacheValue::Hit(value) if cache_hit_enabled => Some(CacheValue::Hit(value)),
251 CacheValue::Miss(value) if cache_miss_enabled => Some(CacheValue::Miss(value)),
252 _ => None,
253 }
254 }
255
256 fn write_cache(&self, did: String, value: CacheValue, expires_at: Instant) {
257 if let Ok(mut cache) = self.cache.lock() {
258 cache.insert(did, CacheEntry { expires_at, value });
259 }
260 }
261
262 fn localhost_is_blocked(&self, now: Instant) -> bool {
263 let guard = match self.localhost_blocked_until.lock() {
264 Ok(guard) => guard,
265 Err(_) => return false,
266 };
267 guard.as_ref().is_some_and(|blocked| *blocked > now)
268 }
269
270 fn block_localhost_until(&self, until: Option<Instant>) {
271 if let Ok(mut guard) = self.localhost_blocked_until.lock() {
272 *guard = until;
273 }
274 }
275}
276
277#[cfg(not(target_arch = "wasm32"))]
278fn normalize_gateway_url(input: &str) -> String {
279 let mut url = input.trim().to_string();
280 if !url.ends_with('/') {
281 url.push('/');
282 }
283 url
284}
285
286#[cfg(not(target_arch = "wasm32"))]
287fn push_gateway(gateways: &mut Vec<String>, candidate: &str) {
288 let normalized = normalize_gateway_url(candidate);
289 if !gateways.iter().any(|g| g.eq_ignore_ascii_case(&normalized)) {
290 gateways.push(normalized);
291 }
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295fn is_localhost_gateway(gateway: &str) -> bool {
296 gateway.starts_with("http://127.0.0.1:") || gateway.starts_with("http://localhost:")
297}