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