feat: Enable fetching and caching of remote ActivityPub posts in the post details API.
This commit is contained in:
@@ -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,11 +26,9 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!post) {
|
if (post) {
|
||||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
mainPost = post;
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch replies to this post
|
|
||||||
const replies = await db.query.posts.findMany({
|
const replies = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.replyToId, id),
|
eq(posts.replyToId, id),
|
||||||
@@ -38,8 +41,7 @@ export async function GET(
|
|||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
let mainPost = post;
|
let allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||||
let replyPosts = replies;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { requireAuth } = await import('@/lib/auth');
|
const { requireAuth } = await import('@/lib/auth');
|
||||||
@@ -47,7 +49,7 @@ export async function GET(
|
|||||||
const { inArray } = await import('drizzle-orm');
|
const { inArray } = await import('drizzle-orm');
|
||||||
|
|
||||||
const viewer = await requireAuth();
|
const viewer = await requireAuth();
|
||||||
const allPostIds = [post.id, ...replies.map(r => r.id)];
|
allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||||
|
|
||||||
if (allPostIds.length > 0) {
|
if (allPostIds.length > 0) {
|
||||||
const viewerLikes = await db.query.likes.findMany({
|
const viewerLikes = await db.query.likes.findMany({
|
||||||
@@ -76,10 +78,51 @@ export async function GET(
|
|||||||
...r,
|
...r,
|
||||||
isLiked: likedPostIds.has(r.id),
|
isLiked: likedPostIds.has(r.id),
|
||||||
isReposted: repostedPostIds.has(r.id),
|
isReposted: repostedPostIds.has(r.id),
|
||||||
})) as any;
|
})) as any[];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not authenticated or other error, skip flags
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainPost) {
|
||||||
|
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user