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="group flex justify-center" href={`/at://${params.repo}#${props.tab}`}> 65 <span 66 classList={{ 67 "flex flex-1 items-center border-b-2": true, 68 "border-transparent group-hover:border-neutral-400 dark:group-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 254 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`} 255 > 256 <div class="flex gap-2 text-xs sm:gap-4 sm:text-sm"> 257 <Show when={!error()}> 258 <RepoTab tab="collections" label="Collections" /> 259 </Show> 260 <RepoTab tab="identity" label="Identity" /> 261 <Show when={did.startsWith("did:plc")}> 262 <RepoTab tab="logs" label="Logs" /> 263 </Show> 264 <Show when={!error()}> 265 <RepoTab tab="blobs" label="Blobs" /> 266 </Show> 267 <RepoTab tab="backlinks" label="Backlinks" /> 268 </div> 269 <div class="flex gap-0.5"> 270 <Show when={error() && error() !== "Missing PDS"}> 271 <div class="flex items-center gap-1 text-red-500 dark:text-red-400"> 272 <span class="iconify lucide--alert-triangle"></span> 273 <span>{error()}</span> 274 </div> 275 </Show> 276 <Show when={!error() && (!location.hash || location.hash === "#collections")}> 277 <Tooltip text="Filter collections"> 278 <button 279 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" 280 onClick={() => setShowFilter(!showFilter())} 281 > 282 <span class="iconify lucide--filter"></span> 283 </button> 284 </Tooltip> 285 </Show> 286 <MenuProvider> 287 <DropdownMenu 288 icon="lucide--ellipsis-vertical" 289 buttonClass="rounded-sm p-1.5" 290 menuClass="top-9 p-2 text-sm" 291 > 292 <CopyMenu content={params.repo} label="Copy DID" icon="lucide--copy" /> 293 <NavMenu 294 href={`/jetstream?dids=${params.repo}`} 295 label="Jetstream" 296 icon="lucide--radio-tower" 297 /> 298 <Show when={params.repo in labelerCache}> 299 <NavMenu 300 href={`/labels?did=${params.repo}&uriPatterns=*`} 301 label="Labels" 302 icon="lucide--tag" 303 /> 304 </Show> 305 <Show when={error()?.length === 0 || error() === undefined}> 306 <ActionMenu 307 label="Export Repo" 308 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 309 onClick={() => downloadRepo()} 310 /> 311 </Show> 312 <MenuSeparator /> 313 <NavMenu 314 href={ 315 did.startsWith("did:plc") ? 316 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 317 : `https://${did.split("did:web:")[1]}/.well-known/did.json` 318 } 319 newTab 320 label="DID Document" 321 icon="lucide--external-link" 322 /> 323 <Show when={did.startsWith("did:plc")}> 324 <NavMenu 325 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`} 326 newTab 327 label="Audit Log" 328 icon="lucide--external-link" 329 /> 330 </Show> 331 </DropdownMenu> 332 </MenuProvider> 333 </div> 334 </div> 335 <div class="flex w-full flex-col gap-1 px-2"> 336 <Show when={location.hash === "#logs"}> 337 <ErrorBoundary 338 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 339 > 340 <Suspense 341 fallback={ 342 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 343 } 344 > 345 <PlcLogView did={did} /> 346 </Suspense> 347 </ErrorBoundary> 348 </Show> 349 <Show when={location.hash === "#backlinks"}> 350 <ErrorBoundary 351 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 352 > 353 <Suspense 354 fallback={ 355 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 356 } 357 > 358 <Backlinks target={did} /> 359 </Suspense> 360 </ErrorBoundary> 361 </Show> 362 <Show when={location.hash === "#blobs"}> 363 <ErrorBoundary 364 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 365 > 366 <Suspense 367 fallback={ 368 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 369 } 370 > 371 <BlobView pds={pds!} repo={did} /> 372 </Suspense> 373 </ErrorBoundary> 374 </Show> 375 <Show when={nsids() && (!location.hash || location.hash === "#collections")}> 376 <Show when={showFilter()}> 377 <TextInput 378 name="filter" 379 placeholder="Filter collections" 380 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 381 class="grow" 382 ref={(node) => { 383 onMount(() => node.focus()); 384 }} 385 /> 386 </Show> 387 <div 388 class="flex flex-col overflow-hidden text-sm" 389 classList={{ "-mt-1": !showFilter() }} 390 > 391 <For 392 each={Object.keys(nsids() ?? {}).filter((authority) => 393 filter() ? 394 authority.includes(filter()!) || 395 nsids()?.[authority].nsids.some((nsid) => 396 `${authority}.${nsid}`.includes(filter()!), 397 ) 398 : true, 399 )} 400 > 401 {(authority) => { 402 const reversedDomain = authority.split(".").reverse().join("."); 403 const [faviconLoaded, setFaviconLoaded] = createSignal(false); 404 405 return ( 406 <div class="dark:hover:bg-dark-200 flex items-start gap-2 rounded-lg p-1 hover:bg-neutral-200"> 407 <div class="flex h-5 w-4 shrink-0 items-center justify-center"> 408 <Show when={!faviconLoaded()}> 409 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 410 </Show> 411 <img 412 src={ 413 ["bsky.app", "bsky.chat"].includes(reversedDomain) ? 414 "https://web-cdn.bsky.app/static/apple-touch-icon.png" 415 : `https://${reversedDomain}/favicon.ico` 416 } 417 alt={`${reversedDomain} favicon`} 418 class="h-4 w-4" 419 classList={{ hidden: !faviconLoaded() }} 420 onLoad={() => setFaviconLoaded(true)} 421 onError={() => setFaviconLoaded(false)} 422 /> 423 </div> 424 <div class="flex flex-1 flex-col"> 425 <For 426 each={nsids()?.[authority].nsids.filter((nsid) => 427 filter() ? `${authority}.${nsid}`.includes(filter()!) : true, 428 )} 429 > 430 {(nsid) => ( 431 <A 432 href={`/at://${did}/${authority}.${nsid}`} 433 class="hover:underline active:underline" 434 > 435 <span>{authority}</span> 436 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span> 437 </A> 438 )} 439 </For> 440 </div> 441 </div> 442 ); 443 }} 444 </For> 445 </div> 446 </Show> 447 <Show when={location.hash === "#identity" || (error() && !location.hash)}> 448 <Show when={didDoc()}> 449 {(didDocument) => ( 450 <div class="flex flex-col gap-2 wrap-anywhere"> 451 {/* ID Section */} 452 <div> 453 <div class="flex items-center gap-1"> 454 <div class="iconify lucide--id-card" /> 455 <p class="font-semibold">ID</p> 456 </div> 457 <div class="text-sm">{didDocument().id}</div> 458 </div> 459 460 {/* Aliases Section */} 461 <div> 462 <div class="flex items-center gap-1"> 463 <div class="iconify lucide--at-sign" /> 464 <p class="font-semibold">Aliases</p> 465 </div> 466 <div class="flex flex-col gap-0.5"> 467 <For each={didDocument().alsoKnownAs}> 468 {(alias) => ( 469 <div class="flex items-center gap-1 text-sm"> 470 <span>{alias}</span> 471 <Show when={alias.startsWith("at://")}> 472 <Tooltip 473 text={ 474 validHandles[alias] === true ? "Valid handle" 475 : validHandles[alias] === undefined ? 476 "Validating" 477 : "Invalid handle" 478 } 479 > 480 <span 481 classList={{ 482 "iconify lucide--circle-check text-green-600 dark:text-green-400": 483 validHandles[alias] === true, 484 "iconify lucide--circle-x text-red-500 dark:text-red-400": 485 validHandles[alias] === false, 486 "iconify lucide--loader-circle animate-spin": 487 validHandles[alias] === undefined, 488 }} 489 ></span> 490 </Tooltip> 491 </Show> 492 </div> 493 )} 494 </For> 495 </div> 496 </div> 497 498 {/* Services Section */} 499 <div> 500 <div class="flex items-center gap-1"> 501 <div class="iconify lucide--hard-drive" /> 502 <p class="font-semibold">Services</p> 503 </div> 504 <div class="flex flex-col gap-0.5"> 505 <For each={didDocument().service}> 506 {(service) => ( 507 <div class="text-sm"> 508 <div class="text-neutral-600 dark:text-neutral-400"> 509 #{service.id.split("#")[1]} 510 </div> 511 <a 512 class="underline hover:text-blue-400" 513 href={service.serviceEndpoint.toString()} 514 target="_blank" 515 rel="noopener" 516 > 517 {service.serviceEndpoint.toString()} 518 </a> 519 </div> 520 )} 521 </For> 522 </div> 523 </div> 524 525 {/* Verification Methods Section */} 526 <div> 527 <div class="flex items-center gap-1"> 528 <div class="iconify lucide--shield-check" /> 529 <p class="font-semibold">Verification Methods</p> 530 </div> 531 <div class="flex flex-col gap-0.5"> 532 <For each={didDocument().verificationMethod}> 533 {(verif) => ( 534 <Show when={verif.publicKeyMultibase}> 535 {(key) => ( 536 <div class="text-sm"> 537 <div class="flex items-baseline gap-1"> 538 <span class="text-neutral-600 dark:text-neutral-400"> 539 #{verif.id.split("#")[1]} 540 </span> 541 <ErrorBoundary 542 fallback={<span class="text-neutral-500">unknown</span>} 543 > 544 <span class="dark:bg-dark-100 rounded bg-neutral-200 px-1 py-0.5 font-mono text-xs"> 545 {parsePublicMultikey(key()).type} 546 </span> 547 </ErrorBoundary> 548 </div> 549 <div class="font-mono break-all">{key()}</div> 550 </div> 551 )} 552 </Show> 553 )} 554 </For> 555 </div> 556 </div> 557 558 {/* Rotation Keys Section */} 559 <Show when={rotationKeys().length > 0}> 560 <div> 561 <div class="flex items-center gap-1"> 562 <div class="iconify lucide--key-round" /> 563 <p class="font-semibold">Rotation Keys</p> 564 </div> 565 <div class="flex flex-col gap-0.5"> 566 <For each={rotationKeys()}> 567 {(key) => ( 568 <div class="text-sm"> 569 <span class="dark:bg-dark-100 rounded bg-neutral-200 px-1 py-0.5 font-mono text-xs"> 570 {parseDidKey(key).type} 571 </span> 572 <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 573 </div> 574 )} 575 </For> 576 </div> 577 </div> 578 </Show> 579 </div> 580 )} 581 </Show> 582 </Show> 583 </div> 584 </div> 585 </Show> 586 ); 587};