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