feat(swarm): Add real-time post fetching and reply aggregation for federated nodes

- Add swarm post ID parsing (format: swarm:domain:uuid) in local post endpoint
- Implement real-time fetching from origin node with 10s timeout
- Transform remote post data to match local format with swarm metadata
- Add reply aggregation from remote node with proper ID transformation
- Implement like status checking for authenticated users on remote posts
- Refactor swarm post endpoint to include reply data in response
- Add media transformation with swarm-prefixed IDs for remote posts
- Improve error handling with fallback to 404 for unreachable nodes
- Enable seamless cross-node post viewing with federated author information
This commit is contained in:
Christomatt
2026-01-26 13:25:54 +01:00
parent 08d507c196
commit 9581fd2810
2 changed files with 176 additions and 59 deletions
+105
View File
@@ -15,6 +15,111 @@ export async function GET(
let mainPost: any = null; let mainPost: any = null;
let replyPosts: any[] = []; let replyPosts: any[] = [];
// Handle swarm post IDs (format: swarm:domain:uuid)
if (id.startsWith('swarm:')) {
const parts = id.split(':');
if (parts.length >= 3) {
const originDomain = parts[1];
const originalPostId = parts[2];
// Fetch from origin node in real-time
try {
const protocol = originDomain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${originDomain}/api/swarm/posts/${originalPostId}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (res.ok) {
const data = await res.json();
// Transform to match expected format
mainPost = {
id: id,
originalPostId: originalPostId,
content: data.post.content,
createdAt: data.post.createdAt,
likesCount: data.post.likesCount || 0,
repostsCount: data.post.repostsCount || 0,
repliesCount: data.post.repliesCount || 0,
isSwarm: true,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${data.post.author.handle}`,
handle: data.post.author.handle,
displayName: data.post.author.displayName,
avatarUrl: data.post.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: data.post.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${originalPostId}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
linkPreviewUrl: data.post.linkPreviewUrl,
linkPreviewTitle: data.post.linkPreviewTitle,
linkPreviewDescription: data.post.linkPreviewDescription,
linkPreviewImage: data.post.linkPreviewImage,
};
// Transform replies
replyPosts = (data.replies || []).map((r: any) => ({
id: `swarm:${originDomain}:${r.id}`,
originalPostId: r.id,
content: r.content,
createdAt: r.createdAt,
likesCount: r.likesCount || 0,
repostsCount: r.repostsCount || 0,
repliesCount: r.repliesCount || 0,
isSwarm: true,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${r.author.handle}`,
handle: r.author.handle,
displayName: r.author.displayName,
avatarUrl: r.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: r.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${r.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
}));
// Check if current user has liked this post
try {
const { requireAuth } = await import('@/lib/auth');
const viewer = await requireAuth();
const likeCheckRes = await fetch(
`${protocol}://${originDomain}/api/swarm/posts/${originalPostId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`,
{ signal: AbortSignal.timeout(3000) }
);
if (likeCheckRes.ok) {
const likeData = await likeCheckRes.json();
mainPost.isLiked = likeData.isLiked;
}
} catch {
// Not logged in or timeout
}
return NextResponse.json({
post: mainPost,
replies: replyPosts,
});
}
} catch (err) {
console.error(`[Swarm] Failed to fetch post from ${originDomain}:`, err);
}
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
}
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: {
+71 -59
View File
@@ -1,90 +1,102 @@
/** /**
* Swarm Post Endpoint * Swarm Post Detail Endpoint
* *
* GET: Returns a single post for swarm requests * GET: Get a post's details for other swarm nodes
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db'; import { db, posts } from '@/db';
import { eq } from 'drizzle-orm'; import { eq, desc, and } from 'drizzle-orm';
type RouteContext = { params: Promise<{ id: string }> }; type RouteContext = { params: Promise<{ id: string }> };
/** /**
* GET /api/swarm/posts/[id] * GET /api/swarm/posts/[id]
* *
* Returns a single post with author info. * Returns post details including replies for swarm federation.
* Used by other nodes to fetch post details.
*/ */
export async function GET(request: NextRequest, context: RouteContext) { export async function GET(request: NextRequest, context: RouteContext) {
try { try {
const { id: postId } = await context.params;
if (!db) { if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 }); return NextResponse.json({ error: 'Database not available' }, { status: 503 });
} }
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const { id: postId } = await context.params;
// Find the post with author // Find the post
const post = await db.query.posts.findFirst({ const post = await db.query.posts.findFirst({
where: eq(posts.id, postId), where: eq(posts.id, postId),
with: { author: true }, with: {
author: true,
media: true,
},
}); });
if (!post) { if (!post || post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 }); return NextResponse.json({ error: 'Post not found' }, { status: 404 });
} }
if (post.isRemoved) { // Get replies
return NextResponse.json({ error: 'Post not found' }, { status: 404 }); const replies = await db.query.posts.findMany({
} where: and(
eq(posts.replyToId, postId),
// Get media for the post eq(posts.isRemoved, false)
const postMedia = await db ),
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) with: {
.from(media) author: true,
.where(eq(media.postId, postId)); media: true,
const author = post.author as {
handle: string;
displayName: string | null;
avatarUrl: string | null;
isNsfw: boolean;
};
return NextResponse.json({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isNsfw: author.isNsfw,
}, },
nodeDomain, orderBy: [desc(posts.createdAt)],
isNsfw: post.isNsfw || author.isNsfw, limit: 50,
likesCount: post.likesCount, });
repostsCount: post.repostsCount,
repliesCount: post.repliesCount, const author = post.author as any;
media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url, return NextResponse.json({
mimeType: m.mimeType || undefined, post: {
altText: m.altText || undefined, id: post.id,
})) : undefined, content: post.content,
linkPreviewUrl: post.linkPreviewUrl || undefined, createdAt: post.createdAt.toISOString(),
linkPreviewTitle: post.linkPreviewTitle || undefined, likesCount: post.likesCount,
linkPreviewDescription: post.linkPreviewDescription || undefined, repostsCount: post.repostsCount,
linkPreviewImage: post.linkPreviewImage || undefined, repliesCount: post.repliesCount,
replyToId: post.replyToId || undefined, author: {
repostOfId: post.repostOfId || undefined, handle: author.handle,
displayName: author.displayName,
avatarUrl: author.avatarUrl,
},
media: (post.media as any[])?.map(m => ({
url: m.url,
altText: m.altText,
})) || [],
linkPreviewUrl: post.linkPreviewUrl,
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
},
replies: replies.map(r => {
const replyAuthor = r.author as any;
return {
id: r.id,
content: r.content,
createdAt: r.createdAt.toISOString(),
likesCount: r.likesCount,
repostsCount: r.repostsCount,
repliesCount: r.repliesCount,
author: {
handle: replyAuthor.handle,
displayName: replyAuthor.displayName,
avatarUrl: replyAuthor.avatarUrl,
},
media: (r.media as any[])?.map(m => ({
url: m.url,
altText: m.altText,
})) || [],
};
}),
}); });
} catch (error) { } catch (error) {
console.error('Swarm post error:', error); console.error('[Swarm] Post detail error:', error);
return NextResponse.json( return NextResponse.json({ error: 'Failed to get post' }, { status: 500 });
{ error: 'Failed to fetch post' },
{ status: 500 }
);
} }
} }