forked from
pds.ls/pdsls
atproto explorer
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};