diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 0926fb4..0a79060 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -161,6 +161,27 @@ export async function POST(request: Request) { // Federate the post to remote followers (non-blocking) (async () => { try { + // SWARM-FIRST: Deliver to swarm followers directly + const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions'); + + const swarmResult = await deliverPostToSwarmFollowers( + user.id, + post, + { + handle: user.handle, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + isNsfw: user.isNsfw, + }, + attachedMedia, + nodeDomain + ); + + if (swarmResult.delivered > 0) { + console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`); + } + + // FALLBACK: Deliver to ActivityPub followers const { createCreateActivity } = await import('@/lib/activitypub/activities'); const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox'); diff --git a/src/app/api/swarm/inbox/route.ts b/src/app/api/swarm/inbox/route.ts new file mode 100644 index 0000000..ff8e2af --- /dev/null +++ b/src/app/api/swarm/inbox/route.ts @@ -0,0 +1,156 @@ +/** + * Swarm Inbox Endpoint + * + * POST: Receive posts from users on other swarm nodes that local users follow + * + * This is the swarm equivalent of ActivityPub inbox - when a user on another + * Synapsis node creates a post, it gets pushed here for their followers. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, media, remoteFollowers } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmPostSchema = z.object({ + post: z.object({ + id: z.string(), + content: z.string(), + createdAt: z.string(), + isNsfw: z.boolean(), + replyToId: z.string().optional(), + repostOfId: z.string().optional(), + media: z.array(z.object({ + url: z.string(), + mimeType: z.string().optional(), + altText: z.string().optional(), + })).optional(), + linkPreviewUrl: z.string().optional(), + linkPreviewTitle: z.string().optional(), + linkPreviewDescription: z.string().optional(), + linkPreviewImage: z.string().optional(), + }), + author: z.object({ + handle: z.string(), + displayName: z.string(), + avatarUrl: z.string().optional(), + isNsfw: z.boolean(), + }), + nodeDomain: z.string(), + timestamp: z.string(), +}); + +/** + * POST /api/swarm/inbox + * + * Receives a post from another swarm node. + * Stores it for local users who follow the author. + */ +export async function POST(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const body = await request.json(); + const data = swarmPostSchema.parse(body); + + // Construct the swarm post ID + const swarmPostId = `swarm:${data.nodeDomain}:${data.post.id}`; + + // Check if we already have this post + const existingPost = await db.query.posts.findFirst({ + where: eq(posts.apId, swarmPostId), + }); + + if (existingPost) { + return NextResponse.json({ + success: true, + message: 'Post already exists', + }); + } + + // Check if anyone on this node follows the author + const authorActorUrl = `swarm://${data.nodeDomain}/${data.author.handle}`; + const hasFollowers = await db.query.remoteFollowers.findFirst({ + where: eq(remoteFollowers.actorUrl, authorActorUrl), + }); + + // Even if no one follows, we might want to cache for timeline + // For now, only store if someone follows + if (!hasFollowers) { + return NextResponse.json({ + success: true, + message: 'No local followers', + }); + } + + // Get or create placeholder user for the remote author + const remoteHandle = `${data.author.handle}@${data.nodeDomain}`; + let remoteUser = await db.query.users.findFirst({ + where: eq(users.handle, remoteHandle), + }); + + if (!remoteUser) { + const [newUser] = await db.insert(users).values({ + did: `did:swarm:${data.nodeDomain}:${data.author.handle}`, + handle: remoteHandle, + displayName: data.author.displayName, + avatarUrl: data.author.avatarUrl || null, + isNsfw: data.author.isNsfw, + publicKey: 'swarm-remote-user', + }).returning(); + remoteUser = newUser; + } else { + // Update profile info if changed + await db.update(users) + .set({ + displayName: data.author.displayName, + avatarUrl: data.author.avatarUrl || remoteUser.avatarUrl, + isNsfw: data.author.isNsfw, + }) + .where(eq(users.id, remoteUser.id)); + } + + // Create the post + const [newPost] = await db.insert(posts).values({ + userId: remoteUser.id, + content: data.post.content, + isNsfw: data.post.isNsfw || data.author.isNsfw, + apId: swarmPostId, + apUrl: `https://${data.nodeDomain}/${data.author.handle}/posts/${data.post.id}`, + createdAt: new Date(data.post.createdAt), + linkPreviewUrl: data.post.linkPreviewUrl || null, + linkPreviewTitle: data.post.linkPreviewTitle || null, + linkPreviewDescription: data.post.linkPreviewDescription || null, + linkPreviewImage: data.post.linkPreviewImage || null, + }).returning(); + + // Store media attachments + if (data.post.media && data.post.media.length > 0) { + for (const m of data.post.media) { + await db.insert(media).values({ + userId: remoteUser.id, + postId: newPost.id, + url: m.url, + mimeType: m.mimeType || null, + altText: m.altText || null, + }); + } + } + + console.log(`[Swarm] Received post from ${remoteHandle}: ${newPost.id}`); + + return NextResponse.json({ + success: true, + message: 'Post received', + postId: newPost.id, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Inbox error:', error); + return NextResponse.json({ error: 'Failed to process post' }, { status: 500 }); + } +} diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts index 3e5fae0..6b72815 100644 --- a/src/lib/swarm/interactions.ts +++ b/src/lib/swarm/interactions.ts @@ -472,3 +472,153 @@ export async function deliverSwarmMentions( return { delivered, failed }; } + + +// ============================================ +// POST DELIVERY TO SWARM FOLLOWERS +// ============================================ + +export interface SwarmPostDeliveryPayload { + post: { + id: string; + content: string; + createdAt: string; + isNsfw: boolean; + replyToId?: string; + repostOfId?: string; + media?: { url: string; mimeType?: string; altText?: string }[]; + linkPreviewUrl?: string; + linkPreviewTitle?: string; + linkPreviewDescription?: string; + linkPreviewImage?: string; + }; + author: { + handle: string; + displayName: string; + avatarUrl?: string; + isNsfw: boolean; + }; + nodeDomain: string; + timestamp: string; +} + +/** + * Deliver a new post to a swarm node's inbox + * This is used to push posts to followers on other swarm nodes + */ +export async function deliverSwarmPost( + targetDomain: string, + payload: SwarmPostDeliveryPayload +): Promise { + return deliverSwarmInteraction(targetDomain, '/api/swarm/inbox', payload); +} + +/** + * Get swarm follower domains from remote followers + * Returns domains of swarm nodes that have followers for this user + */ +export async function getSwarmFollowerDomains(userId: string): Promise { + try { + const { db, remoteFollowers } = await import('@/db'); + const { eq } = await import('drizzle-orm'); + + if (!db) return []; + + const followers = await db.query.remoteFollowers.findMany({ + where: eq(remoteFollowers.userId, userId), + }); + + // Filter for swarm followers (actorUrl starts with swarm://) + const swarmFollowers = followers.filter(f => f.actorUrl.startsWith('swarm://')); + + // Extract unique domains + const domains = swarmFollowers.map(f => { + const match = f.actorUrl.match(/^swarm:\/\/([^\/]+)/); + return match ? match[1] : null; + }).filter((d): d is string => d !== null); + + return [...new Set(domains)]; + } catch (error) { + console.error('[Swarm] Error getting swarm follower domains:', error); + return []; + } +} + +/** + * Deliver a post to all swarm followers + */ +export async function deliverPostToSwarmFollowers( + userId: string, + post: { + id: string; + content: string; + createdAt: Date; + isNsfw: boolean; + replyToId?: string | null; + repostOfId?: string | null; + linkPreviewUrl?: string | null; + linkPreviewTitle?: string | null; + linkPreviewDescription?: string | null; + linkPreviewImage?: string | null; + }, + author: { + handle: string; + displayName: string | null; + avatarUrl: string | null; + isNsfw: boolean; + }, + media: { url: string; mimeType: string | null; altText: string | null }[], + nodeDomain: string +): Promise<{ delivered: number; failed: number }> { + const swarmDomains = await getSwarmFollowerDomains(userId); + + if (swarmDomains.length === 0) { + return { delivered: 0, failed: 0 }; + } + + const payload: SwarmPostDeliveryPayload = { + post: { + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + isNsfw: post.isNsfw, + replyToId: post.replyToId || undefined, + repostOfId: post.repostOfId || undefined, + media: media.length > 0 ? media.map(m => ({ + url: m.url, + mimeType: m.mimeType || undefined, + altText: m.altText || undefined, + })) : undefined, + linkPreviewUrl: post.linkPreviewUrl || undefined, + linkPreviewTitle: post.linkPreviewTitle || undefined, + linkPreviewDescription: post.linkPreviewDescription || undefined, + linkPreviewImage: post.linkPreviewImage || undefined, + }, + author: { + handle: author.handle, + displayName: author.displayName || author.handle, + avatarUrl: author.avatarUrl || undefined, + isNsfw: author.isNsfw, + }, + nodeDomain, + timestamp: new Date().toISOString(), + }; + + let delivered = 0; + let failed = 0; + + // Deliver to all swarm domains in parallel + const results = await Promise.allSettled( + swarmDomains.map(domain => deliverSwarmPost(domain, payload)) + ); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value.success) { + delivered++; + } else { + failed++; + } + } + + return { delivered, failed }; +}