From 72d148066ebfb10811dd99ca189fd8306f567c76 Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 18:32:37 -0800 Subject: [PATCH] feat: Enable fetching and caching of remote ActivityPub posts in the post details API. --- src/app/api/posts/[id]/route.ts | 161 ++++++++++++++--------- src/lib/activitypub/fetchRemotePost.ts | 169 +++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 59 deletions(-) create mode 100644 src/lib/activitypub/fetchRemotePost.ts diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 69d2a24..dd844b2 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -1,6 +1,7 @@ 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 { fetchRemotePost } from '@/lib/activitypub/fetchRemotePost'; export async function GET( request: Request, @@ -9,7 +10,11 @@ export async function GET( try { 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({ where: eq(posts.id, id), with: { @@ -21,65 +26,103 @@ export async function GET( }, }); - if (!post) { - return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + if (post) { + 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 - 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 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 + if (!mainPost) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } return NextResponse.json({ diff --git a/src/lib/activitypub/fetchRemotePost.ts b/src/lib/activitypub/fetchRemotePost.ts new file mode 100644 index 0000000..f182f65 --- /dev/null +++ b/src/lib/activitypub/fetchRemotePost.ts @@ -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 { + 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 { + 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 }; +}