Skip to content
Draft
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
47 changes: 44 additions & 3 deletions packages/frontpage/app/(app)/feed/page.tsx
Original file line number Diff line number Diff line change
@@ -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">) {
Expand All @@ -15,7 +20,43 @@ export default async function FeedPage({ searchParams }: PageProps<"/feed">) {
return <div className="p-4">Invalid URI</div>;
}

const posts = await getFeed(uri);
const [initialData, feedResult] = await Promise.all([
getMoreFeedPostsAction(uri.toString(), null),
resolveFeed(uri),
]);

return <pre>{JSON.stringify(posts, null, 2)}</pre>;
if (!feedResult.ok) {
return (
<div className="p-4">Error loading feed: {feedResult.error.message}</div>
);
}

const handle = isDid(uri.host)
? ((await getVerifiedHandle(uri.host)) ?? "handle.invalid")
: uri.host;

const { displayName, description } = feedResult.data;

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold">{displayName}</h1>
<sub className="text-sm">
<Link
href={`/profile/${uri.host}`}
className="text-indigo-600 hover:underline dark:text-indigo-400"
>
@{handle}
</Link>
</sub>
{description ? <p className="text-gray-600">{description}</p> : null}
</div>
<InfiniteList
cacheKey={`feed:${uri.toString()}`}
getMoreItemsAction={getMoreFeedPostsAction.bind(null, uri.toString())}
fallback={initialData}
emptyMessage="No posts in this feed"
/>
</div>
);
}
203 changes: 150 additions & 53 deletions packages/frontpage/lib/data/feed-resolver.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,20 +19,26 @@ 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 }
| { code: "UnknownFeed"; message: string }
| { code: "ExternalError"; message: string }
| { code: "InvalidResponse"; message: string };

export type FeedSkeletonResult =
| { ok: true; data: FyiFrontpageFeedGetFeedSkeleton.OutputSchema }
| { ok: false; error: FeedError };
type Result<T, E> = { 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
Expand All @@ -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(
Expand Down Expand Up @@ -105,18 +115,17 @@ async function getExternalSkeleton(
cursor: string | undefined,
limit: number,
): Promise<FeedSkeletonResult> {
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));
Expand Down Expand Up @@ -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<Result<FeedResolution, FeedError>> => {
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<string> {
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;
}
}
10 changes: 1 addition & 9 deletions packages/frontpage/lib/feed-action.tsx
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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: (
Expand Down
Loading