Parakeet is a Rust-based Bluesky AppView aiming to implement most of the functionality required to support the Bluesky client
at main 5.1 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 async fn resolve_did_web(&self, id: &str) -> Result<Option<types::DidDocument>, Error> { 83 let res = match self 84 .client 85 .get(format!("https://{id}/.well-known/did.json")) 86 .send() 87 .await 88 { 89 Ok(res) => res, 90 Err(err) => { 91 if err.is_timeout() { 92 return Err(Error::Timeout); 93 } else if err.is_redirect() { 94 return Err(Error::TooManyRedirects); 95 } 96 return Err(Error::Http(err)); 97 } 98 }; 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 { 107 return Ok(None); 108 } 109 110 let did_doc = res.json().await?; 111 Ok(Some(did_doc)) 112 } 113 114 pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>, Error> { 115 // we want one of these to succeed 116 let http = self.resolve_handle_http(handle); 117 let dns = self.resolve_handle_dns(handle); 118 119 match tokio::join!(http, dns) { 120 (Ok(http), Ok(dns)) => Ok(dns.or(http)), 121 (http, dns) => http.or(dns), 122 } 123 } 124 125 async fn resolve_handle_http(&self, handle: &str) -> Result<Option<String>, Error> { 126 let res = self 127 .client 128 .get(format!("https://{handle}/.well-known/atproto-did")) 129 .send() 130 .await? 131 .error_for_status()?; 132 133 if let Some(ct) = res 134 .headers() 135 .get(CONTENT_TYPE) 136 .and_then(|ct| ct.to_str().ok()) 137 { 138 if !ct.starts_with("text/plain") { 139 return Ok(None); 140 } 141 } 142 143 let did = res.text().await?; 144 if !did.starts_with("did:") { 145 return Ok(None); 146 } 147 148 Ok(Some(did)) 149 } 150 151 async fn resolve_handle_dns(&self, handle: &str) -> Result<Option<String>, Error> { 152 let res = match self.dns.txt_lookup(format!("_atproto.{handle}.")).await { 153 Ok(txt) => txt, 154 Err(err) => { 155 return match err.kind() { 156 hickory_resolver::error::ResolveErrorKind::NoRecordsFound { .. } => Ok(None), 157 _ => Err(err.into()), 158 } 159 } 160 }; 161 162 let Some(first) = res.as_lookup().records().first() else { 163 return Ok(None); 164 }; 165 166 let Some(Ok(data)) = first.data().and_then(|v| v.as_txt()).map(|v| v.to_bytes()) else { 167 return Ok(None); 168 }; 169 170 let Some(string_data) = String::from_utf8_lossy(&data) 171 .strip_prefix("$did=") 172 .map(|v| v.to_string()) 173 else { 174 return Ok(None); 175 }; 176 177 Ok(Some(string_data)) 178 } 179}