atmosphere explorer
at main 582 lines 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 { Title } from "@solidjs/meta"; 10import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 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 { PermissionButton } from "../components/permission-button.jsx"; 29import { 30 didDocumentResolver, 31 resolveLexiconAuthority, 32 resolveLexiconSchema, 33 resolvePDS, 34} from "../utils/api.js"; 35import { clearCollectionCache } from "../utils/route-cache.js"; 36import { AtUri, uriTemplates } from "../utils/templates.js"; 37import { lexicons } from "../utils/types/lexicons.js"; 38 39const authorityCache = new Map<string, Promise<AtprotoDid>>(); 40const documentCache = new Map<string, Promise<DidDocument>>(); 41const schemaCache = new Map<string, Promise<unknown>>(); 42 43const getAuthoritySegment = (nsid: string): string => { 44 const segments = nsid.split("."); 45 return segments.slice(0, -1).join("."); 46}; 47 48const resolveSchema = async (authority: AtprotoDid, nsid: Nsid): Promise<unknown> => { 49 const cacheKey = `${authority}:${nsid}`; 50 51 let cachedSchema = schemaCache.get(cacheKey); 52 if (cachedSchema) { 53 return cachedSchema; 54 } 55 56 const schemaPromise = (async () => { 57 let didDocPromise = documentCache.get(authority); 58 if (!didDocPromise) { 59 didDocPromise = didDocumentResolver().resolve(authority); 60 documentCache.set(authority, didDocPromise); 61 } 62 63 const didDocument = await didDocPromise; 64 const pdsEndpoint = getPdsEndpoint(didDocument); 65 66 if (!pdsEndpoint) { 67 throw new FailedLexiconResolutionError(nsid, { 68 cause: new TypeError(`no pds service in did document; did=${authority}`), 69 }); 70 } 71 72 const rpc = new Client({ handler: simpleFetchHandler({ service: pdsEndpoint }) }); 73 const response = await rpc.get("com.atproto.repo.getRecord", { 74 params: { 75 repo: authority, 76 collection: "com.atproto.lexicon.schema", 77 rkey: nsid, 78 }, 79 }); 80 81 if (!response.ok) { 82 throw new Error(`got http ${response.status}`); 83 } 84 85 return response.data.value; 86 })(); 87 88 schemaCache.set(cacheKey, schemaPromise); 89 90 try { 91 return await schemaPromise; 92 } catch (err) { 93 schemaCache.delete(cacheKey); 94 throw err; 95 } 96}; 97 98const extractRefs = (obj: any): Nsid[] => { 99 const refs: Set<string> = new Set(); 100 101 const traverse = (value: any) => { 102 if (!value || typeof value !== "object") return; 103 104 if (value.type === "ref" && value.ref) { 105 const ref = value.ref; 106 if (!ref.startsWith("#")) { 107 const nsid = ref.split("#")[0]; 108 if (isNsid(nsid)) refs.add(nsid); 109 } 110 } 111 112 if (value.type === "union" && Array.isArray(value.refs)) { 113 for (const ref of value.refs) { 114 if (!ref.startsWith("#")) { 115 const nsid = ref.split("#")[0]; 116 if (isNsid(nsid)) refs.add(nsid); 117 } 118 } 119 } 120 121 if (Array.isArray(value)) value.forEach(traverse); 122 else Object.values(value).forEach(traverse); 123 }; 124 125 traverse(obj); 126 return Array.from(refs) as Nsid[]; 127}; 128 129const resolveAllLexicons = async ( 130 nsid: Nsid, 131 depth: number = 0, 132 resolved: Map<string, any> = new Map(), 133 failed: Set<string> = new Set(), 134 inFlight: Map<string, Promise<void>> = new Map(), 135): Promise<{ resolved: Map<string, any>; failed: Set<string> }> => { 136 if (depth >= 10) { 137 console.warn(`Maximum recursion depth reached for ${nsid}`); 138 return { resolved, failed }; 139 } 140 141 if (resolved.has(nsid) || failed.has(nsid)) return { resolved, failed }; 142 143 if (inFlight.has(nsid)) { 144 await inFlight.get(nsid); 145 return { resolved, failed }; 146 } 147 148 const fetchPromise = (async () => { 149 let authority: AtprotoDid | undefined; 150 const authoritySegment = getAuthoritySegment(nsid); 151 try { 152 let authorityPromise = authorityCache.get(authoritySegment); 153 if (!authorityPromise) { 154 authorityPromise = resolveLexiconAuthority(nsid); 155 authorityCache.set(authoritySegment, authorityPromise); 156 } 157 158 authority = await authorityPromise; 159 const schema = await resolveSchema(authority, nsid); 160 161 resolved.set(nsid, schema); 162 163 const refs = extractRefs(schema); 164 165 if (refs.length > 0) { 166 await Promise.all( 167 refs.map((ref) => resolveAllLexicons(ref, depth + 1, resolved, failed, inFlight)), 168 ); 169 } 170 } catch (err) { 171 console.error(`Failed to resolve lexicon ${nsid}:`, err); 172 failed.add(nsid); 173 authorityCache.delete(authoritySegment); 174 if (authority) { 175 documentCache.delete(authority); 176 } 177 } finally { 178 inFlight.delete(nsid); 179 } 180 })(); 181 182 inFlight.set(nsid, fetchPromise); 183 await fetchPromise; 184 185 return { resolved, failed }; 186}; 187 188export const RecordView = () => { 189 const location = useLocation(); 190 const navigate = useNavigate(); 191 const params = useParams(); 192 const [openDelete, setOpenDelete] = createSignal(false); 193 const [verifyError, setVerifyError] = createSignal(""); 194 const [validationError, setValidationError] = createSignal(""); 195 const [externalLink, setExternalLink] = createSignal< 196 { label: string; link: string; icon?: string } | undefined 197 >(); 198 const [lexiconAuthority, setLexiconAuthority] = createSignal<AtprotoDid>(); 199 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined); 200 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined); 201 const [schema, setSchema] = createSignal<ResolvedSchema>(); 202 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>(); 203 const [remoteValidation, setRemoteValidation] = createSignal<boolean>(); 204 const did = params.repo; 205 let rpc: Client; 206 207 const fetchRecord = async () => { 208 setValidRecord(undefined); 209 setValidSchema(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 266 const validator = new RecordValidator(lexiconDocs, params.collection as Nsid); 267 validator.parse({ 268 key: params.rkey ?? null, 269 object: record, 270 }); 271 272 setValidSchema(true); 273 } catch (err: any) { 274 console.error("Schema validation error:", err); 275 setValidSchema(false); 276 setValidationError(err.message || String(err)); 277 } 278 setRemoteValidation(false); 279 }; 280 281 const verifyRecordIntegrity = async () => { 282 try { 283 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", { 284 params: { 285 did: did as Did, 286 collection: params.collection as Nsid, 287 rkey: params.rkey!, 288 }, 289 as: "bytes", 290 }); 291 if (!ok) throw data.error; 292 293 await verifyRecord({ 294 did: did as AtprotoDid, 295 collection: params.collection!, 296 rkey: params.rkey!, 297 carBytes: data as Uint8Array<ArrayBufferLike>, 298 }); 299 300 setValidRecord(true); 301 } catch (err: any) { 302 console.error("Record verification error:", err); 303 setVerifyError(err.message); 304 setValidRecord(false); 305 } 306 }; 307 308 const resolveLexicon = async (nsid: Nsid) => { 309 try { 310 const authority = await resolveLexiconAuthority(nsid); 311 setLexiconAuthority(authority); 312 if (params.collection !== "com.atproto.lexicon.schema") { 313 const schema = await resolveLexiconSchema(authority, nsid); 314 setSchema(schema); 315 setLexiconNotFound(false); 316 } 317 } catch { 318 setLexiconNotFound(true); 319 } 320 }; 321 322 const deleteRecord = async () => { 323 rpc = new Client({ handler: agent()! }); 324 await rpc.post("com.atproto.repo.deleteRecord", { 325 input: { 326 repo: params.repo as ActorIdentifier, 327 collection: params.collection as `${string}.${string}.${string}`, 328 rkey: params.rkey!, 329 }, 330 }); 331 const id = addNotification({ 332 message: "Record deleted", 333 type: "success", 334 }); 335 setTimeout(() => removeNotification(id), 3000); 336 clearCollectionCache(`${params.pds}/${params.repo}/${params.collection}`); 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 font-medium transition-colors": true, 367 "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80": 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 <> 383 <Title> 384 {params.collection}/{params.rkey} - PDSls 385 </Title> 386 <ErrorBoundary 387 fallback={(err) => ( 388 <div class="flex w-full flex-col items-center gap-1 px-2 py-4"> 389 <span class="font-semibold text-red-500 dark:text-red-400">Error loading record</span> 390 <div class="max-w-full text-sm wrap-break-word text-neutral-600 dark:text-neutral-400"> 391 {err.message} 392 </div> 393 </div> 394 )} 395 > 396 <Show when={record()} keyed> 397 <div class="flex w-full flex-col items-center"> 398 <div class="mb-3 flex w-full justify-between px-2 text-sm sm:text-base"> 399 <div class="flex items-center gap-4"> 400 <RecordTab tab="record" label="Record" /> 401 <RecordTab tab="schema" label="Schema" /> 402 <RecordTab tab="backlinks" label="Backlinks" /> 403 <RecordTab tab="info" label="Info" error /> 404 </div> 405 <div class="flex gap-0.5"> 406 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 407 <RecordEditor 408 create={false} 409 record={record()?.value} 410 refetch={refetch} 411 scope="update" 412 /> 413 <PermissionButton 414 scope="delete" 415 tooltip="Delete" 416 onClick={() => setOpenDelete(true)} 417 > 418 <span class="iconify lucide--trash-2"></span> 419 </PermissionButton> 420 <Modal 421 open={openDelete()} 422 onClose={() => setOpenDelete(false)} 423 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 424 > 425 <h2 class="mb-2 font-semibold">Delete this record?</h2> 426 <div class="flex justify-end gap-2"> 427 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 428 <Button 429 onClick={deleteRecord} 430 classList={{ 431 "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true, 432 }} 433 > 434 Delete 435 </Button> 436 </div> 437 </Modal> 438 </Show> 439 <MenuProvider> 440 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 441 <CopyMenu 442 content={JSON.stringify(record()?.value, null, 2)} 443 label="Copy record" 444 icon="lucide--copy" 445 /> 446 <CopyMenu 447 content={`at://${params.repo}/${params.collection}/${params.rkey}`} 448 label="Copy AT URI" 449 icon="lucide--copy" 450 /> 451 <Show when={record()?.cid}> 452 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />} 453 </Show> 454 <MenuSeparator /> 455 <Show when={externalLink()}> 456 {(externalLink) => ( 457 <NavMenu 458 href={externalLink()?.link} 459 icon={`${externalLink().icon ?? "lucide--app-window"}`} 460 label={`Open on ${externalLink().label}`} 461 newTab 462 /> 463 )} 464 </Show> 465 <NavMenu 466 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 467 icon="lucide--external-link" 468 label="Record on PDS" 469 newTab 470 /> 471 </DropdownMenu> 472 </MenuProvider> 473 </div> 474 </div> 475 <Show when={!location.hash || location.hash === "#record"}> 476 <div class="w-full max-w-screen min-w-full px-2 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-max sm:text-sm md:max-w-3xl"> 477 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 478 </div> 479 </Show> 480 <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}> 481 <Show when={lexiconNotFound() === true}> 482 <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span> 483 </Show> 484 <Show when={lexiconNotFound() === undefined}> 485 <span class="w-full px-2 text-sm">Resolving lexicon schema...</span> 486 </Show> 487 <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}> 488 <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 489 <LexiconSchemaView 490 schema={schema()?.rawSchema ?? (record()?.value as any)} 491 authority={lexiconAuthority()} 492 /> 493 </ErrorBoundary> 494 </Show> 495 </Show> 496 <Show when={location.hash === "#backlinks"}> 497 <ErrorBoundary 498 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>} 499 > 500 <Suspense 501 fallback={ 502 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 503 } 504 > 505 <div class="w-full px-2"> 506 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} /> 507 </div> 508 </Suspense> 509 </ErrorBoundary> 510 </Show> 511 <Show when={location.hash === "#info"}> 512 <div class="flex w-full flex-col gap-3 px-2"> 513 <div> 514 <p class="font-semibold">AT URI</p> 515 <div class="truncate text-xs text-neutral-700 dark:text-neutral-300"> 516 {record()?.uri} 517 </div> 518 </div> 519 <Show when={record()?.cid}> 520 <div> 521 <p class="font-semibold">CID</p> 522 <div 523 class="truncate text-left text-xs text-neutral-700 dark:text-neutral-300" 524 dir="rtl" 525 > 526 {record()?.cid} 527 </div> 528 </div> 529 </Show> 530 <div> 531 <div class="flex items-center gap-1"> 532 <p class="font-semibold">Record verification</p> 533 <span 534 classList={{ 535 "iconify lucide--check text-green-500 dark:text-green-400": 536 validRecord() === true, 537 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false, 538 "iconify lucide--loader-circle animate-spin": validRecord() === undefined, 539 }} 540 ></span> 541 </div> 542 <Show when={validRecord() === false}> 543 <div class="text-xs wrap-break-word">{verifyError()}</div> 544 </Show> 545 </div> 546 <div> 547 <div class="flex items-center gap-1"> 548 <p class="font-semibold">Schema validation</p> 549 <span 550 classList={{ 551 "iconify lucide--check text-green-500 dark:text-green-400": 552 validSchema() === true, 553 "iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false, 554 "iconify lucide--loader-circle animate-spin": 555 validSchema() === undefined && remoteValidation(), 556 }} 557 ></span> 558 </div> 559 <Show when={validSchema() === false}> 560 <div class="text-xs wrap-break-word">{validationError()}</div> 561 </Show> 562 <Show 563 when={ 564 !remoteValidation() && 565 validSchema() === undefined && 566 params.collection && 567 !(params.collection in lexicons) 568 } 569 > 570 <Button onClick={() => validateRemoteSchema(record()!.value)}> 571 Validate via resolution 572 </Button> 573 </Show> 574 </div> 575 </div> 576 </Show> 577 </div> 578 </Show> 579 </ErrorBoundary> 580 </> 581 ); 582};