atproto explorer pdsls.dev
atproto tool
at main 22 kB view raw
1import { Client, simpleFetchHandler } from "@atcute/client"; 2import { DidDocument, getPdsEndpoint } from "@atcute/identity"; 3import { lexiconDoc } from "@atcute/lexicon-doc"; 4import { RecordValidator } from "@atcute/lexicon-doc/validations"; 5import { FailedLexiconResolutionError, ResolvedSchema } from "@atcute/lexicon-resolver"; 6import { ActorIdentifier, is, Nsid } from "@atcute/lexicons"; 7import { AtprotoDid, Did, isNsid } from "@atcute/lexicons/syntax"; 8import { verifyRecord } from "@atcute/repo"; 9import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 10import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 11import { hasUserScope } from "../auth/scope-utils"; 12import { agent } from "../auth/state"; 13import { Backlinks } from "../components/backlinks.jsx"; 14import { Button } from "../components/button.jsx"; 15import { RecordEditor, setPlaceholder } from "../components/create"; 16import { 17 CopyMenu, 18 DropdownMenu, 19 MenuProvider, 20 MenuSeparator, 21 NavMenu, 22} from "../components/dropdown.jsx"; 23import { JSONValue } from "../components/json.jsx"; 24import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 25import { Modal } from "../components/modal.jsx"; 26import { pds } from "../components/navbar.jsx"; 27import { addNotification, removeNotification } from "../components/notification.jsx"; 28import Tooltip from "../components/tooltip.jsx"; 29import { 30 didDocumentResolver, 31 resolveLexiconAuthority, 32 resolveLexiconSchema, 33 resolvePDS, 34} from "../utils/api.js"; 35import { AtUri, uriTemplates } from "../utils/templates.js"; 36import { lexicons } from "../utils/types/lexicons.js"; 37 38const authorityCache = new Map<string, Promise<AtprotoDid>>(); 39const documentCache = new Map<string, Promise<DidDocument>>(); 40const schemaCache = new Map<string, Promise<unknown>>(); 41 42const getAuthoritySegment = (nsid: string): string => { 43 const segments = nsid.split("."); 44 return segments.slice(0, -1).join("."); 45}; 46 47const resolveSchema = async (authority: AtprotoDid, nsid: Nsid): Promise<unknown> => { 48 const cacheKey = `${authority}:${nsid}`; 49 50 let cachedSchema = schemaCache.get(cacheKey); 51 if (cachedSchema) { 52 return cachedSchema; 53 } 54 55 const schemaPromise = (async () => { 56 let didDocPromise = documentCache.get(authority); 57 if (!didDocPromise) { 58 didDocPromise = didDocumentResolver.resolve(authority); 59 documentCache.set(authority, didDocPromise); 60 } 61 62 const didDocument = await didDocPromise; 63 const pdsEndpoint = getPdsEndpoint(didDocument); 64 65 if (!pdsEndpoint) { 66 throw new FailedLexiconResolutionError(nsid, { 67 cause: new TypeError(`no pds service in did document; did=${authority}`), 68 }); 69 } 70 71 const rpc = new Client({ handler: simpleFetchHandler({ service: pdsEndpoint }) }); 72 const response = await rpc.get("com.atproto.repo.getRecord", { 73 params: { 74 repo: authority, 75 collection: "com.atproto.lexicon.schema", 76 rkey: nsid, 77 }, 78 }); 79 80 if (!response.ok) { 81 throw new Error(`got http ${response.status}`); 82 } 83 84 return response.data.value; 85 })(); 86 87 schemaCache.set(cacheKey, schemaPromise); 88 89 try { 90 return await schemaPromise; 91 } catch (err) { 92 schemaCache.delete(cacheKey); 93 throw err; 94 } 95}; 96 97const extractRefs = (obj: any): Nsid[] => { 98 const refs: Set<string> = new Set(); 99 100 const traverse = (value: any) => { 101 if (!value || typeof value !== "object") return; 102 103 if (value.type === "ref" && value.ref) { 104 const ref = value.ref; 105 if (!ref.startsWith("#")) { 106 const nsid = ref.split("#")[0]; 107 if (isNsid(nsid)) refs.add(nsid); 108 } 109 } 110 111 if (value.type === "union" && Array.isArray(value.refs)) { 112 for (const ref of value.refs) { 113 if (!ref.startsWith("#")) { 114 const nsid = ref.split("#")[0]; 115 if (isNsid(nsid)) refs.add(nsid); 116 } 117 } 118 } 119 120 if (Array.isArray(value)) value.forEach(traverse); 121 else Object.values(value).forEach(traverse); 122 }; 123 124 traverse(obj); 125 return Array.from(refs) as Nsid[]; 126}; 127 128const resolveAllLexicons = async ( 129 nsid: Nsid, 130 depth: number = 0, 131 resolved: Map<string, any> = new Map(), 132 failed: Set<string> = new Set(), 133 inFlight: Map<string, Promise<void>> = new Map(), 134): Promise<{ resolved: Map<string, any>; failed: Set<string> }> => { 135 if (depth >= 10) { 136 console.warn(`Maximum recursion depth reached for ${nsid}`); 137 return { resolved, failed }; 138 } 139 140 if (resolved.has(nsid) || failed.has(nsid)) return { resolved, failed }; 141 142 if (inFlight.has(nsid)) { 143 await inFlight.get(nsid); 144 return { resolved, failed }; 145 } 146 147 const fetchPromise = (async () => { 148 let authority: AtprotoDid | undefined; 149 const authoritySegment = getAuthoritySegment(nsid); 150 try { 151 let authorityPromise = authorityCache.get(authoritySegment); 152 if (!authorityPromise) { 153 authorityPromise = resolveLexiconAuthority(nsid); 154 authorityCache.set(authoritySegment, authorityPromise); 155 } 156 157 authority = await authorityPromise; 158 const schema = await resolveSchema(authority, nsid); 159 160 resolved.set(nsid, schema); 161 162 const refs = extractRefs(schema); 163 164 if (refs.length > 0) { 165 await Promise.all( 166 refs.map((ref) => resolveAllLexicons(ref, depth + 1, resolved, failed, inFlight)), 167 ); 168 } 169 } catch (err) { 170 console.error(`Failed to resolve lexicon ${nsid}:`, err); 171 failed.add(nsid); 172 authorityCache.delete(authoritySegment); 173 if (authority) { 174 documentCache.delete(authority); 175 } 176 } finally { 177 inFlight.delete(nsid); 178 } 179 })(); 180 181 inFlight.set(nsid, fetchPromise); 182 await fetchPromise; 183 184 return { resolved, failed }; 185}; 186 187export const RecordView = () => { 188 const location = useLocation(); 189 const navigate = useNavigate(); 190 const params = useParams(); 191 const [openDelete, setOpenDelete] = createSignal(false); 192 const [verifyError, setVerifyError] = createSignal(""); 193 const [validationError, setValidationError] = createSignal(""); 194 const [externalLink, setExternalLink] = createSignal< 195 { label: string; link: string; icon?: string } | undefined 196 >(); 197 const [lexiconUri, setLexiconUri] = createSignal<string>(); 198 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined); 199 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined); 200 const [schema, setSchema] = createSignal<ResolvedSchema>(); 201 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>(); 202 const [remoteValidation, setRemoteValidation] = createSignal<boolean>(); 203 const did = params.repo; 204 let rpc: Client; 205 206 const fetchRecord = async () => { 207 setValidRecord(undefined); 208 setValidSchema(undefined); 209 setLexiconUri(undefined); 210 const pds = await resolvePDS(did!); 211 rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 212 const res = await rpc.get("com.atproto.repo.getRecord", { 213 params: { 214 repo: did as ActorIdentifier, 215 collection: params.collection as `${string}.${string}.${string}`, 216 rkey: params.rkey!, 217 }, 218 }); 219 if (!res.ok) { 220 setValidRecord(false); 221 setVerifyError(res.data.error); 222 throw new Error(res.data.error); 223 } 224 setPlaceholder(res.data.value); 225 setExternalLink(checkUri(res.data.uri, res.data.value)); 226 resolveLexicon(params.collection as Nsid); 227 verifyRecordIntegrity(); 228 validateLocalSchema(res.data.value); 229 230 return res.data; 231 }; 232 233 const [record, { refetch }] = createResource(fetchRecord); 234 235 const validateLocalSchema = async (record: Record<string, unknown>) => { 236 try { 237 if (params.collection === "com.atproto.lexicon.schema") { 238 setLexiconNotFound(false); 239 lexiconDoc.parse(record, { mode: "passthrough" }); 240 setValidSchema(true); 241 } else if (params.collection && params.collection in lexicons) { 242 if (is(lexicons[params.collection], record)) setValidSchema(true); 243 else setValidSchema(false); 244 } 245 } catch (err: any) { 246 console.error("Schema validation error:", err); 247 setValidSchema(false); 248 setValidationError(err.message || String(err)); 249 } 250 }; 251 252 const validateRemoteSchema = async (record: Record<string, unknown>) => { 253 try { 254 setRemoteValidation(true); 255 const { resolved, failed } = await resolveAllLexicons(params.collection as Nsid); 256 257 if (failed.size > 0) { 258 console.error(`Failed to resolve ${failed.size} documents:`, Array.from(failed)); 259 setValidSchema(false); 260 setValidationError(`Unable to resolve lexicon documents: ${Array.from(failed).join(", ")}`); 261 return; 262 } 263 264 const lexiconDocs = Object.fromEntries(resolved); 265 console.log(lexiconDocs); 266 267 const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); 268 validator.parse({ 269 key: params.rkey ?? null, 270 object: record, 271 }); 272 273 setValidSchema(true); 274 } catch (err: any) { 275 console.error("Schema validation error:", err); 276 setValidSchema(false); 277 setValidationError(err.message || String(err)); 278 } 279 setRemoteValidation(false); 280 }; 281 282 const verifyRecordIntegrity = async () => { 283 try { 284 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", { 285 params: { 286 did: did as Did, 287 collection: params.collection as Nsid, 288 rkey: params.rkey!, 289 }, 290 as: "bytes", 291 }); 292 if (!ok) throw data.error; 293 294 await verifyRecord({ 295 did: did as AtprotoDid, 296 collection: params.collection!, 297 rkey: params.rkey!, 298 carBytes: data as Uint8Array<ArrayBufferLike>, 299 }); 300 301 setValidRecord(true); 302 } catch (err: any) { 303 console.error("Record verification error:", err); 304 setVerifyError(err.message); 305 setValidRecord(false); 306 } 307 }; 308 309 const resolveLexicon = async (nsid: Nsid) => { 310 try { 311 const authority = await resolveLexiconAuthority(nsid); 312 setLexiconUri(`at://${authority}/com.atproto.lexicon.schema/${nsid}`); 313 if (params.collection !== "com.atproto.lexicon.schema") { 314 const schema = await resolveLexiconSchema(authority, nsid); 315 setSchema(schema); 316 setLexiconNotFound(false); 317 } 318 } catch { 319 setLexiconNotFound(true); 320 } 321 }; 322 323 const deleteRecord = async () => { 324 rpc = new Client({ handler: agent()! }); 325 await rpc.post("com.atproto.repo.deleteRecord", { 326 input: { 327 repo: params.repo as ActorIdentifier, 328 collection: params.collection as `${string}.${string}.${string}`, 329 rkey: params.rkey!, 330 }, 331 }); 332 const id = addNotification({ 333 message: "Record deleted", 334 type: "success", 335 }); 336 setTimeout(() => removeNotification(id), 3000); 337 navigate(`/at://${params.repo}/${params.collection}`); 338 }; 339 340 const checkUri = (uri: string, record: any) => { 341 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"] 342 if (uriParts.length != 5) return undefined; 343 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined; 344 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] }; 345 const template = uriTemplates[parsedUri.collection]; 346 if (!template) return undefined; 347 return template(parsedUri, record); 348 }; 349 350 const RecordTab = (props: { 351 tab: "record" | "backlinks" | "info" | "schema"; 352 label: string; 353 error?: boolean; 354 }) => { 355 const isActive = () => { 356 if (!location.hash && props.tab === "record") return true; 357 if (location.hash === `#${props.tab}`) return true; 358 if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true; 359 return false; 360 }; 361 362 return ( 363 <div class="flex items-center gap-0.5"> 364 <A 365 classList={{ 366 "border-b-2": true, 367 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 368 !isActive(), 369 }} 370 href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`} 371 > 372 {props.label} 373 </A> 374 <Show when={props.error && (validRecord() === false || validSchema() === false)}> 375 <span class="iconify lucide--x text-red-500 dark:text-red-400"></span> 376 </Show> 377 </div> 378 ); 379 }; 380 381 return ( 382 <Show when={record()} keyed> 383 <div class="flex w-full flex-col items-center"> 384 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-3 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-xs dark:border-neutral-700"> 385 <div class="ml-1 flex items-center gap-3"> 386 <RecordTab tab="record" label="Record" /> 387 <RecordTab tab="schema" label="Schema" /> 388 <RecordTab tab="backlinks" label="Backlinks" /> 389 <RecordTab tab="info" label="Info" error /> 390 </div> 391 <div class="flex gap-0.5"> 392 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 393 <Show when={hasUserScope("update")}> 394 <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 395 </Show> 396 <Show when={hasUserScope("delete")}> 397 <Tooltip text="Delete"> 398 <button 399 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" 400 onclick={() => setOpenDelete(true)} 401 > 402 <span class="iconify lucide--trash-2"></span> 403 </button> 404 </Tooltip> 405 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 406 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 407 <h2 class="mb-2 font-semibold">Delete this record?</h2> 408 <div class="flex justify-end gap-2"> 409 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 410 <Button 411 onClick={deleteRecord} 412 class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 413 > 414 Delete 415 </Button> 416 </div> 417 </div> 418 </Modal> 419 </Show> 420 </Show> 421 <MenuProvider> 422 <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 423 <CopyMenu 424 content={JSON.stringify(record()?.value, null, 2)} 425 label="Copy record" 426 icon="lucide--copy" 427 /> 428 <CopyMenu 429 content={`at://${params.repo}/${params.collection}/${params.rkey}`} 430 label="Copy AT URI" 431 icon="lucide--copy" 432 /> 433 <Show when={record()?.cid}> 434 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />} 435 </Show> 436 <MenuSeparator /> 437 <Show when={externalLink()}> 438 {(externalLink) => ( 439 <NavMenu 440 href={externalLink()?.link} 441 icon={`${externalLink().icon ?? "lucide--app-window"}`} 442 label={`Open on ${externalLink().label}`} 443 newTab 444 /> 445 )} 446 </Show> 447 <NavMenu 448 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 449 icon="lucide--external-link" 450 label="Record on PDS" 451 newTab 452 /> 453 </DropdownMenu> 454 </MenuProvider> 455 </div> 456 </div> 457 <Show when={!location.hash || location.hash === "#record"}> 458 <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl"> 459 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 460 </div> 461 </Show> 462 <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}> 463 <Show when={lexiconNotFound() === true}> 464 <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span> 465 </Show> 466 <Show when={lexiconNotFound() === undefined}> 467 <span class="w-full px-2 text-sm">Resolving lexicon schema...</span> 468 </Show> 469 <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}> 470 <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 471 <LexiconSchemaView schema={schema()?.rawSchema ?? (record()?.value as any)} /> 472 </ErrorBoundary> 473 </Show> 474 </Show> 475 <Show when={location.hash === "#backlinks"}> 476 <ErrorBoundary 477 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 478 > 479 <Suspense 480 fallback={ 481 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 482 } 483 > 484 <div class="w-full px-2"> 485 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} /> 486 </div> 487 </Suspense> 488 </ErrorBoundary> 489 </Show> 490 <Show when={location.hash === "#info"}> 491 <div class="flex w-full flex-col gap-2 px-2 text-sm"> 492 <div> 493 <div class="flex items-center gap-1"> 494 <span class="iconify lucide--at-sign"></span> 495 <p class="font-semibold">AT URI</p> 496 </div> 497 <div class="truncate text-xs">{record()?.uri}</div> 498 </div> 499 <Show when={record()?.cid}> 500 <div> 501 <div class="flex items-center gap-1"> 502 <span class="iconify lucide--box"></span> 503 <p class="font-semibold">CID</p> 504 </div> 505 <div class="truncate text-left text-xs" dir="rtl"> 506 {record()?.cid} 507 </div> 508 </div> 509 </Show> 510 <div> 511 <div class="flex items-center gap-1"> 512 <span class="iconify lucide--lock-keyhole"></span> 513 <p class="font-semibold">Record verification</p> 514 <span 515 classList={{ 516 "iconify lucide--check text-green-500 dark:text-green-400": 517 validRecord() === true, 518 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false, 519 "iconify lucide--loader-circle animate-spin": validRecord() === undefined, 520 }} 521 ></span> 522 </div> 523 <Show when={validRecord() === false}> 524 <div class="text-xs wrap-break-word">{verifyError()}</div> 525 </Show> 526 </div> 527 <div> 528 <div class="flex items-center gap-1"> 529 <span class="iconify lucide--file-check"></span> 530 <p class="font-semibold">Schema validation</p> 531 <span 532 classList={{ 533 "iconify lucide--check text-green-500 dark:text-green-400": 534 validSchema() === true, 535 "iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false, 536 "iconify lucide--loader-circle animate-spin": 537 validSchema() === undefined && remoteValidation(), 538 }} 539 ></span> 540 </div> 541 <Show when={validSchema() === false}> 542 <div class="text-xs wrap-break-word">{validationError()}</div> 543 </Show> 544 <Show 545 when={ 546 !remoteValidation() && 547 validSchema() === undefined && 548 params.collection && 549 !(params.collection in lexicons) 550 } 551 > 552 <Button onClick={() => validateRemoteSchema(record()!.value)}> 553 Validate via resolution 554 </Button> 555 </Show> 556 </div> 557 <Show when={lexiconUri()}> 558 <div> 559 <div class="flex items-center gap-1"> 560 <span class="iconify lucide--scroll-text"></span> 561 <p class="font-semibold">Lexicon schema</p> 562 </div> 563 <div class="truncate text-xs"> 564 <A 565 href={`/${lexiconUri()}`} 566 class="text-blue-400 hover:underline active:underline" 567 > 568 {lexiconUri()} 569 </A> 570 </div> 571 </div> 572 </Show> 573 </div> 574 </Show> 575 </div> 576 </Show> 577 ); 578};