diff --git a/packages/frontpage/app/(app)/feed/page.tsx b/packages/frontpage/app/(app)/feed/page.tsx index bb77d735..392f1954 100644 --- a/packages/frontpage/app/(app)/feed/page.tsx +++ b/packages/frontpage/app/(app)/feed/page.tsx @@ -1,5 +1,10 @@ -import { getFeed } from "@/lib/data/feed-resolver"; +import { isDid } from "@/lib/data/atproto/did"; +import { getVerifiedHandle } from "@/lib/data/atproto/identity"; +import { resolveFeed } from "@/lib/data/feed-resolver"; +import { getMoreFeedPostsAction } from "@/lib/feed-action"; +import { InfiniteList } from "@/lib/infinite-list"; import { AtUri } from "@atproto/syntax"; +import Link from "next/link"; import { redirect } from "next/navigation"; export default async function FeedPage({ searchParams }: PageProps<"/feed">) { @@ -15,7 +20,43 @@ export default async function FeedPage({ searchParams }: PageProps<"/feed">) { return
Invalid URI
; } - const posts = await getFeed(uri); + const [initialData, feedResult] = await Promise.all([ + getMoreFeedPostsAction(uri.toString(), null), + resolveFeed(uri), + ]); - return
{JSON.stringify(posts, null, 2)}
; + if (!feedResult.ok) { + return ( +
Error loading feed: {feedResult.error.message}
+ ); + } + + const handle = isDid(uri.host) + ? ((await getVerifiedHandle(uri.host)) ?? "handle.invalid") + : uri.host; + + const { displayName, description } = feedResult.data; + + return ( +
+
+

{displayName}

+ + + @{handle} + + + {description ?

{description}

: null} +
+ +
+ ); } diff --git a/packages/frontpage/lib/data/feed-resolver.ts b/packages/frontpage/lib/data/feed-resolver.ts index 045e6b37..ab1daa31 100644 --- a/packages/frontpage/lib/data/feed-resolver.ts +++ b/packages/frontpage/lib/data/feed-resolver.ts @@ -1,11 +1,15 @@ import "server-only"; import type { AtUri } from "@atproto/syntax"; -import { getDidDoc, parseDid, type DID } from "@/lib/data/atproto/did"; +import { + getDidDoc, + getPdsUrl, + parseDid, + type DID, +} from "@/lib/data/atproto/did"; import { getLocalFeedSkeleton } from "@/lib/data/db/feed-skeleton"; import { hydratePosts, type HydratedPost } from "@/lib/data/db/post"; import { assertPublicHostname } from "@/lib/data/ssrf"; -import { invariant } from "@/lib/utils"; import { FyiFrontpageFeedGenerator, type FyiFrontpageFeedGetFeedSkeleton, @@ -15,6 +19,8 @@ import { FEED_REGISTRY, type FeedSlug } from "@/lib/feed-constants"; import { getAtprotoClient, nsids } from "@/lib/data/atproto/repo"; import { publicConfig } from "../config/public-config"; import { FRONTPAGE_ATPROTO_HANDLE } from "../constants"; +import { getDidFromHandleOrDid } from "./atproto/identity"; +import { cache } from "react"; export type FeedError = | { code: "InvalidCollection"; message: string } @@ -22,13 +28,17 @@ export type FeedError = | { code: "ExternalError"; message: string } | { code: "InvalidResponse"; message: string }; -export type FeedSkeletonResult = - | { ok: true; data: FyiFrontpageFeedGetFeedSkeleton.OutputSchema } - | { ok: false; error: FeedError }; +type Result = { ok: true; data: T } | { ok: false; error: E }; + +export type FeedSkeletonResult = Result< + FyiFrontpageFeedGetFeedSkeleton.OutputSchema, + FeedError +>; -export type FeedResult = - | { ok: true; posts: HydratedPost[]; cursor?: string } - | { ok: false; error: FeedError }; +export type FeedResult = Result< + { posts: HydratedPost[]; cursor?: string }, + FeedError +>; // Internal page size for server action callers. The XRPC route uses its own // DEFAULT_SKELETON_LIMIT and always passes an explicit limit, so this default @@ -43,7 +53,7 @@ export async function getFeed( const posts = await hydratePosts(skeletonResult.data.feed.map((s) => s.post)); - return { ok: true, posts, cursor: skeletonResult.data.cursor }; + return { ok: true, data: { posts, cursor: skeletonResult.data.cursor } }; } async function getFeedSkeleton( @@ -105,18 +115,17 @@ async function getExternalSkeleton( cursor: string | undefined, limit: number, ): Promise { - const generatorRecord = await fetchGeneratorRecord(feedUri); - const serviceDid = parseDid(generatorRecord.did); - invariant( - serviceDid, - `Generator record contains invalid DID: ${generatorRecord.did}`, - ); - - const serviceEndpoint = await resolveServiceEndpoint(serviceDid); + const generatorResult = await resolveFeed(feedUri); + if (!generatorResult.ok) { + return { + ok: false, + error: generatorResult.error, + }; + } const url = new URL( `/xrpc/${nsids.FyiFrontpageFeedGetFeedSkeleton}`, - serviceEndpoint, + generatorResult.data.service, ); url.searchParams.set("feed", feedUri.toString()); url.searchParams.set("limit", String(limit)); @@ -160,41 +169,129 @@ async function getExternalSkeleton( }; } -async function fetchGeneratorRecord(feedUri: AtUri): Promise<{ did: string }> { - const client = getAtprotoClient(); - - const result = await client.com.atproto.repo.getRecord({ - repo: feedUri.host, - collection: feedUri.collection, - rkey: feedUri.rkey, - }); - - const validated = FyiFrontpageFeedGenerator.validateRecord(result.data.value); - invariant(validated.success, "Invalid generator record"); - - return { did: validated.value.did }; -} +type FeedResolution = { + serviceDid: DID; + service: string; + displayName: string; + description?: string; +}; + +export const resolveFeed = cache( + async (feedUri: AtUri): Promise> => { + const generatorDid = await getDidFromHandleOrDid(feedUri.host); + if (!generatorDid) { + return { + ok: false, + error: { + code: "UnknownFeed", + message: `Could not resolve DID for feed generator ${feedUri.host}`, + }, + }; + } + const generatorPdsUrl = await getPdsUrl(generatorDid); + if (!generatorPdsUrl) { + return { + ok: false, + error: { + code: "UnknownFeed", + message: `Could not find PDS for feed generator DID ${generatorDid}`, + }, + }; + } + const client = getAtprotoClient(generatorPdsUrl); + + const result = await client.com.atproto.repo.getRecord({ + repo: feedUri.host, + collection: feedUri.collection, + rkey: feedUri.rkey, + }); + + const validated = FyiFrontpageFeedGenerator.validateRecord( + result.data.value, + ); + if (!validated.success) { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `Feed generator record failed validation: ${validated.error.message}`, + }, + }; + } + + const serviceDid = parseDid(validated.value.did); + if (!serviceDid) { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `Feed generator record contains invalid DID: ${validated.value.did}`, + }, + }; + } + const serviceDidDoc = await getDidDoc(serviceDid); + const service = serviceDidDoc.service?.find( + (s) => s.type === "FrontpageFeedGenerator", + ); + if (!service || typeof service.serviceEndpoint !== "string") { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `No FrontpageFeedGenerator service found in DID document for ${validated.value.did}`, + }, + }; + } + + const serviceUrl = safeParseUrl(service.serviceEndpoint); + if (!serviceUrl) { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `Invalid serviceEndpoint URL in DID document for ${validated.value.did}: ${service.serviceEndpoint}`, + }, + }; + } + + if (serviceUrl.protocol !== "https:") { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `Service endpoint must use HTTPS in DID document for ${validated.value.did}: ${service.serviceEndpoint}`, + }, + }; + } + + try { + assertPublicHostname(serviceUrl.hostname); + } catch (_) { + return { + ok: false, + error: { + code: "InvalidResponse", + message: `Service endpoint hostname is not allowed in DID document for ${validated.value.did}: ${serviceUrl.hostname}`, + }, + }; + } -async function resolveServiceEndpoint(did: DID): Promise { - const didDoc = await getDidDoc(did); - const service = didDoc.service?.find( - (serviceEntry) => - serviceEntry.type === "FrontpageFeedGenerator" || - serviceEntry.type === "BskyFeedGenerator", - ); - - invariant( - service && typeof service.serviceEndpoint === "string", - `No feed generator service found in DID document for ${did}`, - ); - - const url = new URL(service.serviceEndpoint); - invariant( - url.protocol === "https:", - "Feed generator endpoint must use HTTPS", - ); - - assertPublicHostname(url.hostname); + return { + ok: true, + data: { + serviceDid, + service: service.serviceEndpoint, + displayName: validated.value.displayName, + description: validated.value.description, + }, + }; + }, +); - return service.serviceEndpoint; +function safeParseUrl(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } } diff --git a/packages/frontpage/lib/feed-action.tsx b/packages/frontpage/lib/feed-action.tsx index 9b5f30dd..d200d026 100644 --- a/packages/frontpage/lib/feed-action.tsx +++ b/packages/frontpage/lib/feed-action.tsx @@ -1,20 +1,12 @@ import { getFeed } from "@/lib/data/feed-resolver"; import { PostCard } from "@/app/(app)/_components/post-card"; -import { FEED_URIS } from "./feed-constants"; import { AtUri, type AtUriString } from "@atproto/syntax"; -const ALLOWED_FEED_URIS = new Set( - Object.values(FEED_URIS).map((uri) => uri.toString()), -); - export async function getMoreFeedPostsAction( feedUriStr: AtUriString, cursor: string | null, ) { "use server"; - if (!ALLOWED_FEED_URIS.has(feedUriStr)) { - throw new Error("Unknown feed"); - } const feedUri = new AtUri(feedUriStr); @@ -23,7 +15,7 @@ export async function getMoreFeedPostsAction( throw new Error(result.error.message); } - const { posts, cursor: nextCursor } = result; + const { posts, cursor: nextCursor } = result.data; return { content: (