From 936c3325d334ce9805c30e6d8251339a6d36479a Mon Sep 17 00:00:00 2001 From: Tom Sherman Date: Thu, 29 Aug 2024 20:57:49 +0100 Subject: [PATCH] Big ol' route restructure (#116) * Big ol' route restructure * Dont share params --- packages/atproto-browser/app/actions.ts | 6 +- .../[collection]/(rkey)/[rkey]/page.tsx | 129 ++++ .../[collection]/(rkey)/layout.tsx | 43 ++ .../app/at/[identifier]/[collection]/page.tsx | 59 ++ .../app/at/[identifier]/page.tsx | 317 ++++++++ .../app/at/_lib/atproto-json.tsx | 170 +++++ .../app/at/_lib/collection-server.tsx | 50 ++ .../app/at/_lib/collection.tsx | 3 +- .../app/at/_lib/did-components.tsx | 23 + .../atproto-browser/app/at/_lib/uri-bar.tsx | 21 +- packages/atproto-browser/app/at/page.tsx | 709 +----------------- .../app/collection-rss/route.tsx | 3 +- packages/atproto-browser/app/page.tsx | 5 +- .../{app/at/_lib => lib}/at-blob.ts | 0 .../atproto-browser/lib/atproto-server.ts | 13 +- packages/atproto-browser/lib/util.ts | 18 + 16 files changed, 846 insertions(+), 723 deletions(-) create mode 100644 packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx create mode 100644 packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx create mode 100644 packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx create mode 100644 packages/atproto-browser/app/at/[identifier]/page.tsx create mode 100644 packages/atproto-browser/app/at/_lib/atproto-json.tsx create mode 100644 packages/atproto-browser/app/at/_lib/collection-server.tsx create mode 100644 packages/atproto-browser/app/at/_lib/did-components.tsx rename packages/atproto-browser/{app/at/_lib => lib}/at-blob.ts (100%) create mode 100644 packages/atproto-browser/lib/util.ts diff --git a/packages/atproto-browser/app/actions.ts b/packages/atproto-browser/app/actions.ts index c9447000..aa2efe62 100644 --- a/packages/atproto-browser/app/actions.ts +++ b/packages/atproto-browser/app/actions.ts @@ -1,8 +1,10 @@ "use server"; +import { getAtUriPath } from "@/lib/util"; +import { AtUri } from "@atproto/syntax"; import { redirect } from "next/navigation"; export async function navigateUri(_state: unknown, formData: FormData) { - const uri = formData.get("uri") as string; - redirect(`/at?u=${uri}`); + const uri = new AtUri(formData.get("uri") as string); + redirect(getAtUriPath(uri)); } diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx new file mode 100644 index 00000000..54f87f6e --- /dev/null +++ b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx @@ -0,0 +1,129 @@ +import { JSONType, JSONValue } from "@/app/at/_lib/atproto-json"; +import { resolveIdentity } from "@/lib/atproto-server"; +import { getHandle, getKey, getPds } from "@atproto/identity"; +import { verifyRecords } from "@atproto/repo"; +import { Suspense } from "react"; + +export default async function RkeyPage({ + params, +}: { + params: { + identifier: string; + collection: string; + rkey: string; + }; +}) { + const identityResult = await resolveIdentity(params.identifier); + if (!identityResult.success) { + return
{identityResult.error}
; + } + const didDocument = identityResult.identity; + const handle = getHandle(didDocument); + if (!handle) { + return
No handle found for DID: {didDocument.id}
; + } + const pds = getPds(didDocument); + if (!pds) { + return
No PDS found for DID: {didDocument.id}
; + } + + const getRecordUrl = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + getRecordUrl.searchParams.set("repo", didDocument.id); + getRecordUrl.searchParams.set("collection", params.collection); + getRecordUrl.searchParams.set("rkey", params.rkey); + + const response = await fetch(getRecordUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return ( +
+ Failed to fetch record: {response.statusText}. URL:{" "} + {getRecordUrl.toString()} +
+ ); + } + + const record = (await response.json()) as JSONType; + + return ( + <> +

+ Record + + 🤔 + + } + > + + +

+ + + ); +} + +async function RecordVerificationBadge({ + did, + collection, + rkey, +}: { + did: string; + collection: string; + rkey: string; +}) { + const identityResult = await resolveIdentity(did); + if (!identityResult.success) { + throw new Error(identityResult.error); + } + const didDoc = identityResult.identity; + const pds = getPds(didDoc); + if (!pds) { + return ; + } + + const verifyRecordsUrl = new URL(`${pds}/xrpc/com.atproto.sync.getRecord`); + verifyRecordsUrl.searchParams.set("did", did); + verifyRecordsUrl.searchParams.set("collection", collection); + verifyRecordsUrl.searchParams.set("rkey", rkey); + + const response = await fetch(verifyRecordsUrl, { + headers: { + accept: "application/vnd.ipld.car", + }, + }); + + if (!response.ok) { + return ( + + ❌ + + ); + } + const car = new Uint8Array(await response.arrayBuffer()); + const key = getKey(didDoc); + if (!key) { + return ; + } + + try { + await verifyRecords(car, did, key); + return 🔒; + } catch (e) { + if (e instanceof Error) { + return ; + } else { + return ; + } + } +} diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx new file mode 100644 index 00000000..a08e75c7 --- /dev/null +++ b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx @@ -0,0 +1,43 @@ +import { resolveIdentity } from "@/lib/atproto-server"; +import { getHandle, getPds } from "@atproto/identity"; +import Link from "next/link"; +import { Suspense } from "react"; +import { DidSummary } from "@/app/at/_lib/did-components"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: { identifier: string }; +}) { + const identityResult = await resolveIdentity(params.identifier); + if (!identityResult.success) { + return
{identityResult.error}
; + } + const didDocument = identityResult.identity; + const handle = getHandle(didDocument); + if (!handle) { + return
No handle found for DID: {didDocument.id}
; + } + const pds = getPds(didDocument); + if (!pds) { + return
No PDS found for DID: {didDocument.id}
; + } + + return ( +
+
+ + Author: {handle} ( + {didDocument.id}) + + + + +
+ + {children} +
+ ); +} diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx new file mode 100644 index 00000000..7781db8a --- /dev/null +++ b/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx @@ -0,0 +1,59 @@ +import { listRecords } from "@/lib/atproto"; +import { resolveIdentity } from "@/lib/atproto-server"; +import { getHandle, getPds } from "@atproto/identity"; +import Link from "next/link"; +import { SWRConfig } from "swr"; +import { CollectionItems } from "../../_lib/collection"; + +export default async function CollectionPage({ + params, +}: { + params: { identifier: string; collection: string }; +}) { + const identityResult = await resolveIdentity(params.identifier); + if (!identityResult.success) { + return
{identityResult.error}
; + } + const didDocument = identityResult.identity; + const handle = getHandle(didDocument); + if (!handle) { + return
No handle found for DID: {didDocument.id}
; + } + const pds = getPds(didDocument); + if (!pds) { + return
No PDS found for DID: {didDocument.id}
; + } + + const fetchKey = + `listCollections/collection:${params.collection}/cursor:none` as const; + + return ( +
+

+ {handle}'s {params.collection} records{" "} + }`} + title="RSS feed" + > + 🛜 + +

+
    + + + +
+
+ ); +} diff --git a/packages/atproto-browser/app/at/[identifier]/page.tsx b/packages/atproto-browser/app/at/[identifier]/page.tsx new file mode 100644 index 00000000..7e92748a --- /dev/null +++ b/packages/atproto-browser/app/at/[identifier]/page.tsx @@ -0,0 +1,317 @@ +import { getAtUriPath, isNotNull, utcDateFormatter } from "@/lib/util"; +import Link from "next/link"; +import { Fragment, Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { z } from "zod"; +import { DidSummary } from "../_lib/did-components"; +import { resolveIdentity } from "@/lib/atproto-server"; +import { getHandle } from "@atproto/identity"; +import { AtUri } from "@atproto/syntax"; + +export default async function IdentifierPage({ + params, +}: { + params: { identifier: string }; +}) { + const identityResult = await resolveIdentity(params.identifier); + if (!identityResult.success) { + return
{identityResult.error}
; + } + const didDocument = identityResult.identity; + const handle = getHandle(didDocument); + if (!handle) { + return
No handle found for DID: {didDocument.id}
; + } + + return ( + <> +

+ {handle} ({didDocument.id}) +

+ + + Loading history...

}> + Failed to fetch history.

}> +

History

+ +
+
+ + ); +} + +async function DidHistory({ did }: { did: string }) { + const response = await fetch(`https://plc.directory/${did}/log/audit`); + if (!response.ok) { + throw new Error(`Failed to fetch history: ${response.statusText}`); + } + + const auditLog = PlcLogAuditResponse.parse(await response.json()); + + return ( +
    + {auditLog.map((previous, index) => { + const previousOperation = previous.operation; + if (previousOperation.type !== "plc_operation") { + return ( + // eslint-disable-next-line react/no-array-index-key +
  1. + Change created at {utcDateFormatter.format(previous.createdAt)}{" "} + (UTC) of type "{previousOperation.type}". +
  2. + ); + } + const entry = auditLog[index + 1]; + if (!entry) { + return null; + } + const entryOperation = entry.operation; + if (entryOperation.type !== "plc_operation") { + return null; + } + + const alsoKnownAsAdded = entryOperation.alsoKnownAs.filter( + (x) => !previousOperation.alsoKnownAs.includes(x), + ); + const alsoKnownAsRemoved = previousOperation.alsoKnownAs.filter( + (x) => !entryOperation.alsoKnownAs.includes(x), + ); + + const servicesChanged = Object.entries(entryOperation.services) + .map(([id, service]) => { + const previousService = previousOperation.services[id]; + if (!previousService) return null; + return { + id, + type: + service.type !== previousService.type + ? { + from: previousService.type, + to: service.type, + } + : null, + endpoint: + service.endpoint !== previousService.endpoint + ? { + from: previousService.endpoint, + to: service.endpoint, + } + : null, + }; + }) + .filter(isNotNull); + + const servicesAdded = Object.entries(entryOperation.services).filter( + ([id]) => !previousOperation.services[id], + ); + const servicesRemoved = Object.entries( + previousOperation.services, + ).filter(([id]) => !entryOperation.services[id]); + + const rotationKeysAdded = entryOperation.rotationKeys.filter( + (x) => !previousOperation.rotationKeys.includes(x), + ); + const rotationKeysRemoved = previousOperation.rotationKeys.filter( + (x) => !entryOperation.rotationKeys.includes(x), + ); + + const verificationMethodsChanged = Object.entries( + entryOperation.verificationMethods, + ) + .map(([id, key]) => { + const previousKey = previousOperation.verificationMethods[id]; + if (!previousKey) return null; + if (key === previousKey) return null; + return { + id, + from: previousKey, + to: key, + }; + }) + .filter(isNotNull); + const verificationMethodsAdded = Object.entries( + entryOperation.verificationMethods, + ).filter(([id]) => !previousOperation.verificationMethods[id]); + const verificationMethodsRemoved = Object.entries( + previousOperation.verificationMethods, + ).filter(([id]) => !entryOperation.verificationMethods[id]); + + return ( + // eslint-disable-next-line react/no-array-index-key +
  3. +

    + Change created at {utcDateFormatter.format(entry.createdAt)} (UTC) +

    +
      + {alsoKnownAsAdded.length === 1 && + alsoKnownAsRemoved.length === 1 ? ( +
    • + Alias changed from{" "} + + {alsoKnownAsRemoved[0]} + {" "} + to{" "} + + {alsoKnownAsAdded[0]} + +
    • + ) : ( + <> + {alsoKnownAsAdded.length > 0 && ( +
    • + Alias added:{" "} + {alsoKnownAsAdded.flatMap((aka) => [ + + {aka} + , + ", ", + ])} +
    • + )} + {alsoKnownAsRemoved.length > 0 && ( +
    • + Alias removed:{" "} + {alsoKnownAsRemoved.flatMap((aka) => [ + + {aka} + , + ", ", + ])} +
    • + )} + + )} + {servicesChanged.length > 0 && + servicesChanged.map((service) => ( + + {!!service.type && ( +
    • + Service "{service.id}" changed type from + " + {service.type.from}" to "{service.type.to} + " +
    • + )} + {!!service.endpoint && ( +
    • + Service "{service.id}" changed endpoint from{" "} + + {service.endpoint.from} + {" "} + to{" "} + {service.endpoint.to} +
    • + )} +
      + ))} + {servicesAdded.length > 0 && ( +
    • + Services added:{" "} + {servicesAdded.flatMap(([id, service]) => [ + + {id} ({service.type}) + , + ", ", + ])} +
    • + )} + {servicesRemoved.length > 0 && ( +
    • + Services removed:{" "} + {servicesRemoved.flatMap(([id, service]) => [ + + {id} ({service.type}) + , + ", ", + ])} +
    • + )} + {rotationKeysAdded.length > 0 && ( +
    • + Rotation keys added:{" "} + {rotationKeysAdded.flatMap((key) => [ + {key}, + ", ", + ])} +
    • + )} + {rotationKeysRemoved.length > 0 && ( +
    • + Rotation keys removed:{" "} + {rotationKeysRemoved.flatMap((key) => [ + {key}, + ", ", + ])} +
    • + )} + {verificationMethodsChanged.length > 0 && + verificationMethodsChanged.map((method) => ( +
    • + Verification method "{method.id}" changed from{" "} + {method.from} to {method.to} +
    • + ))} + {verificationMethodsAdded.length > 0 && ( +
    • + Verification methods added:{" "} + {verificationMethodsAdded.flatMap(([id, key]) => [ + + {key} ("{id}") + , + ", ", + ])} +
    • + )} + {verificationMethodsRemoved.length > 0 && ( +
    • + Verification methods removed:{" "} + {verificationMethodsRemoved.flatMap(([id, key]) => [ + + {key} ("{id}") + , + ", ", + ])} +
    • + )} +
    +
  4. + ); + })} +
+ ); +} + +const PlcLogAuditResponse = z.array( + z.object({ + createdAt: z + .string() + .datetime() + .transform((x) => new Date(x)), + operation: z.union([ + z.object({ + type: z.literal("plc_operation"), + sig: z.string(), + prev: z.string().nullable(), + services: z.record( + z.object({ + type: z.string(), + endpoint: z.string(), + }), + ), + alsoKnownAs: z.array(z.string()), + rotationKeys: z.array(z.string()), + verificationMethods: z.record(z.string()), + }), + z.object({ + type: z.literal("create"), + signingKey: z.string(), + recoveryKey: z.string(), + handle: z.string(), + service: z.string(), + }), + z.object({ + type: z.literal("plc_tombstone"), + }), + ]), + }), +); diff --git a/packages/atproto-browser/app/at/_lib/atproto-json.tsx b/packages/atproto-browser/app/at/_lib/atproto-json.tsx new file mode 100644 index 00000000..18a2e4cd --- /dev/null +++ b/packages/atproto-browser/app/at/_lib/atproto-json.tsx @@ -0,0 +1,170 @@ +import { isDid } from "@atproto/did"; +import Link from "next/link"; +import { AtBlob } from "../../../lib/at-blob"; +import { getAtUriPath } from "@/lib/util"; +import { AtUri } from "@atproto/syntax"; + +function naiveAtUriCheck(atUri: string) { + if (!atUri.startsWith("at://")) { + return false; + } + + // Check there is no whitespace in the URI + return atUri.split(" ").length === 1; +} + +function JSONString({ data }: { data: string }) { + return ( +
+      {naiveAtUriCheck(data) ? (
+        <>
+          "{data}
+          "
+        
+      ) : isDid(data) ? (
+        <>
+          "{data}
+          "
+        
+      ) : URL.canParse(data) ? (
+        <>
+          "
+          
+            {data}
+          
+          "
+        
+      ) : (
+        `"${data}"`
+      )}
+    
+ ); +} + +function JSONNumber({ data }: { data: number }) { + return ( +
+      {data}
+    
+ ); +} + +function JSONBoolean({ data }: { data: boolean }) { + return ( +
+      {data ? "true" : "false"}
+    
+ ); +} + +function JSONNull() { + return ( +
+      null
+    
+ ); +} + +function JSONObject({ + data, + repo, +}: { + data: { [x: string]: JSONType }; + repo: string; +}) { + const rawObj = ( +
+ {Object.entries(data).map(([key, value]) => ( +
+
+
{key}:
+
+
+ +
+
+ ))} +
+ ); + + const parseBlobResult = AtBlob.safeParse(data); + if ( + parseBlobResult.success && + parseBlobResult.data.mimeType.startsWith("image/") + ) { + return ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ View blob content + {rawObj} +
+ + ); + } + + return rawObj; +} + +function JSONArray({ data, repo }: { data: JSONType[]; repo: string }) { + return ( +
    + {data.map((value, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • + +
  • + ))} +
+ ); +} + +export function JSONValue({ data, repo }: { data: JSONType; repo: string }) { + if (typeof data === "string") { + return ; + } + if (typeof data === "number") { + return ; + } + if (typeof data === "boolean") { + return ; + } + if (data === null) { + return ; + } + if (Array.isArray(data)) { + return ; + } + return ; +} + +export type JSONType = + | string + | number + | boolean + | null + | { + [x: string]: JSONType; + } + | JSONType[]; diff --git a/packages/atproto-browser/app/at/_lib/collection-server.tsx b/packages/atproto-browser/app/at/_lib/collection-server.tsx new file mode 100644 index 00000000..8d5632c4 --- /dev/null +++ b/packages/atproto-browser/app/at/_lib/collection-server.tsx @@ -0,0 +1,50 @@ +import { resolveIdentity } from "@/lib/atproto-server"; +import { getPds } from "@atproto/identity"; +import Link from "next/link"; + +export async function DidCollections({ did }: { did: string }) { + const identityResult = await resolveIdentity(did); + if (!identityResult.success) { + throw new Error(`Could not resolve DID: ${did}`); + } + const didDocument = identityResult.identity; + const pds = getPds(didDocument); + if (!pds) { + throw new Error(`No PDS found for DID: ${didDocument.id}`); + } + + const describeRepoUrl = new URL(`${pds}/xrpc/com.atproto.repo.describeRepo`); + describeRepoUrl.searchParams.set("repo", didDocument.id); + const response = await fetch(describeRepoUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch collections: ${response.statusText}. URL: ${describeRepoUrl.toString()}`, + ); + } + + const { collections } = (await response.json()) as { + collections: string[]; + }; + + return ( +
    + {collections.length === 0 ? ( +

    No collections.

    + ) : ( + collections.map((nsid) => { + return ( +
  • + {nsid} +
  • + ); + }) + )} +
+ ); +} diff --git a/packages/atproto-browser/app/at/_lib/collection.tsx b/packages/atproto-browser/app/at/_lib/collection.tsx index 5b86edc1..7d209020 100644 --- a/packages/atproto-browser/app/at/_lib/collection.tsx +++ b/packages/atproto-browser/app/at/_lib/collection.tsx @@ -1,6 +1,7 @@ "use client"; import { listRecords } from "@/lib/atproto"; +import { getAtUriPath } from "@/lib/util"; import { AtUri } from "@atproto/syntax"; import Link from "next/link"; import { Suspense, useState } from "react"; @@ -34,7 +35,7 @@ export function CollectionItems({ const uri = new AtUri(record.uri); return (
  • - {uri.rkey} + {uri.rkey}
  • ); })} diff --git a/packages/atproto-browser/app/at/_lib/did-components.tsx b/packages/atproto-browser/app/at/_lib/did-components.tsx new file mode 100644 index 00000000..18286412 --- /dev/null +++ b/packages/atproto-browser/app/at/_lib/did-components.tsx @@ -0,0 +1,23 @@ +import { ErrorBoundary } from "react-error-boundary"; +import { JSONType, JSONValue } from "./atproto-json"; +import { resolveIdentity } from "@/lib/atproto-server"; +import { DidCollections } from "./collection-server"; + +export async function DidSummary({ did }: { did: string }) { + return ( + <> + Failed to fetch collections for {did}.} + > +

    Collections

    + +
    +

    DID Doc

    + + + ); +} +async function DidDoc({ did }: { did: string }) { + const identityResult = await resolveIdentity(did); + return ; +} diff --git a/packages/atproto-browser/app/at/_lib/uri-bar.tsx b/packages/atproto-browser/app/at/_lib/uri-bar.tsx index 14fdf3c2..40a03f42 100644 --- a/packages/atproto-browser/app/at/_lib/uri-bar.tsx +++ b/packages/atproto-browser/app/at/_lib/uri-bar.tsx @@ -1,19 +1,30 @@ "use client"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useParams, usePathname, useSearchParams } from "next/navigation"; import { AtUriForm } from "../../aturi-form"; export function UriBar() { const searchParams = useSearchParams(); + const pathname = usePathname(); + const params = useParams() as { + identifier?: string; + collection?: string; + rkey?: string; + }; + + const uri = + pathname === "/at" + ? searchParams.get("u") ?? undefined + : `at://${[params.identifier, params.collection, params.rkey] + .map((c) => c && decodeURIComponent(c)) + .filter(Boolean) + .join("/")}`; return (
    🏠 - +
    ); } diff --git a/packages/atproto-browser/app/at/page.tsx b/packages/atproto-browser/app/at/page.tsx index d61f5569..fe84b14a 100644 --- a/packages/atproto-browser/app/at/page.tsx +++ b/packages/atproto-browser/app/at/page.tsx @@ -1,713 +1,12 @@ -import { getHandle, getKey, getPds } from "@atproto/identity"; +import { getAtUriPath } from "@/lib/util"; import { AtUri } from "@atproto/syntax"; -import { isDid } from "@atproto/did"; -import { Fragment, Suspense } from "react"; -import Link from "next/link"; -import { AtBlob } from "./_lib/at-blob"; -import { CollectionItems } from "./_lib/collection"; -import { SWRConfig } from "swr"; -import { listRecords } from "@/lib/atproto"; -import { verifyRecords } from "@atproto/repo"; -import { ErrorBoundary } from "react-error-boundary"; -import { z } from "zod"; -import { resolveIdentity } from "@/lib/atproto-server"; +import { redirect } from "next/navigation"; -export default async function AtPage({ +export default function AtPage({ searchParams, }: { searchParams: Record; }) { const uri = new AtUri(searchParams.u!); - - const identityResult = await resolveIdentity(uri.hostname); - if (!identityResult.success) { - return
    {identityResult.error}
    ; - } - - const didDocument = identityResult.identity; - - const pds = getPds(didDocument); - if (!pds) { - return
    No PDS found for DID: {didDocument.id}
    ; - } - - const handle = getHandle(didDocument) ?? ``; - - if (uri.pathname === "/" || uri.pathname === "") { - return ( - <> -

    - {handle} ({didDocument.id}) -

    - - - Loading history...

    }> - Failed to fetch history.

    }> -

    History

    - -
    -
    - - ); - } - - if (!uri.rkey) { - const fetchKey = - `listCollections/collection:${uri.collection}/cursor:none` as const; - return ( -
    -

    - {handle}'s {uri.collection} records{" "} - - 🛜 - -

    -
      - - - -
    -
    - ); - } - - const getRecordUrl = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); - getRecordUrl.searchParams.set("repo", didDocument.id); - getRecordUrl.searchParams.set("collection", uri.collection); - getRecordUrl.searchParams.set("rkey", uri.rkey); - - const response = await fetch(getRecordUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - return ( -
    - Failed to fetch record: {response.statusText}. URL:{" "} - {getRecordUrl.toString()} -
    - ); - } - - const record = (await response.json()) as JSONType; - - return ( -
    -
    - - Author: {handle} ( - {didDocument.id}) - - - - -
    -

    - Record - - 🤔 - - } - > - - -

    - -
    - ); -} - -async function RecordVerificationBadge({ - did, - collection, - rkey, -}: { - did: string; - collection: string; - rkey: string; -}) { - const identityResult = await resolveIdentity(did); - if (!identityResult.success) { - throw new Error(identityResult.error); - } - const didDoc = identityResult.identity; - const pds = getPds(didDoc); - if (!pds) { - return ; - } - - const verifyRecordsUrl = new URL(`${pds}/xrpc/com.atproto.sync.getRecord`); - verifyRecordsUrl.searchParams.set("did", did); - verifyRecordsUrl.searchParams.set("collection", collection); - verifyRecordsUrl.searchParams.set("rkey", rkey); - - const response = await fetch(verifyRecordsUrl, { - headers: { - accept: "application/vnd.ipld.car", - }, - }); - - if (!response.ok) { - return ( - - ❌ - - ); - } - const car = new Uint8Array(await response.arrayBuffer()); - const key = getKey(didDoc); - if (!key) { - return ; - } - - try { - await verifyRecords(car, did, key); - return 🔒; - } catch (e) { - if (e instanceof Error) { - return ; - } else { - return ; - } - } -} - -async function DidSummary({ did }: { did: string }) { - return ( - <> - Failed to fetch collections for {did}.} - > -

    Collections

    - -
    -

    DID Doc

    - - - ); + redirect(getAtUriPath(uri)); } - -async function DidDoc({ did }: { did: string }) { - const identityResult = await resolveIdentity(did); - return ; -} - -async function DidCollections({ did }: { did: string }) { - const identityResult = await resolveIdentity(did); - if (!identityResult.success) { - throw new Error(`Could not resolve DID: ${did}`); - } - const didDocument = identityResult.identity; - const pds = getPds(didDocument); - if (!pds) { - throw new Error(`No PDS found for DID: ${didDocument.id}`); - } - - const describeRepoUrl = new URL(`${pds}/xrpc/com.atproto.repo.describeRepo`); - describeRepoUrl.searchParams.set("repo", didDocument.id); - const response = await fetch(describeRepoUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch collections: ${response.statusText}. URL: ${describeRepoUrl.toString()}`, - ); - } - - const { collections } = (await response.json()) as { - collections: string[]; - }; - - return ( -
      - {collections.length === 0 ? ( -

      No collections.

      - ) : ( - collections.map((nsid) => { - const collectionUri = `at://${[did, nsid].join("/")}`; - - return ( -
    • - {nsid} -
    • - ); - }) - )} -
    - ); -} - -const PlcLogAuditResponse = z.array( - z.object({ - createdAt: z - .string() - .datetime() - .transform((x) => new Date(x)), - operation: z.union([ - z.object({ - type: z.literal("plc_operation"), - sig: z.string(), - prev: z.string().nullable(), - services: z.record( - z.object({ - type: z.string(), - endpoint: z.string(), - }), - ), - alsoKnownAs: z.array(z.string()), - rotationKeys: z.array(z.string()), - verificationMethods: z.record(z.string()), - }), - z.object({ - type: z.literal("create"), - signingKey: z.string(), - recoveryKey: z.string(), - handle: z.string(), - service: z.string(), - }), - z.object({ - type: z.literal("plc_tombstone"), - }), - ]), - }), -); - -const utcDateFormatter = new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", - timeStyle: "short", - timeZone: "UTC", -}); - -function isNotNull(x: T | null): x is T { - return x !== null; -} - -async function DidHistory({ did }: { did: string }) { - const response = await fetch(`https://plc.directory/${did}/log/audit`); - if (!response.ok) { - throw new Error(`Failed to fetch history: ${response.statusText}`); - } - - const auditLog = PlcLogAuditResponse.parse(await response.json()); - - return ( -
      - {auditLog.map((previous, index) => { - const previousOperation = previous.operation; - if (previousOperation.type !== "plc_operation") { - return ( - // eslint-disable-next-line react/no-array-index-key -
    1. - Change created at {utcDateFormatter.format(previous.createdAt)}{" "} - (UTC) of type "{previousOperation.type}". -
    2. - ); - } - const entry = auditLog[index + 1]; - if (!entry) { - return null; - } - const entryOperation = entry.operation; - if (entryOperation.type !== "plc_operation") { - return null; - } - - const alsoKnownAsAdded = entryOperation.alsoKnownAs.filter( - (x) => !previousOperation.alsoKnownAs.includes(x), - ); - const alsoKnownAsRemoved = previousOperation.alsoKnownAs.filter( - (x) => !entryOperation.alsoKnownAs.includes(x), - ); - - const servicesChanged = Object.entries(entryOperation.services) - .map(([id, service]) => { - const previousService = previousOperation.services[id]; - if (!previousService) return null; - return { - id, - type: - service.type !== previousService.type - ? { - from: previousService.type, - to: service.type, - } - : null, - endpoint: - service.endpoint !== previousService.endpoint - ? { - from: previousService.endpoint, - to: service.endpoint, - } - : null, - }; - }) - .filter(isNotNull); - - const servicesAdded = Object.entries(entryOperation.services).filter( - ([id]) => !previousOperation.services[id], - ); - const servicesRemoved = Object.entries( - previousOperation.services, - ).filter(([id]) => !entryOperation.services[id]); - - const rotationKeysAdded = entryOperation.rotationKeys.filter( - (x) => !previousOperation.rotationKeys.includes(x), - ); - const rotationKeysRemoved = previousOperation.rotationKeys.filter( - (x) => !entryOperation.rotationKeys.includes(x), - ); - - const verificationMethodsChanged = Object.entries( - entryOperation.verificationMethods, - ) - .map(([id, key]) => { - const previousKey = previousOperation.verificationMethods[id]; - if (!previousKey) return null; - if (key === previousKey) return null; - return { - id, - from: previousKey, - to: key, - }; - }) - .filter(isNotNull); - const verificationMethodsAdded = Object.entries( - entryOperation.verificationMethods, - ).filter(([id]) => !previousOperation.verificationMethods[id]); - const verificationMethodsRemoved = Object.entries( - previousOperation.verificationMethods, - ).filter(([id]) => !entryOperation.verificationMethods[id]); - - return ( - // eslint-disable-next-line react/no-array-index-key -
    3. -

      - Change created at {utcDateFormatter.format(entry.createdAt)} (UTC) -

      -
        - {alsoKnownAsAdded.length === 1 && - alsoKnownAsRemoved.length === 1 ? ( -
      • - Alias changed from{" "} - - {alsoKnownAsRemoved[0]} - {" "} - to{" "} - - {alsoKnownAsAdded[0]} - -
      • - ) : ( - <> - {alsoKnownAsAdded.length > 0 && ( -
      • - Alias added:{" "} - {alsoKnownAsAdded.flatMap((aka) => [ - - {aka} - , - ", ", - ])} -
      • - )} - {alsoKnownAsRemoved.length > 0 && ( -
      • - Alias removed:{" "} - {alsoKnownAsRemoved.flatMap((aka) => [ - - {aka} - , - ", ", - ])} -
      • - )} - - )} - {servicesChanged.length > 0 && - servicesChanged.map((service) => ( - - {!!service.type && ( -
      • - Service "{service.id}" changed type from - " - {service.type.from}" to "{service.type.to} - " -
      • - )} - {!!service.endpoint && ( -
      • - Service "{service.id}" changed endpoint from{" "} - - {service.endpoint.from} - {" "} - to{" "} - {service.endpoint.to} -
      • - )} -
        - ))} - {servicesAdded.length > 0 && ( -
      • - Services added:{" "} - {servicesAdded.flatMap(([id, service]) => [ - - {id} ({service.type}) - , - ", ", - ])} -
      • - )} - {servicesRemoved.length > 0 && ( -
      • - Services removed:{" "} - {servicesRemoved.flatMap(([id, service]) => [ - - {id} ({service.type}) - , - ", ", - ])} -
      • - )} - {rotationKeysAdded.length > 0 && ( -
      • - Rotation keys added:{" "} - {rotationKeysAdded.flatMap((key) => [ - {key}, - ", ", - ])} -
      • - )} - {rotationKeysRemoved.length > 0 && ( -
      • - Rotation keys removed:{" "} - {rotationKeysRemoved.flatMap((key) => [ - {key}, - ", ", - ])} -
      • - )} - {verificationMethodsChanged.length > 0 && - verificationMethodsChanged.map((method) => ( -
      • - Verification method "{method.id}" changed from{" "} - {method.from} to {method.to} -
      • - ))} - {verificationMethodsAdded.length > 0 && ( -
      • - Verification methods added:{" "} - {verificationMethodsAdded.flatMap(([id, key]) => [ - - {key} ("{id}") - , - ", ", - ])} -
      • - )} - {verificationMethodsRemoved.length > 0 && ( -
      • - Verification methods removed:{" "} - {verificationMethodsRemoved.flatMap(([id, key]) => [ - - {key} ("{id}") - , - ", ", - ])} -
      • - )} -
      -
    4. - ); - })} -
    - ); -} - -function naiveAtUriCheck(atUri: string) { - if (!atUri.startsWith("at://")) { - return false; - } - - // Check there is no whitespace in the URI - return atUri.split(" ").length === 1; -} - -function JSONString({ data }: { data: string }) { - return ( -
    -      {naiveAtUriCheck(data) ? (
    -        <>
    -          "{data}
    -          "
    -        
    -      ) : isDid(data) ? (
    -        <>
    -          "{data}
    -          "
    -        
    -      ) : URL.canParse(data) ? (
    -        <>
    -          "
    -          
    -            {data}
    -          
    -          "
    -        
    -      ) : (
    -        `"${data}"`
    -      )}
    -    
    - ); -} - -function JSONNumber({ data }: { data: number }) { - return ( -
    -      {data}
    -    
    - ); -} - -function JSONBoolean({ data }: { data: boolean }) { - return ( -
    -      {data ? "true" : "false"}
    -    
    - ); -} - -function JSONNull() { - return ( -
    -      null
    -    
    - ); -} - -function JSONObject({ - data, - repo, -}: { - data: { [x: string]: JSONType }; - repo: string; -}) { - const rawObj = ( -
    - {Object.entries(data).map(([key, value]) => ( -
    -
    -
    {key}:
    -
    -
    - -
    -
    - ))} -
    - ); - - const parseBlobResult = AtBlob.safeParse(data); - if ( - parseBlobResult.success && - parseBlobResult.data.mimeType.startsWith("image/") - ) { - return ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - -
    - View blob content - {rawObj} -
    - - ); - } - - return rawObj; -} - -function JSONArray({ data, repo }: { data: JSONType[]; repo: string }) { - return ( -
      - {data.map((value, index) => ( - // eslint-disable-next-line react/no-array-index-key -
    • - -
    • - ))} -
    - ); -} - -function JSONValue({ data, repo }: { data: JSONType; repo: string }) { - if (typeof data === "string") { - return ; - } - if (typeof data === "number") { - return ; - } - if (typeof data === "boolean") { - return ; - } - if (data === null) { - return ; - } - if (Array.isArray(data)) { - return ; - } - return ; -} - -type JSONType = - | string - | number - | boolean - | null - | { - [x: string]: JSONType; - } - | JSONType[]; diff --git a/packages/atproto-browser/app/collection-rss/route.tsx b/packages/atproto-browser/app/collection-rss/route.tsx index 917a90a6..5e72b546 100644 --- a/packages/atproto-browser/app/collection-rss/route.tsx +++ b/packages/atproto-browser/app/collection-rss/route.tsx @@ -1,5 +1,6 @@ import { listRecords } from "@/lib/atproto"; import { resolveIdentity } from "@/lib/atproto-server"; +import { getAtUriPath } from "@/lib/util"; import { getHandle, getPds } from "@atproto/identity"; import { AtUri } from "@atproto/syntax"; @@ -47,7 +48,7 @@ export async function GET(request: Request) { month: "2-digit", day: "2-digit", }).format(new Date())} - ${ORIGIN}/at?u=${uri.toString()} + ${ORIGIN}${getAtUriPath(uri)} ${record.cid} `.trim(), diff --git a/packages/atproto-browser/app/page.tsx b/packages/atproto-browser/app/page.tsx index c7ca4651..21d235b4 100644 --- a/packages/atproto-browser/app/page.tsx +++ b/packages/atproto-browser/app/page.tsx @@ -7,8 +7,7 @@ export const metadata: Metadata = { description: "Browse the atmosphere.", }; -const EXAMPLE_URI = - "at://did:plc:2xau7wbgdq4phuou2ypwuen7/app.bsky.feed.like/3kyutnrmg3s2r"; +const EXAMPLE_PATH = "tom-sherman.com/app.bsky.feed.like/3kyutnrmg3s2r"; export default function Home() { return ( @@ -18,7 +17,7 @@ export default function Home() {

    - eg. {EXAMPLE_URI} + eg. at://{EXAMPLE_PATH}

    ); diff --git a/packages/atproto-browser/app/at/_lib/at-blob.ts b/packages/atproto-browser/lib/at-blob.ts similarity index 100% rename from packages/atproto-browser/app/at/_lib/at-blob.ts rename to packages/atproto-browser/lib/at-blob.ts diff --git a/packages/atproto-browser/lib/atproto-server.ts b/packages/atproto-browser/lib/atproto-server.ts index 4a1b3ab2..80c39cb0 100644 --- a/packages/atproto-browser/lib/atproto-server.ts +++ b/packages/atproto-browser/lib/atproto-server.ts @@ -48,20 +48,21 @@ export async function resolveIdentity( ): Promise< { success: true; identity: DidDocument } | { success: false; error: string } > { + const decoded = decodeURIComponent(didOrHandle); let didStr; - if (isValidHandle(didOrHandle)) { - didStr = await resolveHandle(didOrHandle).catch(() => undefined); + if (isValidHandle(decoded)) { + didStr = await resolveHandle(decoded).catch(() => undefined); if (!didStr) { return { success: false, - error: `Could not resolve did from handle: ${didOrHandle}`, + error: `Could not resolve did from handle: ${decoded}`, }; } } else { - if (!isDid(didOrHandle)) { - return { success: false, error: `Invalid DID: ${didOrHandle}` }; + if (!isDid(decoded)) { + return { success: false, error: `Invalid DID: ${decoded}` }; } - didStr = didOrHandle; + didStr = decoded; } const didDocument = await resolveDid(didStr); diff --git a/packages/atproto-browser/lib/util.ts b/packages/atproto-browser/lib/util.ts new file mode 100644 index 00000000..3a92be65 --- /dev/null +++ b/packages/atproto-browser/lib/util.ts @@ -0,0 +1,18 @@ +import { AtUri } from "@atproto/syntax"; + +export const utcDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", +}); + +export function isNotNull(x: T | null): x is T { + return x !== null; +} + +export function getAtUriPath(uri: AtUri): string { + return `/at/${[uri.host, uri.collection, uri.rkey] + .filter(Boolean) + .map((c) => c && decodeURIComponent(c)) + .join("/")}`; +}