From 2372f5c054b86dbd2dcb38f0508bc4bae242c866 Mon Sep 17 00:00:00 2001 From: AskIt Date: Mon, 26 Jan 2026 06:01:27 +0100 Subject: [PATCH] feat(swarm,interactions): Add federated interaction endpoints and update branding - Add new swarm interaction endpoints for follow, unfollow, like, unlike, mention, and repost actions - Create interactions.ts library module to handle federated interaction logic - Add swarm post detail endpoint for retrieving individual post information - Update post interaction routes to support federated operations - Replace logo.svg with new logotext.svg for updated branding - Update login page and sidebar components with new branding assets - Enhance swarm discovery and type definitions to support new interaction patterns - Update user follow endpoint to work with federated swarm interactions --- public/logo.svg | 1 - public/logotext.svg | 56 +++ src/app/api/posts/[id]/like/route.ts | 104 +++- src/app/api/posts/[id]/repost/route.ts | 72 ++- src/app/api/posts/route.ts | 24 + .../api/swarm/interactions/follow/route.ts | 126 +++++ src/app/api/swarm/interactions/like/route.ts | 100 ++++ .../api/swarm/interactions/mention/route.ts | 110 ++++ .../api/swarm/interactions/repost/route.ts | 95 ++++ .../api/swarm/interactions/unfollow/route.ts | 83 +++ .../api/swarm/interactions/unlike/route.ts | 63 +++ src/app/api/swarm/posts/[id]/route.ts | 90 ++++ src/app/api/users/[handle]/follow/route.ts | 106 +++- src/app/login/page.tsx | 2 +- src/components/Sidebar.tsx | 2 +- src/lib/swarm/discovery.ts | 2 +- src/lib/swarm/index.ts | 3 + src/lib/swarm/interactions.ts | 474 ++++++++++++++++++ src/lib/swarm/types.ts | 2 +- 19 files changed, 1482 insertions(+), 33 deletions(-) delete mode 100644 public/logo.svg create mode 100644 public/logotext.svg create mode 100644 src/app/api/swarm/interactions/follow/route.ts create mode 100644 src/app/api/swarm/interactions/like/route.ts create mode 100644 src/app/api/swarm/interactions/mention/route.ts create mode 100644 src/app/api/swarm/interactions/repost/route.ts create mode 100644 src/app/api/swarm/interactions/unfollow/route.ts create mode 100644 src/app/api/swarm/interactions/unlike/route.ts create mode 100644 src/app/api/swarm/posts/[id]/route.ts create mode 100644 src/lib/swarm/interactions.ts diff --git a/public/logo.svg b/public/logo.svg deleted file mode 100644 index e13b6c9..0000000 --- a/public/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logotext.svg b/public/logotext.svg new file mode 100644 index 0000000..d01bcf2 --- /dev/null +++ b/public/logotext.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 88997bf..49f7d95 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -2,14 +2,40 @@ import { NextResponse } from 'next/server'; import { db, posts, likes, users, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; import { eq, and } from 'drizzle-orm'; +import crypto from 'crypto'; type RouteContext = { params: Promise<{ id: string }> }; +/** + * Extract domain from a swarm post ID (swarm:domain:postId) + */ +function extractSwarmDomain(apId: string | null): string | null { + if (!apId?.startsWith('swarm:')) return null; + const parts = apId.split(':'); + return parts.length >= 2 ? parts[1] : null; +} + +/** + * Check if a post is from a swarm node (has swarm: prefix in apId) + */ +function isSwarmPost(apId: string | null): boolean { + return apId?.startsWith('swarm:') ?? false; +} + +/** + * Extract the original post ID from a swarm apId + */ +function extractSwarmPostId(apId: string): string | null { + const parts = apId.split(':'); + return parts.length >= 3 ? parts[2] : null; +} + // Like a post export async function POST(request: Request, context: RouteContext) { try { const user = await requireAuth(); const { id: postId } = await context.params; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (user.isSuspended || user.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); @@ -59,15 +85,45 @@ export async function POST(request: Request, context: RouteContext) { }); } - // Federate the like if the post has an ActivityPub ID - if (post.apId) { + // SWARM-FIRST: Check if this is a swarm post and deliver directly + if (isSwarmPost(post.apId)) { + const targetDomain = extractSwarmDomain(post.apId); + const originalPostId = extractSwarmPostId(post.apId!); + + if (targetDomain && originalPostId) { + (async () => { + try { + const { deliverSwarmLike } = await import('@/lib/swarm/interactions'); + + const result = await deliverSwarmLike(targetDomain, { + postId: originalPostId, + like: { + actorHandle: user.handle, + actorDisplayName: user.displayName || user.handle, + actorAvatarUrl: user.avatarUrl || undefined, + actorNodeDomain: nodeDomain, + interactionId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }); + + if (result.success) { + console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`); + } else { + console.warn(`[Swarm] Like delivery failed: ${result.error}`); + // Could fall back to ActivityPub here if needed + } + } catch (err) { + console.error('[Swarm] Error delivering like:', err); + } + })(); + } + } else if (post.apId) { + // FALLBACK: Use ActivityPub for non-swarm posts (async () => { try { const { createLikeActivity } = await import('@/lib/activitypub/activities'); const { deliverActivity } = await import('@/lib/activitypub/outbox'); - const { fetchRemoteActor } = await import('@/lib/activitypub/fetch'); - - const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; // Get the post author's actor URL const postWithAuthor = await db.query.posts.findFirst({ @@ -78,11 +134,6 @@ export async function POST(request: Request, context: RouteContext) { if (!postWithAuthor?.author) return; const author = postWithAuthor.author as { handle: string }; - // Construct the author's actor URL - const authorActorUrl = `https://${nodeDomain}/users/${author.handle}`; - - // For remote posts, we'd need to fetch the remote actor's inbox - // For local posts, just log it since local delivery doesn't need federation console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`); } catch (err) { console.error('[Federation] Error federating like:', err); @@ -104,6 +155,7 @@ export async function DELETE(request: Request, context: RouteContext) { try { const user = await requireAuth(); const { id: postId } = await context.params; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (user.isSuspended || user.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); @@ -141,6 +193,38 @@ export async function DELETE(request: Request, context: RouteContext) { .set({ likesCount: Math.max(0, post.likesCount - 1) }) .where(eq(posts.id, postId)); + // SWARM-FIRST: Deliver unlike to swarm node + if (isSwarmPost(post.apId)) { + const targetDomain = extractSwarmDomain(post.apId); + const originalPostId = extractSwarmPostId(post.apId!); + + if (targetDomain && originalPostId) { + (async () => { + try { + const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions'); + + const result = await deliverSwarmUnlike(targetDomain, { + postId: originalPostId, + unlike: { + actorHandle: user.handle, + actorNodeDomain: nodeDomain, + interactionId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }); + + if (result.success) { + console.log(`[Swarm] Unlike delivered to ${targetDomain}`); + } else { + console.warn(`[Swarm] Unlike delivery failed: ${result.error}`); + } + } catch (err) { + console.error('[Swarm] Error delivering unlike:', err); + } + })(); + } + } + return NextResponse.json({ success: true, liked: false }); } catch (error) { if (error instanceof Error && error.message === 'Authentication required') { diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index 5a9d8d4..1f1cbe1 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -2,9 +2,34 @@ import { NextResponse } from 'next/server'; import { db, posts, users, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; import { eq, and } from 'drizzle-orm'; +import crypto from 'crypto'; type RouteContext = { params: Promise<{ id: string }> }; +/** + * Extract domain from a swarm post ID (swarm:domain:postId) + */ +function extractSwarmDomain(apId: string | null): string | null { + if (!apId?.startsWith('swarm:')) return null; + const parts = apId.split(':'); + return parts.length >= 2 ? parts[1] : null; +} + +/** + * Check if a post is from a swarm node + */ +function isSwarmPost(apId: string | null): boolean { + return apId?.startsWith('swarm:') ?? false; +} + +/** + * Extract the original post ID from a swarm apId + */ +function extractSwarmPostId(apId: string): string | null { + const parts = apId.split(':'); + return parts.length >= 3 ? parts[2] : null; +} + // Repost a post export async function POST(request: Request, context: RouteContext) { try { @@ -42,12 +67,13 @@ export async function POST(request: Request, context: RouteContext) { } // Create repost + const repostId = crypto.randomUUID(); const [repost] = await db.insert(posts).values({ userId: user.id, content: '', // Reposts don't have their own content repostOfId: postId, - apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, - apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, + apId: `https://${nodeDomain}/posts/${repostId}`, + apUrl: `https://${nodeDomain}/posts/${repostId}`, }).returning(); // Update original post's repost count @@ -69,9 +95,41 @@ export async function POST(request: Request, context: RouteContext) { }); } - // Federate the repost (Announce activity) if the original post has an ActivityPub ID - const postApId = originalPost.apId; - if (postApId) { + // SWARM-FIRST: Deliver repost to swarm node + if (isSwarmPost(originalPost.apId)) { + const targetDomain = extractSwarmDomain(originalPost.apId); + const originalPostIdOnRemote = extractSwarmPostId(originalPost.apId!); + + if (targetDomain && originalPostIdOnRemote) { + (async () => { + try { + const { deliverSwarmRepost } = await import('@/lib/swarm/interactions'); + + const result = await deliverSwarmRepost(targetDomain, { + postId: originalPostIdOnRemote, + repost: { + actorHandle: user.handle, + actorDisplayName: user.displayName || user.handle, + actorAvatarUrl: user.avatarUrl || undefined, + actorNodeDomain: nodeDomain, + repostId: repost.id, + interactionId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }); + + if (result.success) { + console.log(`[Swarm] Repost delivered to ${targetDomain}`); + } else { + console.warn(`[Swarm] Repost delivery failed: ${result.error}`); + } + } catch (err) { + console.error('[Swarm] Error delivering repost:', err); + } + })(); + } + } else if (originalPost.apId) { + // FALLBACK: Use ActivityPub for non-swarm posts (async () => { try { const { createAnnounceActivity } = await import('@/lib/activitypub/activities'); @@ -82,7 +140,7 @@ export async function POST(request: Request, context: RouteContext) { if (followerInboxes.length > 0) { const announceActivity = createAnnounceActivity( user, - postApId, + originalPost.apId!, nodeDomain, repost.id ); @@ -91,7 +149,7 @@ export async function POST(request: Request, context: RouteContext) { if (privateKey) { const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`; const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId); - console.log(`[Federation] Announce for ${postApId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`); + console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`); } } } catch (err) { diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 8bb9605..0926fb4 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -134,6 +134,30 @@ export async function POST(request: Request) { })(); } + // SWARM-FIRST: Deliver mentions to swarm nodes + (async () => { + try { + const { deliverSwarmMentions } = await import('@/lib/swarm/interactions'); + + const result = await deliverSwarmMentions( + data.content, + post.id, + { + handle: user.handle, + displayName: user.displayName || user.handle, + avatarUrl: user.avatarUrl || undefined, + nodeDomain, + } + ); + + if (result.delivered > 0) { + console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`); + } + } catch (err) { + console.error('[Swarm] Error delivering mentions:', err); + } + })(); + // Federate the post to remote followers (non-blocking) (async () => { try { diff --git a/src/app/api/swarm/interactions/follow/route.ts b/src/app/api/swarm/interactions/follow/route.ts new file mode 100644 index 0000000..6459f56 --- /dev/null +++ b/src/app/api/swarm/interactions/follow/route.ts @@ -0,0 +1,126 @@ +/** + * Swarm Follow Endpoint + * + * POST: Receive a follow from another swarm node + * + * This enables swarm-native follows between Synapsis nodes, + * bypassing ActivityPub for faster, more direct connections. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, notifications, remoteFollowers } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmFollowSchema = z.object({ + targetHandle: z.string(), + follow: z.object({ + followerHandle: z.string(), + followerDisplayName: z.string(), + followerAvatarUrl: z.string().optional(), + followerBio: z.string().optional(), + followerNodeDomain: z.string(), + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/follow + * + * Receives a follow from another swarm node. + */ +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 = swarmFollowSchema.parse(body); + + // Find the target user (local user being followed) + const targetUser = await db.query.users.findFirst({ + where: eq(users.handle, data.targetHandle.toLowerCase()), + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (targetUser.isSuspended) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Construct the remote follower's actor URL (swarm-style) + const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`; + const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`; + const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`; + + // Check if this follow already exists + const existingFollow = await db.query.remoteFollowers.findFirst({ + where: and( + eq(remoteFollowers.userId, targetUser.id), + eq(remoteFollowers.actorUrl, actorUrl) + ), + }); + + if (existingFollow) { + return NextResponse.json({ + success: true, + message: 'Already following', + }); + } + + // Create the remote follower record + await db.insert(remoteFollowers).values({ + userId: targetUser.id, + actorUrl, + inboxUrl, + handle: remoteHandle, + activityId: data.follow.interactionId, + }); + + // Update follower count + await db.update(users) + .set({ followersCount: targetUser.followersCount + 1 }) + .where(eq(users.id, targetUser.id)); + + // Get or create placeholder user for the remote follower (for notifications) + 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.follow.followerNodeDomain}:${data.follow.followerHandle}`, + handle: remoteHandle, + displayName: data.follow.followerDisplayName, + avatarUrl: data.follow.followerAvatarUrl || null, + bio: data.follow.followerBio || null, + publicKey: 'swarm-remote-user', + }).returning(); + remoteUser = newUser; + } + + // Create notification + await db.insert(notifications).values({ + userId: targetUser.id, + actorId: remoteUser.id, + type: 'follow', + }); + + console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`); + + return NextResponse.json({ + success: true, + message: 'Follow received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Follow error:', error); + return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts new file mode 100644 index 0000000..41bc1e1 --- /dev/null +++ b/src/app/api/swarm/interactions/like/route.ts @@ -0,0 +1,100 @@ +/** + * Swarm Like Endpoint + * + * POST: Receive a like from another swarm node + * + * This is the swarm-first approach - direct node-to-node communication + * for likes, bypassing ActivityPub for Synapsis nodes. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, notifications } from '@/db'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmLikeSchema = z.object({ + postId: z.string().uuid(), + like: z.object({ + actorHandle: z.string(), + actorDisplayName: z.string(), + actorAvatarUrl: z.string().optional(), + actorNodeDomain: z.string(), + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/like + * + * Receives a like from another swarm node. + */ +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 = swarmLikeSchema.parse(body); + + // Find the target post + const post = await db.query.posts.findFirst({ + where: eq(posts.id, data.postId), + with: { author: true }, + }); + + if (!post) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + if (post.isRemoved) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Increment like count + await db.update(posts) + .set({ likesCount: post.likesCount + 1 }) + .where(eq(posts.id, data.postId)); + + // Create a notification for the post author + // First, get or create a placeholder user for the remote liker + const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`; + let remoteUser = await db.query.users.findFirst({ + where: eq(users.handle, remoteHandle), + }); + + if (!remoteUser) { + // Create a placeholder user for the remote actor + const [newUser] = await db.insert(users).values({ + did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`, + handle: remoteHandle, + displayName: data.like.actorDisplayName, + avatarUrl: data.like.actorAvatarUrl || null, + publicKey: 'swarm-remote-user', + }).returning(); + remoteUser = newUser; + } + + // Create notification + await db.insert(notifications).values({ + userId: post.userId, + actorId: remoteUser.id, + postId: data.postId, + type: 'like', + }); + + console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`); + + return NextResponse.json({ + success: true, + message: 'Like received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Like error:', error); + return NextResponse.json({ error: 'Failed to process like' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts new file mode 100644 index 0000000..e69e591 --- /dev/null +++ b/src/app/api/swarm/interactions/mention/route.ts @@ -0,0 +1,110 @@ +/** + * Swarm Mention Endpoint + * + * POST: Receive a mention notification from another swarm node + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, notifications, posts } from '@/db'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmMentionSchema = z.object({ + mentionedHandle: z.string(), + mention: z.object({ + actorHandle: z.string(), + actorDisplayName: z.string(), + actorAvatarUrl: z.string().optional(), + actorNodeDomain: z.string(), + postId: z.string(), + postContent: z.string(), + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/mention + * + * Receives a mention notification from another swarm node. + */ +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 = swarmMentionSchema.parse(body); + + // Find the mentioned user (local user) + const mentionedUser = await db.query.users.findFirst({ + where: eq(users.handle, data.mentionedHandle.toLowerCase()), + }); + + if (!mentionedUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (mentionedUser.isSuspended) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Get or create placeholder user for the remote actor + const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`; + 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.mention.actorNodeDomain}:${data.mention.actorHandle}`, + handle: remoteHandle, + displayName: data.mention.actorDisplayName, + avatarUrl: data.mention.actorAvatarUrl || null, + publicKey: 'swarm-remote-user', + }).returning(); + remoteUser = newUser; + } + + // Check if we already have this post cached (from swarm timeline) + // If not, create a placeholder post for the notification + const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`; + let post = await db.query.posts.findFirst({ + where: eq(posts.apId, swarmPostId), + }); + + if (!post) { + // Create a placeholder post for the mention + const [newPost] = await db.insert(posts).values({ + userId: remoteUser.id, + content: data.mention.postContent, + apId: swarmPostId, + apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`, + createdAt: new Date(data.mention.timestamp), + }).returning(); + post = newPost; + } + + // Create notification + await db.insert(notifications).values({ + userId: mentionedUser.id, + actorId: remoteUser.id, + postId: post.id, + type: 'mention', + }); + + console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`); + + return NextResponse.json({ + success: true, + message: 'Mention received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Mention error:', error); + return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/interactions/repost/route.ts b/src/app/api/swarm/interactions/repost/route.ts new file mode 100644 index 0000000..15a3e38 --- /dev/null +++ b/src/app/api/swarm/interactions/repost/route.ts @@ -0,0 +1,95 @@ +/** + * Swarm Repost Endpoint + * + * POST: Receive a repost from another swarm node + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, notifications } from '@/db'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmRepostSchema = z.object({ + postId: z.string().uuid(), + repost: z.object({ + actorHandle: z.string(), + actorDisplayName: z.string(), + actorAvatarUrl: z.string().optional(), + actorNodeDomain: z.string(), + repostId: z.string(), // The ID of the repost on the actor's node + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/repost + * + * Receives a repost notification from another swarm node. + */ +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 = swarmRepostSchema.parse(body); + + // Find the target post + const post = await db.query.posts.findFirst({ + where: eq(posts.id, data.postId), + }); + + if (!post) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + if (post.isRemoved) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Increment repost count + await db.update(posts) + .set({ repostsCount: post.repostsCount + 1 }) + .where(eq(posts.id, data.postId)); + + // Get or create placeholder user for the remote reposter + const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`; + 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.repost.actorNodeDomain}:${data.repost.actorHandle}`, + handle: remoteHandle, + displayName: data.repost.actorDisplayName, + avatarUrl: data.repost.actorAvatarUrl || null, + publicKey: 'swarm-remote-user', + }).returning(); + remoteUser = newUser; + } + + // Create notification + await db.insert(notifications).values({ + userId: post.userId, + actorId: remoteUser.id, + postId: data.postId, + type: 'repost', + }); + + console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`); + + return NextResponse.json({ + success: true, + message: 'Repost received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Repost error:', error); + return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/interactions/unfollow/route.ts b/src/app/api/swarm/interactions/unfollow/route.ts new file mode 100644 index 0000000..8e361df --- /dev/null +++ b/src/app/api/swarm/interactions/unfollow/route.ts @@ -0,0 +1,83 @@ +/** + * Swarm Unfollow Endpoint + * + * POST: Receive an unfollow from another swarm node + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, users, remoteFollowers } from '@/db'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmUnfollowSchema = z.object({ + targetHandle: z.string(), + unfollow: z.object({ + followerHandle: z.string(), + followerNodeDomain: z.string(), + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/unfollow + * + * Receives an unfollow from another swarm node. + */ +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 = swarmUnfollowSchema.parse(body); + + // Find the target user + const targetUser = await db.query.users.findFirst({ + where: eq(users.handle, data.targetHandle.toLowerCase()), + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Find and remove the remote follower record + const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`; + + const existingFollow = await db.query.remoteFollowers.findFirst({ + where: and( + eq(remoteFollowers.userId, targetUser.id), + eq(remoteFollowers.actorUrl, actorUrl) + ), + }); + + if (!existingFollow) { + return NextResponse.json({ + success: true, + message: 'Not following', + }); + } + + // Remove the follow + await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id)); + + // Update follower count + await db.update(users) + .set({ followersCount: Math.max(0, targetUser.followersCount - 1) }) + .where(eq(users.id, targetUser.id)); + + console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`); + + return NextResponse.json({ + success: true, + message: 'Unfollow received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Unfollow error:', error); + return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/interactions/unlike/route.ts b/src/app/api/swarm/interactions/unlike/route.ts new file mode 100644 index 0000000..c5c99d8 --- /dev/null +++ b/src/app/api/swarm/interactions/unlike/route.ts @@ -0,0 +1,63 @@ +/** + * Swarm Unlike Endpoint + * + * POST: Receive an unlike from another swarm node + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts } from '@/db'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; + +const swarmUnlikeSchema = z.object({ + postId: z.string().uuid(), + unlike: z.object({ + actorHandle: z.string(), + actorNodeDomain: z.string(), + interactionId: z.string(), + timestamp: z.string(), + }), +}); + +/** + * POST /api/swarm/interactions/unlike + * + * Receives an unlike from another swarm node. + */ +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 = swarmUnlikeSchema.parse(body); + + // Find the target post + const post = await db.query.posts.findFirst({ + where: eq(posts.id, data.postId), + }); + + if (!post) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Decrement like count (don't go below 0) + await db.update(posts) + .set({ likesCount: Math.max(0, post.likesCount - 1) }) + .where(eq(posts.id, data.postId)); + + console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`); + + return NextResponse.json({ + success: true, + message: 'Unlike received', + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Unlike error:', error); + return NextResponse.json({ error: 'Failed to process unlike' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts new file mode 100644 index 0000000..81afd79 --- /dev/null +++ b/src/app/api/swarm/posts/[id]/route.ts @@ -0,0 +1,90 @@ +/** + * Swarm Post Endpoint + * + * GET: Returns a single post for swarm requests + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, media } from '@/db'; +import { eq } from 'drizzle-orm'; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/swarm/posts/[id] + * + * Returns a single post with author info. + * Used by other nodes to fetch post details. + */ +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { id: postId } = await context.params; + + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + + // Find the post with author + const post = await db.query.posts.findFirst({ + where: eq(posts.id, postId), + with: { author: true }, + }); + + if (!post) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + if (post.isRemoved) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Get media for the post + const postMedia = await db + .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) + .from(media) + .where(eq(media.postId, postId)); + + 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, + isNsfw: post.isNsfw || author.isNsfw, + likesCount: post.likesCount, + repostsCount: post.repostsCount, + repliesCount: post.repliesCount, + media: postMedia.length > 0 ? postMedia.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, + replyToId: post.replyToId || undefined, + repostOfId: post.repostOfId || undefined, + }); + } catch (error) { + console.error('Swarm post error:', error); + return NextResponse.json( + { error: 'Failed to fetch post' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index 9ff380d..1548f95 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -7,6 +7,7 @@ import { resolveRemoteUser } from '@/lib/activitypub/fetch'; import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities'; import { deliverActivity } from '@/lib/activitypub/outbox'; import { cacheRemoteUserPosts } from '@/lib/activitypub/cache'; +import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow } from '@/lib/swarm/interactions'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -94,23 +95,16 @@ export async function POST(request: Request, context: RouteContext) { const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const remote = parseRemoteHandle(handle); + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (currentUser.isSuspended || currentUser.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); } if (remote) { - const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); - if (!remoteProfile) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); - } - const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox; - if (!targetInbox) { - return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 }); - } - const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const targetHandle = `${remote.handle}@${remote.domain}`; - + + // Check if already following const existingRemoteFollow = await db.query.remoteFollows.findFirst({ where: and( eq(remoteFollows.followerId, currentUser.id), @@ -121,6 +115,62 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Already following' }, { status: 400 }); } + // SWARM-FIRST: Check if this is a Synapsis swarm node + const isSwarm = await isSwarmNode(remote.domain); + + if (isSwarm) { + // Use swarm protocol for Synapsis nodes + const activityId = crypto.randomUUID(); + + const result = await deliverSwarmFollow(remote.domain, { + targetHandle: remote.handle, + follow: { + followerHandle: currentUser.handle, + followerDisplayName: currentUser.displayName || currentUser.handle, + followerAvatarUrl: currentUser.avatarUrl || undefined, + followerBio: currentUser.bio || undefined, + followerNodeDomain: nodeDomain, + interactionId: activityId, + timestamp: new Date().toISOString(), + }, + }); + + if (!result.success) { + console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`); + // Fall through to ActivityPub below + } else { + // Swarm follow succeeded - store the follow locally + await db.insert(remoteFollows).values({ + followerId: currentUser.id, + targetHandle, + targetActorUrl: `swarm://${remote.domain}/${remote.handle}`, + inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`, + activityId, + displayName: null, // Will be fetched later + bio: null, + avatarUrl: null, + }); + + // Update the user's following count + await db.update(users) + .set({ followingCount: currentUser.followingCount + 1 }) + .where(eq(users.id, currentUser.id)); + + console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`); + return NextResponse.json({ success: true, following: true, remote: true, swarm: true }); + } + } + + // FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed + const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); + if (!remoteProfile) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox; + if (!targetInbox) { + return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 }); + } + const activityId = crypto.randomUUID(); const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId); const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`; @@ -243,6 +293,7 @@ export async function DELETE(request: Request, context: RouteContext) { const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const remote = parseRemoteHandle(handle); + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (remote) { if (!db) { @@ -258,7 +309,40 @@ export async function DELETE(request: Request, context: RouteContext) { if (!existingRemoteFollow) { return NextResponse.json({ error: 'Not following' }, { status: 400 }); } - const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + + // SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL) + const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://'); + + if (isSwarmFollow) { + // Use swarm protocol for unfollow + const result = await deliverSwarmUnfollow(remote.domain, { + targetHandle: remote.handle, + unfollow: { + followerHandle: currentUser.handle, + followerNodeDomain: nodeDomain, + interactionId: crypto.randomUUID(), + timestamp: new Date().toISOString(), + }, + }); + + if (!result.success) { + console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`); + // Continue anyway - remove local record + } + + // Remove the follow record + await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id)); + + // Update the user's following count + await db.update(users) + .set({ followingCount: Math.max(0, currentUser.followingCount - 1) }) + .where(eq(users.id, currentUser.id)); + + console.log(`[Swarm] Unfollow delivered to ${remote.domain}`); + return NextResponse.json({ success: true, following: false, remote: true, swarm: true }); + } + + // FALLBACK: Use ActivityPub for non-swarm follows const originalFollow = createFollowActivity( currentUser, existingRemoteFollow.targetActorUrl, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2abfd22..3266f8b 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -192,7 +192,7 @@ export default function LoginPage() { /> ) : ( Synapsis ) : ( - Synapsis + Synapsis )}