BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1use crate::error::{AppError, Result};
2use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
3use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandle;
4use jacquard::api::com_atproto::label::query_labels::QueryLabels;
5use jacquard::api::com_atproto::repo::describe_repo::DescribeRepo;
6use jacquard::api::com_atproto::repo::get_record::GetRecord;
7use jacquard::api::com_atproto::repo::list_records::ListRecords;
8use jacquard::api::com_atproto::server::describe_server::DescribeServer;
9use jacquard::api::com_atproto::sync::get_blob::GetBlob;
10use jacquard::api::com_atproto::sync::get_repo::GetRepo;
11use jacquard::api::com_atproto::sync::list_repos::ListRepos;
12use jacquard::client::{Agent, UnauthenticatedSession};
13use jacquard::deps::fluent_uri::Uri;
14use jacquard::identity::{resolver::IdentityResolver, JacquardResolver};
15use jacquard::types::aturi::AtUri;
16use jacquard::types::cid::Cid;
17use jacquard::types::did::Did;
18use jacquard::types::did_doc::DidDocument;
19use jacquard::types::handle::Handle;
20use jacquard::types::ident::AtIdentifier;
21use jacquard::types::nsid::Nsid;
22use jacquard::types::recordkey::{RecordKey, Rkey};
23use jacquard::xrpc::XrpcClient;
24use jacquard::IntoStatic;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::time::Duration;
30use tauri::{AppHandle, Emitter, Manager};
31use tauri_plugin_log::log;
32use uuid::Uuid;
33
34pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved";
35const PDS_REPO_LIST_LIMIT: i64 = 100;
36const QUERY_LABELS_LIMIT: i64 = 100;
37const FAVICON_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
38const LEXICON_FAVICON_HOST_OVERRIDES: &[(&str, &str)] = &[("sh.tangled.", "tangled.org"), ("chat.bsky.", "bsky.app")];
39
40type ExplorerClient = Agent<UnauthenticatedSession<JacquardResolver>>;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub enum ExplorerInputKind {
45 AtUri,
46 Handle,
47 Did,
48 PdsUrl,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub enum ExplorerTargetKind {
54 Pds,
55 Repo,
56 Collection,
57 Record,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct ResolvedExplorerInput {
63 pub input: String,
64 pub input_kind: ExplorerInputKind,
65 pub target_kind: ExplorerTargetKind,
66 pub normalized_input: String,
67 pub uri: Option<String>,
68 pub did: Option<String>,
69 pub handle: Option<String>,
70 pub pds_url: Option<String>,
71 pub collection: Option<String>,
72 pub rkey: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ExplorerNavigation {
78 pub target: ResolvedExplorerInput,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ExplorerHostedRepo {
84 pub did: String,
85 pub head: String,
86 pub rev: String,
87 pub active: bool,
88 pub status: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ExplorerServerView {
94 pub pds_url: String,
95 pub server: Value,
96 pub repos: Vec<ExplorerHostedRepo>,
97 pub cursor: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct RepoCarExport {
103 pub did: String,
104 pub path: String,
105 pub bytes_written: usize,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct TempBlobFile {
111 pub path: String,
112 pub bytes_written: usize,
113}
114
115pub async fn resolve_input(input: String) -> Result<ResolvedExplorerInput> {
116 let trimmed = input.trim();
117 if trimmed.is_empty() {
118 return Err(AppError::validation("explorer input cannot be empty"));
119 }
120
121 match detect_input_kind(trimmed)? {
122 ExplorerInputKind::AtUri => resolve_at_uri_input(trimmed).await,
123 ExplorerInputKind::Handle => resolve_handle_input(trimmed).await,
124 ExplorerInputKind::Did => resolve_did_input(trimmed).await,
125 ExplorerInputKind::PdsUrl => Ok(ResolvedExplorerInput {
126 input: trimmed.to_string(),
127 input_kind: ExplorerInputKind::PdsUrl,
128 target_kind: ExplorerTargetKind::Pds,
129 normalized_input: normalize_pds_url(trimmed)?,
130 uri: None,
131 did: None,
132 handle: None,
133 pds_url: Some(normalize_pds_url(trimmed)?),
134 collection: None,
135 rkey: None,
136 }),
137 }
138}
139
140pub async fn describe_server(pds_url: String) -> Result<ExplorerServerView> {
141 let normalized_pds_url = normalize_pds_url(&pds_url)?;
142 let client = client_for_base_uri(&normalized_pds_url).await?;
143
144 let server_output = client
145 .send(DescribeServer)
146 .await
147 .map_err(|error| AppError::validation(format!("describeServer request failed: {error}")))?
148 .into_output()
149 .map_err(|error| AppError::validation(format!("describeServer output failed: {error}")))?
150 .into_static();
151
152 let repo_output = client
153 .send(ListRepos::new().limit(PDS_REPO_LIST_LIMIT).build())
154 .await
155 .map_err(|error| AppError::validation(format!("listRepos request failed: {error}")))?
156 .into_output()
157 .map_err(|error| AppError::validation(format!("listRepos output failed: {error}")))?
158 .into_static();
159
160 let repos = repo_output
161 .repos
162 .into_iter()
163 .map(|repo| ExplorerHostedRepo {
164 did: repo.did.to_string(),
165 head: repo.head.to_string(),
166 rev: repo.rev.to_string(),
167 active: repo.active.unwrap_or(true),
168 status: repo.status.map(|status| status.to_string()),
169 })
170 .collect();
171
172 Ok(ExplorerServerView {
173 pds_url: normalized_pds_url,
174 server: serde_json::to_value(&server_output)?,
175 repos,
176 cursor: repo_output.cursor.map(|cursor| cursor.to_string()),
177 })
178}
179
180pub async fn describe_repo(did: String) -> Result<Value> {
181 let output = describe_repo_output(&did).await?;
182 serde_json::to_value(output).map_err(AppError::from)
183}
184
185pub async fn list_records(did: String, collection: String, cursor: Option<String>) -> Result<Value> {
186 let client = client_for_repo_did(&did).await?;
187 let request = ListRecords::new()
188 .repo(parse_at_identifier(&did)?)
189 .collection(parse_collection(&collection)?)
190 .maybe_cursor(cursor.map(Into::into))
191 .build();
192
193 let output = client
194 .send(request)
195 .await
196 .map_err(|error| AppError::validation(format!("listRecords request failed: {error}")))?
197 .into_output()
198 .map_err(|error| AppError::validation(format!("listRecords output failed: {error}")))?
199 .into_static();
200
201 serde_json::to_value(output).map_err(AppError::from)
202}
203
204pub async fn get_record(did: String, collection: String, rkey: String) -> Result<Value> {
205 let client = client_for_repo_did(&did).await?;
206 let request = GetRecord::new()
207 .repo(parse_at_identifier(&did)?)
208 .collection(parse_collection(&collection)?)
209 .rkey(parse_record_key(&rkey)?)
210 .build();
211
212 let output = client
213 .send(request)
214 .await
215 .map_err(|error| AppError::validation(format!("getRecord request failed: {error}")))?
216 .into_output()
217 .map_err(|error| AppError::validation(format!("getRecord output failed: {error}")))?
218 .into_static();
219
220 serde_json::to_value(output).map_err(AppError::from)
221}
222
223pub async fn export_repo_car(did: String, app: &AppHandle) -> Result<RepoCarExport> {
224 let parsed_did = Did::new(&did)?.into_static();
225 let client = client_for_repo_did(parsed_did.as_str()).await?;
226 let output = client
227 .send(GetRepo::new().did(parsed_did.clone()).build())
228 .await
229 .map_err(|error| AppError::validation(format!("getRepo request failed: {error}")))?
230 .into_output()
231 .map_err(|error| AppError::validation(format!("getRepo output failed: {error}")))?;
232
233 let export_path = resolve_car_export_path(app, parsed_did.as_str())?;
234 if let Some(parent) = export_path.parent() {
235 std::fs::create_dir_all(parent)?;
236 }
237 std::fs::write(&export_path, &output.body)?;
238
239 Ok(RepoCarExport {
240 did: parsed_did.to_string(),
241 path: export_path.to_string_lossy().into_owned(),
242 bytes_written: output.body.len(),
243 })
244}
245
246pub async fn fetch_blob_to_temp_file(
247 did: String, cid: String, extension: Option<String>, app: &AppHandle,
248) -> Result<TempBlobFile> {
249 let parsed_did = Did::new(did.trim())?.into_static();
250 let parsed_cid = parse_cid(&cid)?;
251 let client = client_for_repo_did(parsed_did.as_str()).await?;
252 let output = client
253 .send(GetBlob::new().did(parsed_did.clone()).cid(parsed_cid.clone()).build())
254 .await
255 .map_err(|error| AppError::validation(format!("getBlob request failed: {error}")))?
256 .into_output()
257 .map_err(|error| AppError::validation(format!("getBlob output failed: {error}")))?;
258
259 let blob_path = resolve_blob_temp_path(app, parsed_did.as_str(), parsed_cid.as_str(), extension.as_deref())?;
260 if let Some(parent) = blob_path.parent() {
261 std::fs::create_dir_all(parent)?;
262 }
263
264 std::fs::write(&blob_path, &output.body).map_err(|error| {
265 log::error!(
266 "failed to write temporary blob file {} for did {} cid {}: {error}",
267 blob_path.display(),
268 parsed_did,
269 parsed_cid
270 );
271 AppError::validation("Couldn't save a temporary media file for playback.")
272 })?;
273
274 Ok(TempBlobFile { path: blob_path.to_string_lossy().into_owned(), bytes_written: output.body.len() })
275}
276
277pub fn delete_blob_temp_file(path: &str, app: &AppHandle) -> Result<()> {
278 let trimmed_path = path.trim();
279 if trimmed_path.is_empty() {
280 return Ok(());
281 }
282
283 let target_path = PathBuf::from(trimmed_path);
284 if !target_path.exists() {
285 return Ok(());
286 }
287
288 let blob_dir = resolve_blob_temp_dir(app)?;
289 if !blob_dir.exists() {
290 std::fs::create_dir_all(&blob_dir)?;
291 }
292
293 let canonical_blob_dir = std::fs::canonicalize(&blob_dir)?;
294 let canonical_target = std::fs::canonicalize(&target_path).map_err(|error| {
295 log::warn!(
296 "failed to resolve blob temp file path {}: {error}",
297 target_path.display()
298 );
299 AppError::validation("Couldn't remove the temporary media file.")
300 })?;
301
302 if !is_path_within_directory(&canonical_target, &canonical_blob_dir) {
303 log::warn!(
304 "refusing to delete temp blob outside managed directory: {} not in {}",
305 canonical_target.display(),
306 canonical_blob_dir.display()
307 );
308 return Err(AppError::validation("Couldn't remove the temporary media file."));
309 }
310
311 if canonical_target.is_file() {
312 std::fs::remove_file(&canonical_target).map_err(|error| {
313 log::warn!(
314 "failed to remove temporary blob file {}: {error}",
315 canonical_target.display()
316 );
317 AppError::validation("Couldn't remove the temporary media file.")
318 })?;
319 }
320
321 Ok(())
322}
323
324pub async fn query_labels(uri: String) -> Result<Value> {
325 let normalized_uri = normalize_at_uri(&uri)?;
326 let client = public_client();
327 let output = client
328 .send(
329 QueryLabels::new()
330 .uri_patterns(vec![normalized_uri.into()])
331 .limit(QUERY_LABELS_LIMIT)
332 .build(),
333 )
334 .await
335 .map_err(|error| AppError::validation(format!("queryLabels request failed: {error}")))?
336 .into_output()
337 .map_err(|error| AppError::validation(format!("queryLabels output failed: {error}")))?
338 .into_static();
339
340 serde_json::to_value(output).map_err(AppError::from)
341}
342
343pub async fn get_lexicon_favicons(
344 collections: Vec<String>, app: &AppHandle,
345) -> Result<HashMap<String, Option<String>>> {
346 let client = match reqwest::Client::builder().timeout(FAVICON_FETCH_TIMEOUT).build() {
347 Ok(client) => client,
348 Err(error) => {
349 log::warn!("failed to construct favicon client: {error}");
350 return Ok(collections.into_iter().map(|collection| (collection, None)).collect());
351 }
352 };
353 let cache_dir = match resolve_favicon_cache_dir(app) {
354 Ok(cache_dir) => Some(cache_dir),
355 Err(error) => {
356 log::warn!("failed to resolve explorer favicon cache directory: {error}");
357 None
358 }
359 };
360
361 let mut icons = HashMap::with_capacity(collections.len());
362
363 for collection in collections {
364 let icon = resolve_lexicon_favicon_data_url(&client, cache_dir.as_deref(), &collection).await;
365 icons.insert(collection, icon);
366 }
367
368 Ok(icons)
369}
370
371pub fn clear_lexicon_favicon_cache(app: &AppHandle) -> Result<()> {
372 let cache_dir = resolve_favicon_cache_dir(app)?;
373 clear_favicon_cache_dir(&cache_dir)
374}
375
376pub async fn emit_explorer_navigation(app: &AppHandle, raw: &str) -> Result<()> {
377 let target = resolve_input(raw.to_string()).await?;
378 app.emit(EXPLORER_NAVIGATION_EVENT, ExplorerNavigation { target })?;
379 Ok(())
380}
381
382fn public_client() -> ExplorerClient {
383 Agent::new(UnauthenticatedSession::new_public())
384}
385
386async fn client_for_base_uri(base_uri: &str) -> Result<ExplorerClient> {
387 let client = public_client();
388 let normalized = Uri::parse(base_uri)?;
389 client.set_base_uri(normalized.to_owned()).await;
390 Ok(client)
391}
392
393async fn client_for_repo(repo: &str) -> Result<ExplorerClient> {
394 match parse_at_identifier(repo)? {
395 AtIdentifier::Did(did) => client_for_repo_did(did.as_str()).await,
396 AtIdentifier::Handle(handle) => {
397 let did = resolve_handle_to_did(handle.as_str()).await?;
398 client_for_repo_did(&did).await
399 }
400 }
401}
402
403async fn client_for_repo_did(did: &str) -> Result<ExplorerClient> {
404 let metadata = resolve_repo_metadata(did).await?;
405 let pds_url = metadata
406 .pds_url
407 .ok_or_else(|| AppError::validation(format!("missing PDS endpoint for repo {did}")))?;
408 client_for_base_uri(&pds_url).await
409}
410
411async fn resolve_at_uri_input(input: &str) -> Result<ResolvedExplorerInput> {
412 let parsed = AtUri::new(input)?;
413 let (did, handle) = match parsed.authority() {
414 AtIdentifier::Did(did) => (did.to_string(), None),
415 AtIdentifier::Handle(handle) => (resolve_handle_to_did(handle.as_str()).await?, Some(handle.to_string())),
416 };
417 let repo_metadata = resolve_repo_metadata(&did).await?;
418
419 Ok(build_resolved_at_uri(
420 input,
421 &did,
422 handle.or(repo_metadata.handle),
423 repo_metadata.pds_url,
424 &parsed,
425 ))
426}
427
428async fn resolve_handle_input(input: &str) -> Result<ResolvedExplorerInput> {
429 let normalized_handle = normalize_handle(input).ok_or_else(|| AppError::validation("invalid handle input"))?;
430 let did = resolve_handle_to_did(&normalized_handle).await?;
431 let repo_metadata = resolve_repo_metadata(&did).await?;
432
433 Ok(ResolvedExplorerInput {
434 input: input.trim().to_string(),
435 input_kind: ExplorerInputKind::Handle,
436 target_kind: ExplorerTargetKind::Repo,
437 normalized_input: did.clone(),
438 uri: Some(format!("at://{did}")),
439 did: Some(did),
440 handle: repo_metadata.handle.or(Some(normalized_handle)),
441 pds_url: repo_metadata.pds_url,
442 collection: None,
443 rkey: None,
444 })
445}
446
447async fn resolve_did_input(input: &str) -> Result<ResolvedExplorerInput> {
448 let did = Did::new(input.trim())?.to_string();
449 let repo_metadata = resolve_repo_metadata(&did).await?;
450
451 Ok(ResolvedExplorerInput {
452 input: input.trim().to_string(),
453 input_kind: ExplorerInputKind::Did,
454 target_kind: ExplorerTargetKind::Repo,
455 normalized_input: did.clone(),
456 uri: Some(format!("at://{did}")),
457 did: Some(did),
458 handle: repo_metadata.handle,
459 pds_url: repo_metadata.pds_url,
460 collection: None,
461 rkey: None,
462 })
463}
464
465async fn describe_repo_output(
466 repo: &str,
467) -> Result<jacquard::api::com_atproto::repo::describe_repo::DescribeRepoOutput<'static>> {
468 let client = client_for_repo(repo).await?;
469 client
470 .send(DescribeRepo::new().repo(parse_at_identifier(repo)?).build())
471 .await
472 .map_err(|error| AppError::validation(format!("describeRepo request failed: {error}")))?
473 .into_output()
474 .map_err(|error| AppError::validation(format!("describeRepo output failed: {error}")))
475 .map(IntoStatic::into_static)
476}
477
478#[derive(Debug, Clone, Default)]
479struct RepoMetadata {
480 handle: Option<String>,
481 pds_url: Option<String>,
482}
483
484async fn resolve_repo_metadata(did: &str) -> Result<RepoMetadata> {
485 let did_doc = resolve_did_document(did).await?;
486 Ok(repo_metadata_from_did_doc(&did_doc))
487}
488
489async fn resolve_did_document(did: &str) -> Result<DidDocument<'static>> {
490 let client = public_client();
491 let parsed_did = Did::new(did)?.into_static();
492
493 client
494 .resolve_did_doc(&parsed_did)
495 .await
496 .map_err(|error| AppError::validation(format!("resolveDid request failed: {error}")))?
497 .into_owned()
498 .map_err(|error| AppError::validation(format!("resolveDid output failed: {error}")))
499}
500
501fn repo_metadata_from_did_doc(did_doc: &DidDocument<'_>) -> RepoMetadata {
502 let handle = did_doc.also_known_as.as_ref().and_then(|aliases| {
503 aliases.iter().find_map(|alias| {
504 let candidate = alias.as_ref().strip_prefix("at://")?;
505 Handle::new(candidate).ok().map(|handle| handle.to_string())
506 })
507 });
508 let pds_url = did_doc
509 .pds_endpoint()
510 .and_then(|uri| normalize_pds_url(uri.as_str()).ok());
511
512 RepoMetadata { handle, pds_url }
513}
514
515async fn resolve_handle_to_did(handle: &str) -> Result<String> {
516 let client = public_client();
517 client
518 .send(ResolveHandle::new().handle(Handle::new(handle)?.into_static()).build())
519 .await
520 .map_err(|error| AppError::validation(format!("resolveHandle request failed: {error}")))?
521 .into_output()
522 .map_err(|error| AppError::validation(format!("resolveHandle output failed: {error}")))
523 .map(|output| output.did.to_string())
524}
525
526fn build_resolved_at_uri(
527 input: &str, did: &str, handle: Option<String>, pds_url: Option<String>, parsed: &AtUri<'_>,
528) -> ResolvedExplorerInput {
529 let collection = parsed.collection().map(|collection| collection.to_string());
530 let rkey = parsed.rkey().map(|rkey| rkey.as_ref().to_string());
531 let target_kind = match (collection.as_ref(), rkey.as_ref()) {
532 (Some(_), Some(_)) => ExplorerTargetKind::Record,
533 (Some(_), None) => ExplorerTargetKind::Collection,
534 (None, None) => ExplorerTargetKind::Repo,
535 (None, Some(_)) => ExplorerTargetKind::Repo,
536 };
537 let normalized_input = canonical_at_uri(did, collection.as_deref(), rkey.as_deref());
538
539 ResolvedExplorerInput {
540 input: input.trim().to_string(),
541 input_kind: ExplorerInputKind::AtUri,
542 target_kind,
543 normalized_input: normalized_input.clone(),
544 uri: Some(normalized_input),
545 did: Some(did.to_string()),
546 handle,
547 pds_url,
548 collection,
549 rkey,
550 }
551}
552
553fn detect_input_kind(input: &str) -> Result<ExplorerInputKind> {
554 let trimmed = input.trim();
555
556 if trimmed.starts_with("at://") {
557 normalize_at_uri(trimmed)?;
558 return Ok(ExplorerInputKind::AtUri);
559 }
560
561 if normalize_handle(trimmed).is_some() {
562 return Ok(ExplorerInputKind::Handle);
563 }
564
565 if Did::new(trimmed).is_ok() {
566 return Ok(ExplorerInputKind::Did);
567 }
568
569 if looks_like_http_url(trimmed) {
570 normalize_pds_url(trimmed)?;
571 return Ok(ExplorerInputKind::PdsUrl);
572 }
573
574 Err(AppError::validation(
575 "explorer input must be an at:// URI, handle, DID, or PDS URL",
576 ))
577}
578
579fn normalize_at_uri(input: &str) -> Result<String> {
580 Ok(AtUri::new(input)?.to_string())
581}
582
583fn normalize_handle(input: &str) -> Option<String> {
584 let trimmed = input.trim().trim_start_matches('@');
585 if trimmed.is_empty() {
586 return None;
587 }
588
589 Handle::new(trimmed).ok().map(|handle| handle.to_string())
590}
591
592fn looks_like_http_url(input: &str) -> bool {
593 input.starts_with("http://") || input.starts_with("https://")
594}
595
596fn normalize_pds_url(input: &str) -> Result<String> {
597 let mut url =
598 reqwest::Url::parse(input.trim()).map_err(|error| AppError::validation(format!("invalid PDS URL: {error}")))?;
599
600 match url.scheme() {
601 "http" | "https" => {}
602 scheme => return Err(AppError::validation(format!("unsupported PDS URL scheme: {scheme}"))),
603 }
604
605 if url.host_str().is_none() {
606 return Err(AppError::validation("PDS URL must include a host"));
607 }
608
609 url.set_path("");
610 url.set_query(None);
611 url.set_fragment(None);
612
613 Ok(url.to_string().trim_end_matches('/').to_string())
614}
615
616fn canonical_at_uri(did: &str, collection: Option<&str>, rkey: Option<&str>) -> String {
617 match (collection, rkey) {
618 (Some(collection), Some(rkey)) => format!("at://{did}/{collection}/{rkey}"),
619 (Some(collection), None) => format!("at://{did}/{collection}"),
620 _ => format!("at://{did}"),
621 }
622}
623
624fn parse_at_identifier(value: &str) -> Result<AtIdentifier<'static>> {
625 AtIdentifier::new(value)
626 .map(IntoStatic::into_static)
627 .map_err(AppError::from)
628}
629
630fn parse_collection(collection: &str) -> Result<Nsid<'static>> {
631 Nsid::new(collection)
632 .map(IntoStatic::into_static)
633 .map_err(AppError::from)
634}
635
636fn parse_record_key(rkey: &str) -> Result<RecordKey<Rkey<'static>>> {
637 RecordKey::any(rkey)
638 .map(IntoStatic::into_static)
639 .map_err(AppError::from)
640}
641
642fn parse_cid(cid: &str) -> Result<Cid<'static>> {
643 let trimmed = cid.trim();
644 if trimmed.is_empty() {
645 return Err(AppError::validation("CID cannot be empty"));
646 }
647
648 let parsed = Cid::str(trimmed).into_static();
649 parsed
650 .to_ipld()
651 .map_err(|error| AppError::validation(format!("invalid CID: {error}")))?;
652 Ok(parsed)
653}
654
655fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result<PathBuf> {
656 let mut app_data_dir = app
657 .path()
658 .app_data_dir()
659 .map_err(|error| AppError::PathResolve(error.to_string()))?;
660 app_data_dir.push("exports");
661 app_data_dir.push(repo_car_filename(did));
662 Ok(app_data_dir)
663}
664
665fn resolve_favicon_cache_dir(app: &AppHandle) -> Result<PathBuf> {
666 let mut cache_dir = app
667 .path()
668 .app_cache_dir()
669 .map_err(|error| AppError::PathResolve(error.to_string()))?;
670 cache_dir.push("explorer");
671 cache_dir.push("favicons");
672 Ok(cache_dir)
673}
674
675fn resolve_blob_temp_dir(app: &AppHandle) -> Result<PathBuf> {
676 let mut cache_dir = app
677 .path()
678 .app_cache_dir()
679 .map_err(|error| AppError::PathResolve(error.to_string()))?;
680 cache_dir.push("explorer");
681 cache_dir.push("temp-blob");
682 Ok(cache_dir)
683}
684
685fn resolve_blob_temp_path(app: &AppHandle, did: &str, cid: &str, extension: Option<&str>) -> Result<PathBuf> {
686 let mut cache_dir = resolve_blob_temp_dir(app)?;
687 let safe_extension = sanitize_blob_extension(extension).unwrap_or_else(|| "bin".to_string());
688 let file_name = format!(
689 "{}_{}_{}.{}",
690 sanitize_did_for_filename(did),
691 sanitize_cid_for_filename(cid),
692 Uuid::new_v4(),
693 safe_extension
694 );
695 cache_dir.push(file_name);
696 Ok(cache_dir)
697}
698
699fn sanitize_cid_for_filename(cid: &str) -> String {
700 cid.chars()
701 .map(|character| match character {
702 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => character,
703 _ => '_',
704 })
705 .collect()
706}
707
708fn sanitize_blob_extension(extension: Option<&str>) -> Option<String> {
709 let normalized = extension
710 .map(str::trim)
711 .filter(|value| !value.is_empty())
712 .map(|value| value.trim_start_matches('.').to_ascii_lowercase())?;
713 if normalized.is_empty() || normalized.len() > 12 {
714 return None;
715 }
716 if normalized.chars().all(|character| character.is_ascii_alphanumeric()) {
717 Some(normalized)
718 } else {
719 None
720 }
721}
722
723fn is_path_within_directory(path: &std::path::Path, directory: &std::path::Path) -> bool {
724 path.starts_with(directory)
725}
726
727fn clear_favicon_cache_dir(cache_dir: &std::path::Path) -> Result<()> {
728 if !cache_dir.exists() {
729 return Ok(());
730 }
731
732 std::fs::remove_dir_all(cache_dir)?;
733 Ok(())
734}
735
736async fn resolve_lexicon_favicon_data_url(
737 client: &reqwest::Client, cache_dir: Option<&std::path::Path>, collection: &str,
738) -> Option<String> {
739 let hosts = lexicon_favicon_hosts(collection)
740 .map_err(|error| {
741 log::warn!("failed to derive favicon hosts for {collection}: {error}");
742 error
743 })
744 .ok()?;
745
746 for host in hosts {
747 if let Some(cache_dir) = cache_dir {
748 if let Some(cached) = read_cached_favicon_data_url(cache_dir, &host) {
749 return Some(cached);
750 }
751 }
752
753 if let Some(icon) = fetch_host_favicon(client, &host).await {
754 if let Some(cache_dir) = cache_dir {
755 write_cached_favicon(cache_dir, &host, &icon);
756 }
757 return Some(icon.data_url);
758 }
759 }
760
761 None
762}
763
764async fn fetch_host_favicon(client: &reqwest::Client, host: &str) -> Option<CachedFavicon> {
765 let favicon_url = format!("https://{host}/favicon.ico");
766 if let Some(icon) = fetch_favicon_from_url(client, &favicon_url).await {
767 return Some(icon);
768 }
769
770 let root_url = format!("https://{host}/");
771 let html = match fetch_html_document(client, &root_url).await {
772 Some(html) => html,
773 None => return None,
774 };
775 let base_url = match reqwest::Url::parse(&root_url) {
776 Ok(url) => url,
777 Err(error) => {
778 log::warn!("failed to parse root favicon fallback URL {root_url}: {error}");
779 return None;
780 }
781 };
782
783 for candidate_url in extract_favicon_urls(&html, &base_url) {
784 if let Some(icon) = fetch_favicon_from_url(client, candidate_url.as_str()).await {
785 return Some(icon);
786 }
787 }
788
789 None
790}
791
792async fn fetch_favicon_from_url(client: &reqwest::Client, favicon_url: &str) -> Option<CachedFavicon> {
793 let response = match client.get(favicon_url).send().await {
794 Ok(response) => response,
795 Err(error) => {
796 log::warn!("failed to fetch favicon from {favicon_url}: {error}");
797 return None;
798 }
799 };
800
801 if !response.status().is_success() {
802 log::warn!("favicon request to {favicon_url} returned {}", response.status());
803 return None;
804 }
805
806 let content_type = response
807 .headers()
808 .get(reqwest::header::CONTENT_TYPE)
809 .and_then(|value| value.to_str().ok())
810 .map(str::to_owned);
811 let bytes = match response.bytes().await {
812 Ok(bytes) => bytes.to_vec(),
813 Err(error) => {
814 log::warn!("failed to read favicon bytes from {favicon_url}: {error}");
815 return None;
816 }
817 };
818 let mime = match detect_favicon_mime(content_type.as_deref(), &bytes) {
819 Some(mime) => mime,
820 None => {
821 log::warn!("favicon response from {favicon_url} was not a recognized image");
822 return None;
823 }
824 };
825
826 Some(CachedFavicon {
827 bytes: bytes.clone(),
828 mime: mime.clone(),
829 data_url: format!("data:{mime};base64,{}", BASE64_STANDARD.encode(&bytes)),
830 })
831}
832
833async fn fetch_html_document(client: &reqwest::Client, root_url: &str) -> Option<String> {
834 let response = match client.get(root_url).send().await {
835 Ok(response) => response,
836 Err(error) => {
837 log::warn!("failed to fetch HTML fallback document from {root_url}: {error}");
838 return None;
839 }
840 };
841
842 if !response.status().is_success() {
843 log::warn!("HTML fallback request to {root_url} returned {}", response.status());
844 return None;
845 }
846
847 match response.text().await {
848 Ok(html) => Some(html),
849 Err(error) => {
850 log::warn!("failed to read HTML fallback document from {root_url}: {error}");
851 None
852 }
853 }
854}
855
856fn extract_favicon_urls(html: &str, base_url: &reqwest::Url) -> Vec<reqwest::Url> {
857 let resolved_base_url = resolve_html_base_url(html, base_url);
858 let lowercase = html.to_ascii_lowercase();
859 let mut cursor = 0;
860 let mut urls = Vec::new();
861
862 while let Some(relative_start) = lowercase[cursor..].find("<link") {
863 let start = cursor + relative_start;
864 let Some(relative_end) = lowercase[start..].find('>') else {
865 break;
866 };
867 let end = start + relative_end + 1;
868 let tag = &html[start..end];
869
870 let rel = extract_html_attribute(tag, "rel");
871 let href = extract_html_attribute(tag, "href");
872
873 if let (Some(rel), Some(href)) = (rel, href) {
874 if !rel_indicates_favicon(&rel) {
875 cursor = end;
876 continue;
877 }
878
879 if let Ok(url) = resolved_base_url.join(&href) {
880 if matches!(url.scheme(), "http" | "https") && !urls.iter().any(|existing| existing == &url) {
881 urls.push(url);
882 }
883 }
884 }
885
886 cursor = end;
887 }
888
889 urls
890}
891
892fn extract_html_attribute(tag: &str, attribute: &str) -> Option<String> {
893 let lowercase = tag.to_ascii_lowercase();
894 let lowercase_bytes = lowercase.as_bytes();
895 let bytes = tag.as_bytes();
896 let attribute_bytes = attribute.as_bytes();
897 let mut cursor = 0;
898
899 while cursor + attribute_bytes.len() <= lowercase_bytes.len() {
900 let start = lowercase[cursor..].find(attribute)? + cursor;
901 let before = start
902 .checked_sub(1)
903 .and_then(|index| lowercase_bytes.get(index))
904 .copied();
905 let after = lowercase_bytes.get(start + attribute_bytes.len()).copied();
906
907 let invalid_before = before
908 .is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':'));
909 let invalid_after =
910 after.is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':'));
911
912 if invalid_before || invalid_after {
913 cursor = start + attribute_bytes.len();
914 continue;
915 }
916
917 let mut value_start = start + attribute_bytes.len();
918 while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) {
919 value_start += 1;
920 }
921
922 if bytes.get(value_start) != Some(&b'=') {
923 cursor = start + attribute_bytes.len();
924 continue;
925 }
926
927 value_start += 1;
928 while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) {
929 value_start += 1;
930 }
931
932 let quote = *bytes.get(value_start)?;
933 if quote == b'"' || quote == b'\'' {
934 let value_end = tag[value_start + 1..].find(char::from(quote))?;
935 return Some(tag[value_start + 1..value_start + 1 + value_end].trim().to_string());
936 }
937
938 let value_end = tag[value_start..]
939 .find(|character: char| character.is_whitespace() || character == '>')
940 .unwrap_or(tag.len() - value_start);
941 return Some(tag[value_start..value_start + value_end].trim().to_string());
942 }
943
944 None
945}
946
947fn resolve_html_base_url(html: &str, request_url: &reqwest::Url) -> reqwest::Url {
948 let lowercase = html.to_ascii_lowercase();
949 let mut cursor = 0;
950
951 while let Some(relative_start) = lowercase[cursor..].find("<base") {
952 let start = cursor + relative_start;
953 let Some(relative_end) = lowercase[start..].find('>') else {
954 break;
955 };
956 let end = start + relative_end + 1;
957 let tag = &html[start..end];
958
959 if let Some(href) = extract_html_attribute(tag, "href") {
960 if let Ok(base_url) = request_url.join(&href) {
961 if matches!(base_url.scheme(), "http" | "https") {
962 return base_url;
963 }
964 }
965 }
966
967 cursor = end;
968 }
969
970 request_url.clone()
971}
972
973fn rel_indicates_favicon(rel: &str) -> bool {
974 rel.to_ascii_lowercase().contains("icon")
975}
976
977fn lexicon_favicon_hosts(collection: &str) -> Result<Vec<String>> {
978 let domain_authority = parse_collection(collection)?.domain_authority().to_string();
979 let authority_labels: Vec<&str> = domain_authority.split('.').collect();
980 let mut hosts = Vec::new();
981
982 for (prefix, host) in LEXICON_FAVICON_HOST_OVERRIDES {
983 if collection.starts_with(prefix) && !hosts.iter().any(|candidate| candidate == host) {
984 hosts.push((*host).to_string());
985 }
986 }
987
988 if authority_labels.len() >= 2 {
989 let canonical_host = format!("{}.{}", authority_labels[1], authority_labels[0]);
990 if !hosts.iter().any(|candidate| candidate == &canonical_host) {
991 hosts.push(canonical_host);
992 }
993 }
994
995 Ok(hosts)
996}
997
998fn read_cached_favicon_data_url(cache_dir: &std::path::Path, host: &str) -> Option<String> {
999 let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host);
1000 let mime = match std::fs::read_to_string(&mime_path) {
1001 Ok(mime) => mime.trim().to_string(),
1002 Err(error) => {
1003 if mime_path.exists() {
1004 log::warn!("failed to read cached favicon mime for {host}: {error}");
1005 }
1006 return None;
1007 }
1008 };
1009 let bytes = match std::fs::read(&bytes_path) {
1010 Ok(bytes) => bytes,
1011 Err(error) => {
1012 if bytes_path.exists() {
1013 log::warn!("failed to read cached favicon bytes for {host}: {error}");
1014 }
1015 return None;
1016 }
1017 };
1018
1019 Some(format!("data:{mime};base64,{}", BASE64_STANDARD.encode(bytes)))
1020}
1021
1022fn write_cached_favicon(cache_dir: &std::path::Path, host: &str, icon: &CachedFavicon) {
1023 if let Err(error) = std::fs::create_dir_all(cache_dir) {
1024 log::warn!(
1025 "failed to create favicon cache directory {}: {error}",
1026 cache_dir.display()
1027 );
1028 return;
1029 }
1030
1031 let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host);
1032
1033 if let Err(error) = std::fs::write(&bytes_path, &icon.bytes) {
1034 log::warn!("failed to write cached favicon bytes for {host}: {error}");
1035 return;
1036 }
1037
1038 if let Err(error) = std::fs::write(&mime_path, &icon.mime) {
1039 log::warn!("failed to write cached favicon mime for {host}: {error}");
1040 }
1041}
1042
1043fn favicon_cache_paths(cache_dir: &std::path::Path, host: &str) -> (PathBuf, PathBuf) {
1044 let safe_host = sanitize_host_for_filename(host);
1045 (
1046 cache_dir.join(format!("{safe_host}.bin")),
1047 cache_dir.join(format!("{safe_host}.mime")),
1048 )
1049}
1050
1051fn sanitize_host_for_filename(host: &str) -> String {
1052 host.chars()
1053 .map(|character| match character {
1054 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => character,
1055 _ => '_',
1056 })
1057 .collect()
1058}
1059
1060fn detect_favicon_mime(content_type: Option<&str>, bytes: &[u8]) -> Option<String> {
1061 if let Some(content_type) = content_type {
1062 let mime = content_type.split(';').next()?.trim().to_ascii_lowercase();
1063 if mime.starts_with("image/") {
1064 return Some(mime);
1065 }
1066 }
1067
1068 if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
1069 return Some("image/png".to_string());
1070 }
1071
1072 if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
1073 return Some("image/jpeg".to_string());
1074 }
1075
1076 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
1077 return Some("image/gif".to_string());
1078 }
1079
1080 if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) {
1081 return Some("image/x-icon".to_string());
1082 }
1083
1084 if String::from_utf8_lossy(bytes).contains("<svg") {
1085 return Some("image/svg+xml".to_string());
1086 }
1087
1088 None
1089}
1090
1091#[derive(Debug, Clone)]
1092struct CachedFavicon {
1093 bytes: Vec<u8>,
1094 mime: String,
1095 data_url: String,
1096}
1097
1098fn repo_car_filename(did: &str) -> String {
1099 format!("{}.car", sanitize_did_for_filename(did))
1100}
1101
1102fn sanitize_did_for_filename(did: &str) -> String {
1103 did.chars()
1104 .map(|character| match character {
1105 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => character,
1106 _ => '_',
1107 })
1108 .collect()
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::{
1114 build_resolved_at_uri, canonical_at_uri, clear_favicon_cache_dir, detect_favicon_mime, detect_input_kind,
1115 extract_favicon_urls, extract_html_attribute, is_path_within_directory, lexicon_favicon_hosts,
1116 normalize_handle, normalize_pds_url, read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename,
1117 repo_metadata_from_did_doc, resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_blob_extension,
1118 sanitize_cid_for_filename, sanitize_did_for_filename, write_cached_favicon, CachedFavicon, ExplorerInputKind,
1119 ExplorerTargetKind,
1120 };
1121 use jacquard::types::aturi::AtUri;
1122 use jacquard::types::did_doc::DidDocument;
1123 use reqwest::Client;
1124 use std::fs;
1125 use std::time::Duration;
1126 use uuid::Uuid;
1127
1128 #[test]
1129 fn detects_all_supported_input_kinds() {
1130 assert_eq!(
1131 detect_input_kind("at://did:plc:alice/app.bsky.feed.post/123").expect("at uri should detect"),
1132 ExplorerInputKind::AtUri
1133 );
1134 assert_eq!(
1135 detect_input_kind("@alice.bsky.social").expect("handle should detect"),
1136 ExplorerInputKind::Handle
1137 );
1138 assert_eq!(
1139 detect_input_kind("did:plc:alice123").expect("did should detect"),
1140 ExplorerInputKind::Did
1141 );
1142 assert_eq!(
1143 detect_input_kind("https://pds.example.com/xrpc/com.atproto.server.describeServer")
1144 .expect("pds url should detect"),
1145 ExplorerInputKind::PdsUrl
1146 );
1147 }
1148
1149 #[test]
1150 fn normalizes_handles_and_pds_urls() {
1151 assert_eq!(
1152 normalize_handle("@alice.bsky.social").expect("handle should normalize"),
1153 "alice.bsky.social"
1154 );
1155 assert_eq!(
1156 normalize_pds_url("https://pds.example.com/xrpc/com.atproto.server.describeServer?foo=bar#hash")
1157 .expect("pds url should normalize"),
1158 "https://pds.example.com"
1159 );
1160 }
1161
1162 #[test]
1163 fn canonicalizes_at_uri_targets() {
1164 assert_eq!(canonical_at_uri("did:plc:alice", None, None), "at://did:plc:alice");
1165 assert_eq!(
1166 canonical_at_uri("did:plc:alice", Some("app.bsky.feed.post"), None),
1167 "at://did:plc:alice/app.bsky.feed.post"
1168 );
1169 assert_eq!(
1170 canonical_at_uri("did:plc:alice", Some("app.bsky.feed.post"), Some("abc123")),
1171 "at://did:plc:alice/app.bsky.feed.post/abc123"
1172 );
1173 }
1174
1175 #[test]
1176 fn extracts_repo_metadata_from_did_documents() {
1177 let did_doc: DidDocument<'_> = serde_json::from_str(
1178 r##"{
1179 "id": "did:plc:alice",
1180 "alsoKnownAs": ["at://alice.bsky.social"],
1181 "service": [
1182 {
1183 "id": "#pds",
1184 "type": "AtprotoPersonalDataServer",
1185 "serviceEndpoint": {
1186 "url": "https://pds.object.example.com/xrpc"
1187 }
1188 }
1189 ]
1190 }"##,
1191 )
1192 .expect("did document should parse");
1193
1194 let metadata = repo_metadata_from_did_doc(&did_doc);
1195
1196 assert_eq!(metadata.handle, Some("alice.bsky.social".to_string()));
1197 assert_eq!(metadata.pds_url, Some("https://pds.object.example.com".to_string()));
1198 }
1199
1200 #[test]
1201 fn repo_car_filenames_are_filesystem_safe() {
1202 assert_eq!(sanitize_did_for_filename("did:plc:alice-123"), "did_plc_alice-123");
1203 assert_eq!(repo_car_filename("did:plc:alice-123"), "did_plc_alice-123.car");
1204 }
1205
1206 #[test]
1207 fn sanitizes_blob_filename_inputs() {
1208 assert_eq!(sanitize_cid_for_filename("bafy/beih?123"), "bafy_beih_123");
1209 assert_eq!(sanitize_blob_extension(Some(".mp4")), Some("mp4".to_string()));
1210 assert_eq!(sanitize_blob_extension(Some("webm")), Some("webm".to_string()));
1211 assert_eq!(sanitize_blob_extension(Some("m3u8?foo")), None);
1212 assert_eq!(sanitize_blob_extension(Some(" ")), None);
1213 }
1214
1215 #[test]
1216 fn verifies_path_containment() {
1217 let base = std::path::Path::new("/tmp/base");
1218 let nested = std::path::Path::new("/tmp/base/nested/file.bin");
1219 let outside = std::path::Path::new("/tmp/other/file.bin");
1220
1221 assert!(is_path_within_directory(nested, base));
1222 assert!(!is_path_within_directory(outside, base));
1223 }
1224
1225 #[test]
1226 fn derives_candidate_hosts_from_lexicon_nsids() {
1227 assert_eq!(
1228 lexicon_favicon_hosts("app.bsky.feed.post").expect("nsid should parse"),
1229 vec!["bsky.app".to_string()]
1230 );
1231 assert_eq!(
1232 lexicon_favicon_hosts("sh.tangled.repo.issue").expect("override nsid should parse"),
1233 vec!["tangled.org".to_string(), "tangled.sh".to_string()]
1234 );
1235 assert!(lexicon_favicon_hosts("not-a-valid-nsid").is_err());
1236 }
1237
1238 #[test]
1239 fn detects_supported_favicon_mime_types() {
1240 assert_eq!(
1241 detect_favicon_mime(Some("image/vnd.microsoft.icon"), &[0x00, 0x00, 0x01, 0x00]),
1242 Some("image/vnd.microsoft.icon".to_string())
1243 );
1244 assert_eq!(
1245 detect_favicon_mime(None, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A]),
1246 Some("image/png".to_string())
1247 );
1248 assert!(detect_favicon_mime(Some("text/html"), b"<html></html>").is_none());
1249 }
1250
1251 #[test]
1252 fn extracts_favicon_urls_from_html_link_elements() {
1253 let base_url = reqwest::Url::parse("https://bsky.app/").expect("base URL should parse");
1254 let urls = extract_favicon_urls(
1255 r#"
1256 <html>
1257 <head>
1258 <link rel="stylesheet" href="/styles.css">
1259 <link rel="icon" href="/favicon-32.png">
1260 <link rel="shortcut icon" href="https://cdn.example.com/favicon.ico">
1261 <link rel="apple-touch-icon" href="/apple-touch.png">
1262 </head>
1263 </html>
1264 "#,
1265 &base_url,
1266 );
1267
1268 assert_eq!(
1269 urls,
1270 vec![
1271 reqwest::Url::parse("https://bsky.app/favicon-32.png").expect("relative favicon URL should resolve"),
1272 reqwest::Url::parse("https://cdn.example.com/favicon.ico").expect("absolute favicon URL should parse"),
1273 reqwest::Url::parse("https://bsky.app/apple-touch.png")
1274 .expect("apple touch favicon URL should resolve"),
1275 ]
1276 );
1277 }
1278
1279 #[test]
1280 fn resolves_relative_favicon_urls_like_tangled() {
1281 let base_url = reqwest::Url::parse("https://tangled.org/").expect("base URL should parse");
1282 let urls = extract_favicon_urls(
1283 r#"<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml">"#,
1284 &base_url,
1285 );
1286
1287 assert_eq!(
1288 urls,
1289 vec![reqwest::Url::parse("https://tangled.org/static/logos/dolly.svg")
1290 .expect("tangled favicon URL should resolve")]
1291 );
1292 }
1293
1294 #[test]
1295 fn extracts_html_attributes_with_whitespace_and_quotes() {
1296 let tag = r#"<link rel = "icon" href = '/static/logos/dolly.svg' type="image/svg+xml">"#;
1297
1298 assert_eq!(extract_html_attribute(tag, "rel"), Some("icon".to_string()));
1299 assert_eq!(
1300 extract_html_attribute(tag, "href"),
1301 Some("/static/logos/dolly.svg".to_string())
1302 );
1303 assert_eq!(extract_html_attribute(tag, "type"), Some("image/svg+xml".to_string()));
1304 }
1305
1306 #[test]
1307 fn honors_html_base_href_when_resolving_favicon_urls() {
1308 let request_url = reqwest::Url::parse("https://example.com/app/").expect("request URL should parse");
1309 let html = r#"
1310 <head>
1311 <base href="https://cdn.example.com/assets/">
1312 <link rel="icon" href="favicons/app.svg">
1313 </head>
1314 "#;
1315
1316 assert_eq!(
1317 resolve_html_base_url(html, &request_url),
1318 reqwest::Url::parse("https://cdn.example.com/assets/").expect("base href should resolve")
1319 );
1320 assert_eq!(
1321 extract_favicon_urls(html, &request_url),
1322 vec![reqwest::Url::parse("https://cdn.example.com/assets/favicons/app.svg")
1323 .expect("favicon URL should resolve against base href")]
1324 );
1325 }
1326
1327 #[test]
1328 fn recognizes_common_favicon_rel_patterns() {
1329 assert!(rel_indicates_favicon("icon"));
1330 assert!(rel_indicates_favicon("shortcut icon"));
1331 assert!(rel_indicates_favicon("apple-touch-icon"));
1332 assert!(rel_indicates_favicon("mask-icon"));
1333 assert!(!rel_indicates_favicon("stylesheet"));
1334 }
1335
1336 #[tokio::test]
1337 async fn returns_cached_lexicon_favicon_without_fetching() {
1338 let cache_dir = create_temp_cache_dir();
1339 write_cached_favicon(
1340 &cache_dir,
1341 "bsky.app",
1342 &CachedFavicon {
1343 bytes: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A],
1344 mime: "image/png".to_string(),
1345 data_url: "data:image/png;base64,ignored".to_string(),
1346 },
1347 );
1348
1349 let client = Client::builder()
1350 .timeout(Duration::from_millis(200))
1351 .build()
1352 .expect("client should build");
1353 let icon = resolve_lexicon_favicon_data_url(&client, Some(cache_dir.as_path()), "app.bsky.feed.post").await;
1354
1355 assert_eq!(icon, read_cached_favicon_data_url(&cache_dir, "bsky.app"),);
1356
1357 fs::remove_dir_all(cache_dir).expect("temporary cache directory should be removed");
1358 }
1359
1360 #[tokio::test]
1361 async fn failed_favicon_fetches_return_none() {
1362 let client = Client::builder()
1363 .timeout(Duration::from_millis(200))
1364 .build()
1365 .expect("client should build");
1366
1367 assert!(super::fetch_host_favicon(&client, "127.0.0.1:9").await.is_none());
1368 }
1369
1370 #[test]
1371 fn clears_favicon_cache_directory_contents() {
1372 let cache_dir = create_temp_cache_dir();
1373 fs::write(cache_dir.join("icon.bin"), [1_u8, 2_u8, 3_u8]).expect("test cache file should be written");
1374 fs::write(cache_dir.join("icon.mime"), "image/png").expect("test cache mime should be written");
1375
1376 clear_favicon_cache_dir(&cache_dir).expect("cache directory should clear");
1377
1378 assert!(!cache_dir.exists());
1379 }
1380
1381 #[test]
1382 fn at_uri_parser_distinguishes_repo_collection_and_record_levels() {
1383 let repo_uri = AtUri::new("at://did:plc:alice").expect("repo uri should parse");
1384 let collection_uri = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse");
1385 let record_uri = AtUri::new("at://did:plc:alice/app.bsky.feed.post/abc123").expect("record uri should parse");
1386
1387 assert!(repo_uri.collection().is_none());
1388 assert!(repo_uri.rkey().is_none());
1389 assert_eq!(
1390 collection_uri.collection().expect("collection should exist").as_str(),
1391 "app.bsky.feed.post"
1392 );
1393 assert!(collection_uri.rkey().is_none());
1394 assert_eq!(record_uri.rkey().expect("rkey should exist").as_ref(), "abc123");
1395 }
1396
1397 #[test]
1398 fn build_resolved_at_uri_sets_expected_target_levels() {
1399 let repo = AtUri::new("at://did:plc:alice").expect("repo uri should parse");
1400 let collection = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse");
1401 let record = AtUri::new("at://did:plc:alice/app.bsky.feed.post/abc123").expect("record uri should parse");
1402
1403 assert_eq!(
1404 build_resolved_at_uri("at://did:plc:alice", "did:plc:alice", None, None, &repo).target_kind,
1405 ExplorerTargetKind::Repo
1406 );
1407 assert_eq!(
1408 build_resolved_at_uri(
1409 "at://did:plc:alice/app.bsky.feed.post",
1410 "did:plc:alice",
1411 None,
1412 None,
1413 &collection
1414 )
1415 .target_kind,
1416 ExplorerTargetKind::Collection
1417 );
1418 assert_eq!(
1419 build_resolved_at_uri(
1420 "at://did:plc:alice/app.bsky.feed.post/abc123",
1421 "did:plc:alice",
1422 None,
1423 None,
1424 &record
1425 )
1426 .target_kind,
1427 ExplorerTargetKind::Record
1428 );
1429 }
1430
1431 fn create_temp_cache_dir() -> std::path::PathBuf {
1432 let path = std::env::temp_dir().join(format!("lazurite-explorer-cache-{}", Uuid::new_v4()));
1433 fs::create_dir_all(&path).expect("temporary cache directory should be created");
1434 path
1435 }
1436}