feat: Enable fetching and caching of remote ActivityPub posts in the post details API.

This commit is contained in:
Christopher
2026-01-22 18:32:37 -08:00
parent fdb6cfbd35
commit 72d148066e
2 changed files with 271 additions and 59 deletions
+102 -59
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db'; import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and } from 'drizzle-orm'; import { eq, desc, and } from 'drizzle-orm';
import { fetchRemotePost } from '@/lib/activitypub/fetchRemotePost';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -9,7 +10,11 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
// Fetch the main post const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
let mainPost: any = null;
let replyPosts: any[] = [];
const post = await db.query.posts.findFirst({ const post = await db.query.posts.findFirst({
where: eq(posts.id, id), where: eq(posts.id, id),
with: { with: {
@@ -21,65 +26,103 @@ export async function GET(
}, },
}); });
if (!post) { if (post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 }); mainPost = post;
const replies = await db.query.posts.findMany({
where: and(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
let allPostIds = [post.id, ...replies.map(r => r.id)];
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const { inArray } = await import('drizzle-orm');
const viewer = await requireAuth();
allPostIds = [post.id, ...replies.map(r => r.id)];
if (allPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, allPostIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, allPostIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
mainPost = {
...post,
isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id),
} as any;
replyPosts = replies.map(r => ({
...r,
isLiked: likedPostIds.has(r.id),
isReposted: repostedPostIds.has(r.id),
})) as any[];
}
} catch {
}
} else {
const cached = await db.query.remotePosts.findFirst({
where: eq(remotePosts.apId, id),
});
if (cached) {
mainPost = {
id: cached.id,
content: cached.content,
createdAt: cached.publishedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: {
id: cached.authorHandle,
handle: cached.authorHandle,
displayName: cached.authorDisplayName || cached.authorHandle,
avatarUrl: cached.authorAvatarUrl,
bio: null,
isRemote: true,
},
media: cached.mediaJson ? JSON.parse(cached.mediaJson) : null,
linkPreviewUrl: cached.linkPreviewUrl,
linkPreviewTitle: cached.linkPreviewTitle,
linkPreviewDescription: cached.linkPreviewDescription,
linkPreviewImage: cached.linkPreviewImage,
isLiked: false,
isReposted: false,
};
} else {
const postUrl = `https://${nodeDomain}/posts/${id}`;
const result = await fetchRemotePost(postUrl, nodeDomain);
if (result.post) {
mainPost = result.post;
}
}
} }
// Fetch replies to this post if (!mainPost) {
const replies = await db.query.posts.findMany({ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
where: and(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
let mainPost = post;
let replyPosts = replies;
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const { inArray } = await import('drizzle-orm');
const viewer = await requireAuth();
const allPostIds = [post.id, ...replies.map(r => r.id)];
if (allPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, allPostIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, allPostIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
mainPost = {
...post,
isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id),
} as any;
replyPosts = replies.map(r => ({
...r,
isLiked: likedPostIds.has(r.id),
isReposted: repostedPostIds.has(r.id),
})) as any;
}
} catch {
// Not authenticated or other error, skip flags
} }
return NextResponse.json({ return NextResponse.json({
+169
View File
@@ -0,0 +1,169 @@
import { db, remotePosts } from '@/db';
import { eq } from 'drizzle-orm';
export interface ActivityPubNote {
'@context': string;
id: string;
type: 'Note';
attributedTo: string;
content: string;
published: string;
to: string[];
cc: string[];
inReplyTo?: string | null;
url: string;
attachment?: {
type: string;
mediaType: string;
url: string;
name?: string;
}[];
}
function parsePostIdFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
const postsIndex = pathParts.indexOf('posts');
if (postsIndex !== -1 && pathParts[postsIndex + 1]) {
return pathParts[postsIndex + 1];
}
const objectsIndex = pathParts.indexOf('objects');
if (objectsIndex !== -1 && pathParts[objectsIndex + 1]) {
return pathParts[objectsIndex + 1];
}
if (pathParts.includes('objects') && pathParts.includes('users')) {
const lastPart = pathParts[pathParts.length - 1];
return lastPart;
}
return null;
} catch {
return null;
}
}
async function fetchRemotePostFromUrl(url: string): Promise<ActivityPubNote | null> {
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent': 'Synapsis/1.0 (+https://synapsis.social)',
},
});
if (!response.ok) {
console.error(`Failed to fetch remote post: ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error('Error fetching remote post:', error);
return null;
}
}
async function cacheRemotePost(apNote: ActivityPubNote, nodeDomain: string): Promise<boolean> {
try {
const postId = parsePostIdFromUrl(apNote.url);
if (!postId) {
console.error('Could not parse post ID from:', apNote.url);
return false;
}
const existing = await db.query.remotePosts.findFirst({
where: eq(remotePosts.apId, apNote.id),
});
if (existing) {
console.log('Remote post already cached:', postId);
return true;
}
let mediaJson: string | null = null;
if (apNote.attachment && apNote.attachment.length > 0) {
mediaJson = JSON.stringify(apNote.attachment);
}
const authorUrl = new URL(apNote.attributedTo);
const authorHandle = authorUrl.pathname.split('/').pop() || apNote.attributedTo;
const authorDomain = authorUrl.hostname;
await db.insert(remotePosts).values({
id: crypto.randomUUID(),
apId: apNote.id,
authorHandle: authorHandle,
authorActorUrl: apNote.attributedTo,
authorDisplayName: apNote.attributedTo === authorHandle ? null : authorHandle,
authorAvatarUrl: null,
content: apNote.content || '',
publishedAt: apNote.published ? new Date(apNote.published) : new Date(),
linkPreviewUrl: null,
linkPreviewTitle: null,
linkPreviewDescription: null,
linkPreviewImage: null,
mediaJson: mediaJson,
fetchedAt: new Date(),
});
console.log('Cached remote post:', postId);
return true;
} catch (error) {
console.error('Error caching remote post:', error);
return false;
}
}
function cachedRemotePostToFrontend(remotePost: any) {
let media = null;
try {
if (remotePost.mediaJson) {
media = JSON.parse(remotePost.mediaJson);
}
} catch {
}
return {
id: remotePost.id,
content: remotePost.content,
createdAt: remotePost.publishedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: {
id: remotePost.authorHandle,
handle: remotePost.authorHandle,
displayName: remotePost.authorDisplayName || remotePost.authorHandle,
avatarUrl: remotePost.authorAvatarUrl,
bio: null,
isRemote: true,
},
media: media || undefined,
linkPreviewUrl: remotePost.linkPreviewUrl,
linkPreviewTitle: remotePost.linkPreviewTitle,
linkPreviewDescription: remotePost.linkPreviewDescription,
linkPreviewImage: remotePost.linkPreviewImage,
isLiked: false,
isReposted: false,
};
}
export async function fetchRemotePost(postUrl: string, nodeDomain: string): Promise<{ post: any | null; isCached: boolean }> {
const apNote = await fetchRemotePostFromUrl(postUrl);
if (!apNote) {
return { post: null, isCached: false };
}
const cached = await cacheRemotePost(apNote, nodeDomain);
if (!cached) {
return { post: null, isCached: false };
}
const frontendPost = cachedRemotePostToFrontend(apNote);
return { post: frontendPost, isCached: true };
}