atproto explorer pdsls.dev
atproto tool
at v1.1.0 24 kB view raw
1import { Client, CredentialManager } from "@atcute/client"; 2import { parseDidKey, parsePublicMultikey } from "@atcute/crypto"; 3import { DidDocument } from "@atcute/identity"; 4import { ActorIdentifier, Did, Handle, Nsid } from "@atcute/lexicons"; 5import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 6import { 7 createEffect, 8 createResource, 9 createSignal, 10 ErrorBoundary, 11 For, 12 onMount, 13 Show, 14 Suspense, 15} from "solid-js"; 16import { createStore } from "solid-js/store"; 17import { Backlinks } from "../components/backlinks.jsx"; 18import { 19 ActionMenu, 20 CopyMenu, 21 DropdownMenu, 22 MenuProvider, 23 MenuSeparator, 24 NavMenu, 25} from "../components/dropdown.jsx"; 26import { setPDS } from "../components/navbar.jsx"; 27import { 28 addNotification, 29 removeNotification, 30 updateNotification, 31} from "../components/notification.jsx"; 32import { TextInput } from "../components/text-input.jsx"; 33import Tooltip from "../components/tooltip.jsx"; 34import { 35 didDocCache, 36 labelerCache, 37 resolveHandle, 38 resolveLexiconAuthority, 39 resolvePDS, 40 validateHandle, 41} from "../utils/api.js"; 42import { BlobView } from "./blob.jsx"; 43import { PlcLogView } from "./logs.jsx"; 44 45export const RepoView = () => { 46 const params = useParams(); 47 const location = useLocation(); 48 const navigate = useNavigate(); 49 const [error, setError] = createSignal<string>(); 50 const [downloading, setDownloading] = createSignal(false); 51 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 52 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 53 const [filter, setFilter] = createSignal<string>(); 54 const [showFilter, setShowFilter] = createSignal(false); 55 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 56 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 57 let rpc: Client; 58 let pds: string; 59 const did = params.repo!; 60 61 // Handle scrolling to a collection group when hash is like #collections:app.bsky 62 createEffect(() => { 63 const hash = location.hash; 64 if (hash.startsWith("#collections:")) { 65 const authority = hash.slice(13); 66 requestAnimationFrame(() => { 67 const element = document.getElementById(`collection-${authority}`); 68 if (element) element.scrollIntoView({ behavior: "instant", block: "start" }); 69 }); 70 } 71 }); 72 73 const RepoTab = (props: { 74 tab: "collections" | "backlinks" | "identity" | "blobs" | "logs"; 75 label: string; 76 }) => { 77 const isActive = () => { 78 if (!location.hash) { 79 if (!error() && props.tab === "collections") return true; 80 if (!!error() && props.tab === "identity") return true; 81 return false; 82 } 83 if (props.tab === "collections") 84 return location.hash === "#collections" || location.hash.startsWith("#collections:"); 85 return location.hash === `#${props.tab}`; 86 }; 87 88 return ( 89 <A class="flex items-center" href={`/at://${params.repo}#${props.tab}`}> 90 <span 91 classList={{ 92 "flex items-center border-b-2": true, 93 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 94 !isActive(), 95 }} 96 > 97 {props.label} 98 </span> 99 </A> 100 ); 101 }; 102 103 const getRotationKeys = async () => { 104 const res = await fetch( 105 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`, 106 ); 107 const json = await res.json(); 108 setRotationKeys(json.rotationKeys ?? []); 109 }; 110 111 const fetchRepo = async () => { 112 try { 113 pds = await resolvePDS(did); 114 } catch { 115 if (!did.startsWith("did:")) { 116 try { 117 const did = await resolveHandle(params.repo as Handle); 118 navigate(location.pathname.replace(params.repo!, did)); 119 return; 120 } catch { 121 try { 122 const nsid = params.repo as Nsid; 123 const res = await resolveLexiconAuthority(nsid); 124 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 125 return; 126 } catch { 127 navigate(`/${did}`); 128 return; 129 } 130 } 131 } 132 } 133 setDidDoc(didDocCache[did] as DidDocument); 134 getRotationKeys(); 135 136 validateHandles(); 137 138 if (!pds) { 139 setError("Missing PDS"); 140 setPDS("Missing PDS"); 141 return {}; 142 } 143 144 rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 145 const res = await rpc.get("com.atproto.repo.describeRepo", { 146 params: { repo: did as ActorIdentifier }, 147 }); 148 if (res.ok) { 149 const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 150 res.data.collections.forEach((c) => { 151 const nsid = c.split("."); 152 if (nsid.length > 2) { 153 const authority = `${nsid[0]}.${nsid[1]}`; 154 collections[authority] = { 155 nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 156 hidden: false, 157 }; 158 } 159 }); 160 setNsids(collections); 161 } else { 162 console.error(res.data.error); 163 switch (res.data.error) { 164 case "RepoDeactivated": 165 setError("Deactivated"); 166 break; 167 case "RepoTakendown": 168 setError("Takendown"); 169 break; 170 default: 171 setError("Unreachable"); 172 } 173 } 174 175 return res.data; 176 }; 177 178 const [repo] = createResource(fetchRepo); 179 180 const validateHandles = async () => { 181 for (const alias of didDoc()?.alsoKnownAs ?? []) { 182 if (alias.startsWith("at://")) 183 setValidHandles( 184 alias, 185 await validateHandle(alias.replace("at://", "") as Handle, did as Did), 186 ); 187 } 188 }; 189 190 const downloadRepo = async () => { 191 let notificationId: string | null = null; 192 193 try { 194 setDownloading(true); 195 notificationId = addNotification({ 196 message: "Downloading repository...", 197 progress: 0, 198 total: 0, 199 type: "info", 200 }); 201 202 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 203 if (!response.ok) { 204 throw new Error(`HTTP error status: ${response.status}`); 205 } 206 207 const contentLength = response.headers.get("content-length"); 208 const total = contentLength ? parseInt(contentLength, 10) : 0; 209 let loaded = 0; 210 211 const reader = response.body?.getReader(); 212 const chunks: Uint8Array[] = []; 213 214 if (reader) { 215 while (true) { 216 const { done, value } = await reader.read(); 217 if (done) break; 218 219 chunks.push(value); 220 loaded += value.length; 221 222 if (total > 0) { 223 const progress = Math.round((loaded / total) * 100); 224 updateNotification(notificationId, { 225 progress, 226 total, 227 }); 228 } else { 229 const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; 230 updateNotification(notificationId, { 231 progress: progressMB, 232 total: 0, 233 }); 234 } 235 } 236 } 237 238 const blob = new Blob(chunks); 239 const url = window.URL.createObjectURL(blob); 240 const a = document.createElement("a"); 241 a.href = url; 242 a.download = `${did}-${new Date().toISOString()}.car`; 243 document.body.appendChild(a); 244 a.click(); 245 246 window.URL.revokeObjectURL(url); 247 document.body.removeChild(a); 248 249 updateNotification(notificationId, { 250 message: "Repository downloaded successfully", 251 type: "success", 252 progress: undefined, 253 }); 254 setTimeout(() => { 255 if (notificationId) removeNotification(notificationId); 256 }, 3000); 257 } catch (error) { 258 console.error("Download failed:", error); 259 if (notificationId) { 260 updateNotification(notificationId, { 261 message: "Download failed", 262 type: "error", 263 progress: undefined, 264 }); 265 setTimeout(() => { 266 if (notificationId) removeNotification(notificationId); 267 }, 5000); 268 } 269 } 270 setDownloading(false); 271 }; 272 273 return ( 274 <Show when={repo()}> 275 <div class="flex w-full flex-col gap-3 wrap-break-word"> 276 <div class="dark:shadow-dark-700 dark:bg-dark-300 flex justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-xs dark:border-neutral-700"> 277 <div class="ml-1 flex gap-2 text-xs sm:gap-4 sm:text-sm"> 278 <Show when={!error()}> 279 <RepoTab tab="collections" label="Collections" /> 280 </Show> 281 <RepoTab tab="identity" label="Identity" /> 282 <Show when={did.startsWith("did:plc")}> 283 <RepoTab tab="logs" label="Logs" /> 284 </Show> 285 <Show when={!error()}> 286 <RepoTab tab="blobs" label="Blobs" /> 287 </Show> 288 <RepoTab tab="backlinks" label="Backlinks" /> 289 </div> 290 <div class="flex gap-0.5"> 291 <Show when={error() && error() !== "Missing PDS"}> 292 <div class="flex items-center gap-1 text-red-500 dark:text-red-400"> 293 <span class="iconify lucide--alert-triangle"></span> 294 <span>{error()}</span> 295 </div> 296 </Show> 297 <Show when={!error() && (!location.hash || location.hash.startsWith("#collections"))}> 298 <Tooltip text="Filter collections"> 299 <button 300 class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 301 onClick={() => setShowFilter(!showFilter())} 302 > 303 <span class="iconify lucide--filter"></span> 304 </button> 305 </Tooltip> 306 </Show> 307 <MenuProvider> 308 <DropdownMenu 309 icon="lucide--ellipsis-vertical" 310 buttonClass="rounded-sm p-1.5" 311 menuClass="top-9 p-2 text-sm" 312 > 313 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 314 <NavMenu 315 href={`/jetstream?dids=${params.repo}`} 316 label="Jetstream" 317 icon="lucide--radio-tower" 318 /> 319 <Show when={params.repo && params.repo in labelerCache}> 320 <NavMenu 321 href={`/labels?did=${params.repo}&uriPatterns=*`} 322 label="Labels" 323 icon="lucide--tag" 324 /> 325 </Show> 326 <Show when={error()?.length === 0 || error() === undefined}> 327 <ActionMenu 328 label="Export Repo" 329 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 330 onClick={() => downloadRepo()} 331 /> 332 </Show> 333 <MenuSeparator /> 334 <NavMenu 335 href={ 336 did.startsWith("did:plc") ? 337 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 338 : `https://${did.split("did:web:")[1]}/.well-known/did.json` 339 } 340 newTab 341 label="DID Document" 342 icon="lucide--external-link" 343 /> 344 <Show when={did.startsWith("did:plc")}> 345 <NavMenu 346 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`} 347 newTab 348 label="Audit Log" 349 icon="lucide--external-link" 350 /> 351 </Show> 352 </DropdownMenu> 353 </MenuProvider> 354 </div> 355 </div> 356 <div class="flex w-full flex-col gap-1 px-2"> 357 <Show when={location.hash === "#logs"}> 358 <ErrorBoundary 359 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 360 > 361 <Suspense 362 fallback={ 363 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 364 } 365 > 366 <PlcLogView did={did} /> 367 </Suspense> 368 </ErrorBoundary> 369 </Show> 370 <Show when={location.hash === "#backlinks"}> 371 <ErrorBoundary 372 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 373 > 374 <Suspense 375 fallback={ 376 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 377 } 378 > 379 <Backlinks target={did} /> 380 </Suspense> 381 </ErrorBoundary> 382 </Show> 383 <Show when={location.hash === "#blobs"}> 384 <ErrorBoundary 385 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 386 > 387 <Suspense 388 fallback={ 389 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 390 } 391 > 392 <BlobView pds={pds!} repo={did} /> 393 </Suspense> 394 </ErrorBoundary> 395 </Show> 396 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 397 <Show when={showFilter()}> 398 <TextInput 399 name="filter" 400 placeholder="Filter collections" 401 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 402 class="grow" 403 ref={(node) => { 404 onMount(() => node.focus()); 405 }} 406 /> 407 </Show> 408 <div class="flex flex-col text-sm wrap-anywhere" classList={{ "-mt-1": !showFilter() }}> 409 <For 410 each={Object.keys(nsids() ?? {}).filter((authority) => 411 filter() ? 412 authority.includes(filter()!) || 413 nsids()?.[authority].nsids.some((nsid) => 414 `${authority}.${nsid}`.includes(filter()!), 415 ) 416 : true, 417 )} 418 > 419 {(authority) => { 420 const reversedDomain = authority.split(".").reverse().join("."); 421 const [faviconLoaded, setFaviconLoaded] = createSignal(false); 422 423 const isHighlighted = () => location.hash === `#collections:${authority}`; 424 425 return ( 426 <div 427 id={`collection-${authority}`} 428 class="group flex items-start gap-2 rounded-lg p-1 transition-colors" 429 classList={{ 430 "dark:hover:bg-dark-200 hover:bg-neutral-200": !isHighlighted(), 431 "bg-blue-100 dark:bg-blue-500/25": isHighlighted(), 432 }} 433 > 434 <a 435 href={`#collections:${authority}`} 436 class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70" 437 > 438 <span class="absolute top-1/2 -left-5 flex -translate-y-1/2 items-center text-base opacity-0 transition-opacity group-hover:opacity-100"> 439 <span class="iconify lucide--link absolute -left-2 w-7"></span> 440 </span> 441 <Show when={!faviconLoaded()}> 442 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 443 </Show> 444 <img 445 src={ 446 ["bsky.app", "bsky.chat"].includes(reversedDomain) ? 447 "https://web-cdn.bsky.app/static/apple-touch-icon.png" 448 : `https://${reversedDomain}/favicon.ico` 449 } 450 alt={`${reversedDomain} favicon`} 451 class="h-4 w-4" 452 classList={{ hidden: !faviconLoaded() }} 453 onLoad={() => setFaviconLoaded(true)} 454 onError={() => setFaviconLoaded(false)} 455 /> 456 </a> 457 <div class="flex flex-1 flex-col"> 458 <For 459 each={nsids()?.[authority].nsids.filter((nsid) => 460 filter() ? `${authority}.${nsid}`.includes(filter()!) : true, 461 )} 462 > 463 {(nsid) => ( 464 <A 465 href={`/at://${did}/${authority}.${nsid}`} 466 class="hover:underline active:underline" 467 > 468 <span>{authority}</span> 469 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span> 470 </A> 471 )} 472 </For> 473 </div> 474 </div> 475 ); 476 }} 477 </For> 478 </div> 479 </Show> 480 <Show when={location.hash === "#identity" || (error() && !location.hash)}> 481 <Show when={didDoc()}> 482 {(didDocument) => ( 483 <div class="flex flex-col gap-2 wrap-anywhere"> 484 {/* ID Section */} 485 <div> 486 <div class="flex items-center gap-1"> 487 <div class="iconify lucide--id-card" /> 488 <p class="font-semibold">ID</p> 489 </div> 490 <div class="text-sm">{didDocument().id}</div> 491 </div> 492 493 {/* Aliases Section */} 494 <div> 495 <div class="flex items-center gap-1"> 496 <div class="iconify lucide--at-sign" /> 497 <p class="font-semibold">Aliases</p> 498 </div> 499 <div class="flex flex-col gap-0.5"> 500 <For each={didDocument().alsoKnownAs}> 501 {(alias) => ( 502 <div class="flex items-center gap-1 text-sm"> 503 <span>{alias}</span> 504 <Show when={alias.startsWith("at://")}> 505 <Tooltip 506 text={ 507 validHandles[alias] === true ? "Valid handle" 508 : validHandles[alias] === undefined ? 509 "Validating" 510 : "Invalid handle" 511 } 512 > 513 <span 514 classList={{ 515 "iconify lucide--circle-check text-green-600 dark:text-green-400": 516 validHandles[alias] === true, 517 "iconify lucide--circle-x text-red-500 dark:text-red-400": 518 validHandles[alias] === false, 519 "iconify lucide--loader-circle animate-spin": 520 validHandles[alias] === undefined, 521 }} 522 ></span> 523 </Tooltip> 524 </Show> 525 </div> 526 )} 527 </For> 528 </div> 529 </div> 530 531 {/* Services Section */} 532 <div> 533 <div class="flex items-center gap-1"> 534 <div class="iconify lucide--hard-drive" /> 535 <p class="font-semibold">Services</p> 536 </div> 537 <div class="flex flex-col gap-0.5"> 538 <For each={didDocument().service}> 539 {(service) => ( 540 <div class="text-sm"> 541 <div class="font-medium text-neutral-700 dark:text-neutral-300"> 542 #{service.id.split("#")[1]} 543 </div> 544 <a 545 class="underline hover:text-blue-400" 546 href={service.serviceEndpoint.toString()} 547 target="_blank" 548 rel="noopener" 549 > 550 {service.serviceEndpoint.toString()} 551 </a> 552 </div> 553 )} 554 </For> 555 </div> 556 </div> 557 558 {/* Verification Methods Section */} 559 <div> 560 <div class="flex items-center gap-1"> 561 <div class="iconify lucide--shield-check" /> 562 <p class="font-semibold">Verification Methods</p> 563 </div> 564 <div class="flex flex-col gap-0.5"> 565 <For each={didDocument().verificationMethod}> 566 {(verif) => ( 567 <Show when={verif.publicKeyMultibase}> 568 {(key) => ( 569 <div class="text-sm"> 570 <div class="flex items-baseline gap-1"> 571 <span class="font-medium text-neutral-700 dark:text-neutral-300"> 572 #{verif.id.split("#")[1]} 573 </span> 574 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 575 <ErrorBoundary fallback={<>unknown</>}> 576 {parsePublicMultikey(key()).type} 577 </ErrorBoundary> 578 </span> 579 </div> 580 <div class="font-mono break-all">{key()}</div> 581 </div> 582 )} 583 </Show> 584 )} 585 </For> 586 </div> 587 </div> 588 589 {/* Rotation Keys Section */} 590 <Show when={rotationKeys().length > 0}> 591 <div> 592 <div class="flex items-center gap-1"> 593 <div class="iconify lucide--key-round" /> 594 <p class="font-semibold">Rotation Keys</p> 595 </div> 596 <div class="flex flex-col gap-0.5"> 597 <For each={rotationKeys()}> 598 {(key) => ( 599 <div class="text-sm"> 600 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 601 {parseDidKey(key).type} 602 </span> 603 <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 604 </div> 605 )} 606 </For> 607 </div> 608 </div> 609 </Show> 610 </div> 611 )} 612 </Show> 613 </Show> 614 </div> 615 </div> 616 </Show> 617 ); 618};