forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use anyhow::Result;
2use errors::ResolveError;
3use futures_util::future::join3;
4use hickory_resolver::{
5 config::{NameServerConfigGroup, ResolverConfig, ResolverOpts},
6 TokioAsyncResolver,
7};
8use std::collections::HashSet;
9use std::time::Duration;
10
11use crate::config::DnsNameservers;
12use crate::did::web::query_hostname;
13
14pub enum InputType {
15 Handle(String),
16 Plc(String),
17 Web(String),
18}
19
20pub async fn resolve_handle_dns(
21 dns_resolver: &TokioAsyncResolver,
22 lookup_dns: &str,
23) -> Result<String, ResolveError> {
24 let lookup = dns_resolver
25 .txt_lookup(&format!("_atproto.{}", lookup_dns))
26 .await
27 .map_err(ResolveError::DNSResolutionFailed)?;
28
29 let dids = lookup
30 .iter()
31 .filter_map(|record| {
32 record
33 .to_string()
34 .strip_prefix("did=")
35 .map(|did| did.to_string())
36 })
37 .collect::<HashSet<String>>();
38
39 if dids.len() > 1 {
40 return Err(ResolveError::MultipleDIDsFound);
41 }
42
43 dids.iter().next().cloned().ok_or(ResolveError::NoDIDsFound)
44}
45
46pub async fn resolve_handle_http(
47 http_client: &reqwest::Client,
48 handle: &str,
49) -> Result<String, ResolveError> {
50 let lookup_url = format!("https://{}/.well-known/atproto-did", handle);
51
52 http_client
53 .get(lookup_url.clone())
54 .timeout(Duration::from_secs(10))
55 .send()
56 .await
57 .map_err(ResolveError::HTTPResolutionFailed)?
58 .text()
59 .await
60 .map_err(ResolveError::HTTPResolutionFailed)
61 .and_then(|body| {
62 if body.starts_with("did:") {
63 Ok(body.trim().to_string())
64 } else {
65 Err(ResolveError::InvalidHTTPResolutionResponse)
66 }
67 })
68}
69
70pub fn parse_input(input: &str) -> Result<InputType, ResolveError> {
71 let trimmed = {
72 if let Some(value) = input.trim().strip_prefix("at://") {
73 value.trim()
74 } else if let Some(value) = input.trim().strip_prefix('@') {
75 value.trim()
76 } else {
77 input.trim()
78 }
79 };
80 if trimmed.is_empty() {
81 return Err(ResolveError::InvalidInput);
82 }
83 if trimmed.starts_with("did:web:") {
84 Ok(InputType::Web(trimmed.to_string()))
85 } else if trimmed.starts_with("did:plc:") {
86 Ok(InputType::Plc(trimmed.to_string()))
87 } else {
88 Ok(InputType::Handle(trimmed.to_string()))
89 }
90}
91
92pub async fn resolve_handle(
93 http_client: &reqwest::Client,
94 dns_resolver: &TokioAsyncResolver,
95 handle: &str,
96) -> Result<String, ResolveError> {
97 let trimmed = {
98 if let Some(value) = handle.trim().strip_prefix("at://") {
99 value
100 } else if let Some(value) = handle.trim().strip_prefix('@') {
101 value
102 } else {
103 handle.trim()
104 }
105 };
106
107 let (dns_lookup, http_lookup, did_web_lookup) = join3(
108 resolve_handle_dns(dns_resolver, trimmed),
109 resolve_handle_http(http_client, trimmed),
110 query_hostname(http_client, trimmed),
111 )
112 .await;
113
114 tracing::debug!(
115 ?handle,
116 ?dns_lookup,
117 ?http_lookup,
118 ?did_web_lookup,
119 "raw query results"
120 );
121
122 let did_web_lookup_did = did_web_lookup
123 .map(|document| document.id)
124 .map_err(ResolveError::DIDWebResolutionFailed);
125
126 let results = vec![dns_lookup, http_lookup, did_web_lookup_did]
127 .into_iter()
128 .filter_map(|result| result.ok())
129 .collect::<Vec<String>>();
130 if results.is_empty() {
131 return Err(ResolveError::NoDIDsFound);
132 }
133
134 tracing::debug!(?handle, ?results, "query results");
135
136 let first = results[0].clone();
137 if results.iter().all(|result| result == &first) {
138 return Ok(first);
139 }
140 Err(ResolveError::ConflictingDIDsFound)
141}
142
143pub async fn resolve_subject(
144 http_client: &reqwest::Client,
145 dns_resolver: &TokioAsyncResolver,
146 subject: &str,
147) -> Result<String, ResolveError> {
148 match parse_input(subject)? {
149 InputType::Handle(handle) => resolve_handle(http_client, dns_resolver, &handle).await,
150 InputType::Plc(did) | InputType::Web(did) => Ok(did),
151 }
152}
153
154/// Creates a new DNS resolver with configuration based on app config.
155///
156/// If custom nameservers are configured in app config, they will be used.
157/// Otherwise, the system default resolver configuration will be used.
158pub fn create_resolver(nameservers: DnsNameservers) -> TokioAsyncResolver {
159 // Initialize the DNS resolver with custom nameservers if configured
160 let nameservers = nameservers.as_ref();
161 let resolver_config = if !nameservers.is_empty() {
162 // Use custom nameservers
163 tracing::info!("Using custom DNS nameservers: {:?}", nameservers);
164 let nameserver_group = NameServerConfigGroup::from_ips_clear(nameservers, 53, true);
165 ResolverConfig::from_parts(None, vec![], nameserver_group)
166 } else {
167 // Use system default
168 tracing::info!("Using system default DNS nameservers");
169 ResolverConfig::default()
170 };
171
172 // TokioAsyncResolver::tokio returns an AsyncResolver directly, not a Result
173 TokioAsyncResolver::tokio(resolver_config, ResolverOpts::default())
174}
175
176pub mod errors {
177 use thiserror::Error;
178
179 #[derive(Debug, Error)]
180 pub enum ResolveError {
181 #[error("error-resolve-1 Multiple DIDs resolved for method")]
182 MultipleDIDsFound,
183
184 #[error("error-resolve-2 No DIDs resolved for method")]
185 NoDIDsFound,
186
187 #[error("error-resolve-3 No DIDs resolved for method")]
188 ConflictingDIDsFound,
189
190 #[error("error-resolve-4 DNS resolution failed: {0:?}")]
191 DNSResolutionFailed(hickory_resolver::error::ResolveError),
192
193 #[error("error-resolve-5 HTTP resolution failed: {0:?}")]
194 HTTPResolutionFailed(reqwest::Error),
195
196 #[error("error-resolve-6 HTTP resolution failed")]
197 InvalidHTTPResolutionResponse,
198
199 #[error("error-resolve-7 HTTP resolution failed: {0:?}")]
200 DIDWebResolutionFailed(anyhow::Error),
201
202 #[error("error-resolve-8 Invalid input")]
203 InvalidInput,
204 }
205}