1use error::Error;
2use hickory_resolver::proto::serialize::binary::BinEncodable;
3use hickory_resolver::TokioAsyncResolver;
4use reqwest::header::CONTENT_TYPE;
5use reqwest::{Client, StatusCode};
6
7pub mod error;
8pub mod types;
9
10const DEFAULT_DUR: std::time::Duration = std::time::Duration::from_secs(3);
11const DEFAULT_PLC: &str = "https://plc.directory";
12
13#[derive(Default)]
14pub struct ResolverOpts {
15 pub plc_directory: Option<String>,
16 pub timeout: Option<std::time::Duration>,
17 pub user_agent: Option<String>,
18}
19
20pub struct Resolver {
21 client: Client,
22 plc: String,
23 dns: TokioAsyncResolver,
24}
25
26impl Resolver {
27 pub fn new(opts: ResolverOpts) -> Result<Self, Error> {
28 let dns = hickory_resolver::AsyncResolver::tokio_from_system_conf()?;
29
30 let mut client = Client::builder().timeout(opts.timeout.unwrap_or(DEFAULT_DUR));
31
32 if let Some(user_agent) = opts.user_agent {
33 client = client.user_agent(user_agent);
34 }
35
36 Ok(Resolver {
37 client: client.build()?,
38 plc: opts.plc_directory.unwrap_or(DEFAULT_PLC.to_string()),
39 dns,
40 })
41 }
42
43 pub async fn resolve_did(&self, did: &str) -> Result<Option<types::DidDocument>, Error> {
44 let did_parts = did.splitn(3, ':').collect::<Vec<&str>>();
45
46 if did_parts.len() != 3 {
47 return Err(Error::BadDidFormat);
48 }
49
50 if did_parts[0] != "did" {
51 return Err(Error::BadDidFormat);
52 }
53
54 match did_parts[1] {
55 "plc" => self.resolve_did_plc(did).await,
56 "web" => self.resolve_did_web(did_parts[2]).await,
57 method => Err(Error::UnsupportedDidMethod(method.to_string())),
58 }
59 }
60
61 async fn resolve_did_plc(&self, did: &str) -> Result<Option<types::DidDocument>, Error> {
62 let res = self
63 .client
64 .get(format!("{}/{did}", self.plc))
65 .send()
66 .await?;
67
68 let status = res.status();
69
70 if status.is_server_error() {
71 return Err(Error::ServerError);
72 }
73
74 if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
75 return Ok(None);
76 }
77
78 let did_doc = res.json().await?;
79 Ok(Some(did_doc))
80 }
81
82 /// Get account creation timestamp from PLC audit log
83 /// Returns the timestamp of the first operation (where prev is null)
84 /// Only works for did:plc DIDs - returns None for other DID methods
85 pub async fn get_plc_creation_time(
86 &self,
87 did: &str,
88 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, Error> {
89 // Only fetch for did:plc
90 if !did.starts_with("did:plc:") {
91 return Ok(None);
92 }
93
94 let res = self
95 .client
96 .get(format!("{}/{did}/log/audit", self.plc))
97 .send()
98 .await?;
99
100 let status = res.status();
101
102 if status.is_server_error() {
103 return Err(Error::ServerError);
104 }
105
106 if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
107 return Ok(None);
108 }
109
110 let audit_log: Vec<types::PlcAuditLogEntry> = res.json().await?;
111
112 // First entry in the audit log is the account creation
113 Ok(audit_log.first().map(|entry| entry.created_at))
114 }
115
116 async fn resolve_did_web(&self, id: &str) -> Result<Option<types::DidDocument>, Error> {
117 let res = match self
118 .client
119 .get(format!("https://{id}/.well-known/did.json"))
120 .send()
121 .await
122 {
123 Ok(res) => res,
124 Err(err) => {
125 if err.is_timeout() {
126 return Err(Error::Timeout);
127 } else if err.is_redirect() {
128 return Err(Error::TooManyRedirects);
129 }
130 return Err(Error::Http(err));
131 }
132 };
133
134 let status = res.status();
135
136 if status.is_server_error() {
137 return Err(Error::ServerError);
138 }
139
140 if status == StatusCode::NOT_FOUND {
141 return Ok(None);
142 }
143
144 let did_doc = res.json().await?;
145 Ok(Some(did_doc))
146 }
147
148 pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>, Error> {
149 // we want one of these to succeed
150 let http = self.resolve_handle_http(handle);
151 let dns = self.resolve_handle_dns(handle);
152
153 match tokio::join!(http, dns) {
154 (Ok(http), Ok(dns)) => Ok(dns.or(http)),
155 (http, dns) => http.or(dns),
156 }
157 }
158
159 async fn resolve_handle_http(&self, handle: &str) -> Result<Option<String>, Error> {
160 let res = self
161 .client
162 .get(format!("https://{handle}/.well-known/atproto-did"))
163 .send()
164 .await?
165 .error_for_status()?;
166
167 if let Some(ct) = res
168 .headers()
169 .get(CONTENT_TYPE)
170 .and_then(|ct| ct.to_str().ok())
171 {
172 if !ct.starts_with("text/plain") {
173 return Ok(None);
174 }
175 }
176
177 let did = res.text().await?;
178 if !did.starts_with("did:") {
179 return Ok(None);
180 }
181
182 Ok(Some(did))
183 }
184
185 async fn resolve_handle_dns(&self, handle: &str) -> Result<Option<String>, Error> {
186 let res = match self.dns.txt_lookup(format!("_atproto.{handle}.")).await {
187 Ok(txt) => txt,
188 Err(err) => {
189 return match err.kind() {
190 hickory_resolver::error::ResolveErrorKind::NoRecordsFound { .. } => Ok(None),
191 _ => Err(err.into()),
192 }
193 }
194 };
195
196 let Some(first) = res.as_lookup().records().first() else {
197 return Ok(None);
198 };
199
200 let Some(Ok(data)) = first.data().and_then(|v| v.as_txt()).map(|v| v.to_bytes()) else {
201 return Ok(None);
202 };
203
204 let Some(string_data) = String::from_utf8_lossy(&data)
205 .strip_prefix("$did=")
206 .map(|v| v.to_string())
207 else {
208 return Ok(None);
209 };
210
211 Ok(Some(string_data))
212 }
213}