Parakeet is a Rust-based Bluesky AppView aiming to implement most of the functionality required to support the Bluesky client
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}