atproto explorer
at main 449 lines 17 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 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 NavMenu, 23} from "../components/dropdown.jsx"; 24import { TextInput } from "../components/text-input.jsx"; 25import Tooltip from "../components/tooltip.jsx"; 26import { 27 didDocCache, 28 resolveHandle, 29 resolveLexiconAuthority, 30 resolvePDS, 31 validateHandle, 32} from "../utils/api.js"; 33import { BlobView } from "./blob.jsx"; 34import { PlcLogView } from "./logs.jsx"; 35 36export const RepoView = () => { 37 const params = useParams(); 38 const location = useLocation(); 39 const navigate = useNavigate(); 40 const [error, setError] = createSignal<string>(); 41 const [downloading, setDownloading] = createSignal(false); 42 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 43 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 44 const [filter, setFilter] = createSignal<string>(); 45 const [showFilter, setShowFilter] = createSignal(false); 46 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 47 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 48 let rpc: Client; 49 let pds: string; 50 const did = params.repo; 51 52 const RepoTab = (props: { 53 tab: "collections" | "backlinks" | "identity" | "blobs" | "logs"; 54 label: string; 55 }) => ( 56 <A class="group flex justify-center" href={`/at://${params.repo}#${props.tab}`}> 57 <span 58 classList={{ 59 "flex flex-1 items-center border-b-2": true, 60 "border-transparent group-hover:border-neutral-400 dark:group-hover:border-neutral-600": 61 (location.hash !== `#${props.tab}` && !!location.hash) || 62 (!location.hash && props.tab !== "collections"), 63 }} 64 > 65 {props.label} 66 </span> 67 </A> 68 ); 69 70 const getRotationKeys = async () => { 71 const res = await fetch( 72 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`, 73 ); 74 const json = await res.json(); 75 setRotationKeys(json.rotationKeys ?? []); 76 }; 77 78 const fetchRepo = async () => { 79 try { 80 pds = await resolvePDS(did); 81 } catch { 82 try { 83 const did = await resolveHandle(params.repo as Handle); 84 navigate(location.pathname.replace(params.repo, did)); 85 } catch { 86 try { 87 const nsid = params.repo as Nsid; 88 const res = await resolveLexiconAuthority(nsid); 89 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 90 } catch { 91 navigate(`/${did}`); 92 } 93 } 94 } 95 setDidDoc(didDocCache[did] as DidDocument); 96 getRotationKeys(); 97 98 validateHandles(); 99 100 rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 101 const res = await rpc.get("com.atproto.repo.describeRepo", { 102 params: { repo: did as ActorIdentifier }, 103 }); 104 if (res.ok) { 105 const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 106 res.data.collections.forEach((c) => { 107 const nsid = c.split("."); 108 if (nsid.length > 2) { 109 const authority = `${nsid[0]}.${nsid[1]}`; 110 collections[authority] = { 111 nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 112 hidden: false, 113 }; 114 } 115 }); 116 setNsids(collections); 117 } else { 118 console.error(res.data.error); 119 switch (res.data.error) { 120 case "RepoDeactivated": 121 setError("Deactivated"); 122 break; 123 case "RepoTakendown": 124 setError("Takendown"); 125 break; 126 default: 127 setError("Unreachable"); 128 } 129 navigate(`/at://${params.repo}#identity`); 130 } 131 132 return res.data; 133 }; 134 135 const [repo] = createResource(fetchRepo); 136 137 const validateHandles = async () => { 138 for (const alias of didDoc()?.alsoKnownAs ?? []) { 139 if (alias.startsWith("at://")) 140 setValidHandles( 141 alias, 142 await validateHandle(alias.replace("at://", "") as Handle, did as Did), 143 ); 144 } 145 }; 146 147 const downloadRepo = async () => { 148 try { 149 setDownloading(true); 150 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 151 if (!response.ok) { 152 throw new Error(`HTTP error status: ${response.status}`); 153 } 154 155 const blob = await response.blob(); 156 const url = window.URL.createObjectURL(blob); 157 const a = document.createElement("a"); 158 a.href = url; 159 a.download = `${did}-${new Date().toISOString()}.car`; 160 document.body.appendChild(a); 161 a.click(); 162 163 window.URL.revokeObjectURL(url); 164 document.body.removeChild(a); 165 } catch (error) { 166 console.error("Download failed:", error); 167 } 168 setDownloading(false); 169 }; 170 171 return ( 172 <Show when={repo()}> 173 <div class="flex w-full flex-col gap-2 wrap-break-word"> 174 <div 175 class={`dark:shadow-dark-700 dark:bg-dark-300 flex justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700`} 176 > 177 <div class="flex gap-2 text-xs sm:gap-4 sm:text-sm"> 178 <Show when={!error()}> 179 <RepoTab tab="collections" label="Collections" /> 180 </Show> 181 <RepoTab tab="identity" label="Identity" /> 182 <Show when={did.startsWith("did:plc")}> 183 <RepoTab tab="logs" label="Logs" /> 184 </Show> 185 <Show when={!error()}> 186 <RepoTab tab="blobs" label="Blobs" /> 187 </Show> 188 <RepoTab tab="backlinks" label="Backlinks" /> 189 </div> 190 <div class="flex gap-1"> 191 <Show when={error()}> 192 <div class="flex items-center gap-1 text-red-500 dark:text-red-400"> 193 <span class="iconify lucide--alert-triangle"></span> 194 <span>{error()}</span> 195 </div> 196 </Show> 197 <Show when={!error() && (!location.hash || location.hash === "#collections")}> 198 <Tooltip text="Filter collections"> 199 <button 200 class="flex items-center rounded-sm p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 201 onClick={() => setShowFilter(!showFilter())} 202 > 203 <span class="iconify lucide--filter"></span> 204 </button> 205 </Tooltip> 206 </Show> 207 <MenuProvider> 208 <DropdownMenu 209 icon="lucide--ellipsis-vertical" 210 buttonClass="rounded-sm p-1" 211 menuClass="top-8 p-2 text-sm" 212 > 213 <CopyMenu content={params.repo} label="Copy DID" icon="lucide--copy" /> 214 <NavMenu 215 href={`/jetstream?dids=${params.repo}`} 216 label="Jetstream" 217 icon="lucide--radio-tower" 218 /> 219 <NavMenu 220 href={ 221 did.startsWith("did:plc") ? 222 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 223 : `https://${did.split("did:web:")[1]}/.well-known/did.json` 224 } 225 newTab 226 label="DID Document" 227 icon="lucide--external-link" 228 /> 229 <Show when={did.startsWith("did:plc")}> 230 <NavMenu 231 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`} 232 newTab 233 label="Audit Log" 234 icon="lucide--external-link" 235 /> 236 </Show> 237 <Show when={error()?.length === 0 || error() === undefined}> 238 <ActionMenu 239 label="Export Repo" 240 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 241 onClick={() => downloadRepo()} 242 /> 243 </Show> 244 </DropdownMenu> 245 </MenuProvider> 246 </div> 247 </div> 248 <div class="flex w-full flex-col gap-1 px-2"> 249 <Show when={location.hash === "#logs"}> 250 <ErrorBoundary 251 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 252 > 253 <Suspense 254 fallback={ 255 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 256 } 257 > 258 <PlcLogView did={did} /> 259 </Suspense> 260 </ErrorBoundary> 261 </Show> 262 <Show when={location.hash === "#backlinks"}> 263 <ErrorBoundary 264 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 265 > 266 <Suspense 267 fallback={ 268 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 269 } 270 > 271 <Backlinks target={did} /> 272 </Suspense> 273 </ErrorBoundary> 274 </Show> 275 <Show when={location.hash === "#blobs"}> 276 <ErrorBoundary 277 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 278 > 279 <Suspense 280 fallback={ 281 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" /> 282 } 283 > 284 <BlobView pds={pds!} repo={did} /> 285 </Suspense> 286 </ErrorBoundary> 287 </Show> 288 <Show when={nsids() && (!location.hash || location.hash === "#collections")}> 289 <Show when={showFilter()}> 290 <TextInput 291 name="filter" 292 placeholder="Filter collections" 293 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 294 class="grow" 295 ref={(node) => { 296 onMount(() => node.focus()); 297 }} 298 /> 299 </Show> 300 <div class="flex flex-col overflow-hidden text-sm"> 301 <For 302 each={Object.keys(nsids() ?? {}).filter((authority) => 303 filter() ? 304 authority.startsWith(filter()!) || filter()?.startsWith(authority) 305 : true, 306 )} 307 > 308 {(authority) => ( 309 <div class="dark:hover:bg-dark-200 flex flex-col rounded-lg p-1 hover:bg-neutral-200"> 310 <For 311 each={nsids()?.[authority].nsids.filter((nsid) => 312 filter() ? nsid.startsWith(filter()!.split(".").slice(2).join(".")) : true, 313 )} 314 > 315 {(nsid) => ( 316 <A 317 href={`/at://${did}/${authority}.${nsid}`} 318 class="hover:underline active:underline" 319 > 320 <span>{authority}</span> 321 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span> 322 </A> 323 )} 324 </For> 325 </div> 326 )} 327 </For> 328 </div> 329 </Show> 330 <Show when={location.hash === "#identity"}> 331 <Show when={didDoc()}> 332 {(didDocument) => ( 333 <div class="flex flex-col gap-y-1 wrap-anywhere"> 334 <div> 335 <div class="flex items-center gap-1"> 336 <div class="iconify lucide--id-card" /> 337 <p class="font-semibold">ID</p> 338 </div> 339 <div class="text-sm">{didDocument().id}</div> 340 </div> 341 <div> 342 <div class="flex items-center gap-1"> 343 <div class="iconify lucide--at-sign" /> 344 <p class="font-semibold">Aliases</p> 345 </div> 346 <ul> 347 <For each={didDocument().alsoKnownAs}> 348 {(alias) => ( 349 <li class="flex items-center gap-1 text-sm"> 350 <span>{alias}</span> 351 <Show when={alias.startsWith("at://")}> 352 <Tooltip 353 text={ 354 validHandles[alias] === true ? "Valid handle" 355 : validHandles[alias] === undefined ? 356 "Validating" 357 : "Invalid handle" 358 } 359 > 360 <span 361 classList={{ 362 "iconify lucide--circle-check": validHandles[alias] === true, 363 "iconify lucide--circle-x text-red-500 dark:text-red-400": 364 validHandles[alias] === false, 365 "iconify lucide--loader-circle animate-spin": 366 validHandles[alias] === undefined, 367 }} 368 ></span> 369 </Tooltip> 370 </Show> 371 </li> 372 )} 373 </For> 374 </ul> 375 </div> 376 <div> 377 <div class="flex items-center gap-1"> 378 <div class="iconify lucide--hard-drive" /> 379 <p class="font-semibold">Services</p> 380 </div> 381 <ul> 382 <For each={didDocument().service}> 383 {(service) => ( 384 <li class="flex flex-col text-sm"> 385 <span>#{service.id.split("#")[1]}</span> 386 <a 387 class="w-fit underline" 388 href={service.serviceEndpoint.toString()} 389 target="_blank" 390 rel="noopener" 391 > 392 {service.serviceEndpoint.toString()} 393 </a> 394 </li> 395 )} 396 </For> 397 </ul> 398 </div> 399 <div> 400 <div class="flex items-center gap-1"> 401 <div class="iconify lucide--shield-check" /> 402 <p class="font-semibold">Verification Methods</p> 403 </div> 404 <ul> 405 <For each={didDocument().verificationMethod}> 406 {(verif) => ( 407 <Show when={verif.publicKeyMultibase}> 408 {(key) => ( 409 <li class="flex flex-col text-sm"> 410 <span> 411 <span>#{verif.id.split("#")[1]}</span> 412 <ErrorBoundary fallback={<>unknown</>}> 413 {" "} 414 ({parsePublicMultikey(key()).type}) 415 </ErrorBoundary> 416 </span> 417 <span class="truncate">{key()}</span> 418 </li> 419 )} 420 </Show> 421 )} 422 </For> 423 </ul> 424 </div> 425 <div> 426 <div class="flex items-center gap-1"> 427 <div class="iconify lucide--key-round" /> 428 <p class="font-semibold">Rotation Keys</p> 429 </div> 430 <ul> 431 <For each={rotationKeys()}> 432 {(key) => ( 433 <li class="text-xs"> 434 <span>{key.replace("did:key:", "")}</span> 435 <span> ({parseDidKey(key).type})</span> 436 </li> 437 )} 438 </For> 439 </ul> 440 </div> 441 </div> 442 )} 443 </Show> 444 </Show> 445 </div> 446 </div> 447 </Show> 448 ); 449};