BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1436 lines 50 kB view raw
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}