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