Rust AppView - highly experimental!
at experiments 213 lines 6.2 kB view raw
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}