Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/frontpage/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ import {
} from "@repo/frontpage-oauth";
import { getRootHost } from "./navigation";
import { invariant } from "./utils";

const USER_AGENT = "appview/@frontpage.fyi (@tom-sherman.com)";
import { FRONTPAGE_APPVIEW_USER_AGENT } from "./constants";

export const getPrivateJwk = cache(() =>
importJWK(
Expand Down Expand Up @@ -104,7 +103,7 @@ export function oauthDiscoveryRequest(url: URL) {
return discoveryRequest(url, {
algorithm: "oauth2",
headers: new Headers({
"User-Agent": USER_AGENT,
"User-Agent": FRONTPAGE_APPVIEW_USER_AGENT,
}),
});
}
Expand All @@ -120,7 +119,7 @@ export async function oauthProtectedMetadataRequest(did: DID) {

const response = await fetch(`${pds}/.well-known/oauth-protected-resource`, {
headers: {
"User-Agent": USER_AGENT,
"User-Agent": FRONTPAGE_APPVIEW_USER_AGENT,
},
});
if (response.status !== 200) {
Expand Down
2 changes: 2 additions & 0 deletions packages/frontpage/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const FRONTPAGE_ATPROTO_HANDLE = "frontpage.fyi";
export const FRONTPAGE_APPVIEW_USER_AGENT =
"appview/@frontpage.fyi (@frontpage.fyi, @tom.sherman.is)";
57 changes: 27 additions & 30 deletions packages/frontpage/lib/data/atproto/did.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { cache } from "react";
import { z } from "zod";
import { PlcDidDocumentResolver } from "@atcute/identity-resolver";
import type { DidDocument } from "@atcute/identity";
import { FRONTPAGE_APPVIEW_USER_AGENT } from "@/lib/constants";

type Brand<K, T> = K & { __brand: T };
export type DID = Brand<string, "DID">;
export type DID = Brand<`did:plc:${string}`, "DID">;

export function isDid(s: string): s is DID {
// We don't support did:web yet
Expand All @@ -20,40 +23,34 @@ export function parseDid(s: string): DID | null {
return s;
}

export const getDidDoc = cache(async (did: DID) => {
const url = process.env.PLC_DIRECTORY_URL ?? "https://plc.directory";
const response = await fetch(`${url}/${did}`, {
next: {
// TODO: Also revalidate this when we receive an identity change event
// That would allow us to extend the revalidation time to 1 day
revalidate: 60 * 60, // 1 hour
},
});

if (!response.ok) {
throw new Error(`Failed to fetch DID document for ${did}`);
}
const didResolver = new PlcDidDocumentResolver({
apiUrl: process.env.PLC_DIRECTORY_URL ?? "https://plc.directory",
fetch: (request) =>
fetch(request, {
headers: {
"User-Agent": FRONTPAGE_APPVIEW_USER_AGENT,
},
next: {
// TODO: Also revalidate this when we receive an identity change event
// That would allow us to extend the revalidation time to 1 day
revalidate: 60 * 60, // 1 hour
},
}),
});

return PlcDocument.parse(await response.json());
export const getDidDoc = cache(async (did: DID): Promise<DidDocument> => {
const resolution = await didResolver.resolve(did);
return resolution;
});

export const getPdsUrl = cache(async (did: DID) => {
const plc = await getDidDoc(did);

return (
plc.service.find((s) => s.type === "AtprotoPersonalDataServer")
?.serviceEndpoint ?? null
const service = plc.service?.find(
(s) => s.type === "AtprotoPersonalDataServer",
);
});

const PlcDocument = z.object({
id: z.string(),
alsoKnownAs: z.array(z.string()),
service: z.array(
z.object({
id: z.string(),
type: z.string(),
serviceEndpoint: z.string(),
}),
),
// TODO: Investigate and handle the other possible types of serviceEndpoint (eg. Record<string, string>)
return typeof service?.serviceEndpoint === "string"
? service.serviceEndpoint
: null;
});
148 changes: 58 additions & 90 deletions packages/frontpage/lib/data/atproto/identity.ts
Original file line number Diff line number Diff line change
@@ -1,121 +1,89 @@
import { cache } from "react";
import { type DID, getDidDoc, isDid, parseDid } from "./did";
import { unstable_cache } from "next/cache";
import { z } from "zod";

export const getVerifiedDid = cache(async (handle: string) => {
const [dnsDid, httpDid] = await Promise.all([
getAtprotoDidFromDns(handle).catch((_) => {
return null;
import { type DID, getDidDoc, isDid } from "./did";
import {
CompositeHandleResolver,
DohJsonHandleResolver,
WellKnownHandleResolver,
} from "@atcute/identity-resolver";
import { FRONTPAGE_APPVIEW_USER_AGENT } from "@/lib/constants";
import { isValidHandle } from "@atproto/syntax";

const handleResolver = new CompositeHandleResolver({
strategy: "both",
methods: {
dns: new DohJsonHandleResolver({
dohUrl: "https://cloudflare-dns.com/dns-query",
fetch: (request) =>
fetch(request, {
headers: {
"User-Agent": FRONTPAGE_APPVIEW_USER_AGENT,
Accept: "application/dns-json",
},
next: {
revalidate: 60 * 60 * 24, // 24 hours
},
}),
}),
getAtprotoFromHttps(handle).catch((_) => {
return null;
http: new WellKnownHandleResolver({
fetch: (request) => {
const signal = AbortSignal.timeout(1500);
return fetch(request, {
signal,
headers: {
"User-Agent": FRONTPAGE_APPVIEW_USER_AGENT,
},
next: {
revalidate: 60 * 60 * 24, // 24 hours
},
});
},
}),
]);
},
});

if (dnsDid && httpDid && dnsDid !== httpDid) {
const getVerifiedDidFromHandle = cache(async (handle: string) => {
if (!isValidHandle(handle)) {
return null;
}
const did = await handleResolver.resolve(handle).catch(() => null);

if (!did) return null;

const did = dnsDid ?? (httpDid ? parseDid(httpDid) : null);
if (!did) {
if (!isDid(did)) {
return null;
}

const plcDoc = await getDidDoc(did);
const plcHandle = plcDoc.alsoKnownAs
.find((handle) => handle.startsWith("at://"))
const didDoc = await getDidDoc(did);
const didDocHandle = didDoc.alsoKnownAs
?.find((handle) => handle.startsWith("at://"))
?.replace("at://", "");

if (!plcHandle) return null;
if (!didDocHandle) return null;

return plcHandle.toLowerCase() === handle.toLowerCase() ? did : null;
return didDocHandle.toLowerCase() === handle.toLowerCase() ? did : null;
});

const DnsQueryResponse = z.object({
Answer: z.array(
z.object({
name: z.string(),
type: z.number(),
TTL: z.number(),
data: z.string(),
}),
),
});

// See https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
async function getAtprotoDidFromDns(handle: string) {
const url = new URL("https://cloudflare-dns.com/dns-query");
url.searchParams.set("type", "TXT");
url.searchParams.set("name", `_atproto.${handle}`);

const response = await fetch(url, {
headers: {
Accept: "application/dns-json",
},
next: {
revalidate: 60 * 60 * 24, // 24 hours
},
});

const { Answer } = DnsQueryResponse.parse(await response.json());
// Answer[0].data is "\"did=...\"" (with quotes)
const val = Answer[0]?.data
? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
JSON.parse(Answer[0]?.data).split("did=")[1]
: null;

return val
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
parseDid(val)
: null;
}

const getAtprotoFromHttps = unstable_cache(
async (handle: string) => {
let res;
const timeoutSignal = AbortSignal.timeout(1500);
try {
res = await fetch(`https://${handle}/.well-known/atproto-did`, {
signal: timeoutSignal,
});
} catch (_e) {
// We're caching failures here, we should at some point invalidate the cache by listening to handle changes on the network
return null;
}

if (!res.ok) {
return null;
}
return parseDid((await res.text()).trim());
},
["did", "https"],
{
revalidate: 60 * 60 * 24, // 24 hours
},
);

/**
* Returns the DID of the the handle or the DID itself if it's a DID. Or null if the handle doesn't resolve to a DID.
* Returns the DID of the handle or the DID itself if it's a DID. Or null if the handle doesn't resolve to a DID.
*/
export const getDidFromHandleOrDid = cache(async (handleOrDid: string) => {
const decodedHandleOrDid = decodeURIComponent(handleOrDid);
if (isDid(decodedHandleOrDid)) {
return decodedHandleOrDid;
}

return getVerifiedDid(decodedHandleOrDid);
return getVerifiedDidFromHandle(decodedHandleOrDid);
});

export const getVerifiedHandle = cache(async (did: DID) => {
const plcDoc = await getDidDoc(did);
const plcHandle = plcDoc.alsoKnownAs
.find((handle) => handle.startsWith("at://"))
const didDoc = await getDidDoc(did);
const didDocHandle = didDoc.alsoKnownAs
?.find((handle) => handle.startsWith("at://"))
?.replace("at://", "");

if (!plcHandle) return null;
if (!didDocHandle) return null;

const resolvedDid = await getVerifiedDid(plcHandle);
const resolvedDid = await getVerifiedDidFromHandle(didDocHandle);

return resolvedDid ? plcHandle : null;
return resolvedDid ? didDocHandle : null;
});
2 changes: 2 additions & 0 deletions packages/frontpage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"generate-local-env": "node scripts/generate-local-env.mts"
},
"dependencies": {
"@atcute/identity": "catalog:",
"@atcute/identity-resolver": "catalog:",
"@atproto/common-web": "^0.3.2",
"@atproto/oauth-types": "^0.1.5",
"@atproto/syntax": "catalog:",
Expand Down
Loading