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 lastColonIndex = apId.lastIndexOf(':'); if (lastColonIndex <= 6) return null; return apId.substring(6, lastColonIndex); } /** * 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 { if (!apId) return null; const lastColonIndex = apId.lastIndexOf(':'); if (lastColonIndex === -1) return null; return apId.substring(lastColonIndex + 1); } // Like a post export async function POST(request: Request, context: RouteContext) { try { const user = await requireAuth(); const { id: rawId } = await context.params; const postId = decodeURIComponent(rawId); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (user.isSuspended || user.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); } // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); const originalPostId = extractSwarmPostId(postId); if (!targetDomain || !originalPostId) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } // Deliver like directly to the origin node 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.error(`[Swarm] Like delivery failed: ${result.error}`); return NextResponse.json({ error: 'Failed to deliver like to remote node' }, { status: 502 }); } console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, liked: true }); } // Local post - check if it exists const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), }); if (!post) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } if (post.isRemoved) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } // Check if already liked const existingLike = await db.query.likes.findFirst({ where: and( eq(likes.userId, user.id), eq(likes.postId, postId) ), }); if (existingLike) { return NextResponse.json({ error: 'Already liked' }, { status: 400 }); } // Create like await db.insert(likes).values({ userId: user.id, postId, }); // Update post's like count await db.update(posts) .set({ likesCount: post.likesCount + 1 }) .where(eq(posts.id, postId)); if (post.userId !== user.id) { // Create notification with actor info stored directly await db.insert(notifications).values({ userId: post.userId, actorId: user.id, actorHandle: user.handle, actorDisplayName: user.displayName, actorAvatarUrl: user.avatarUrl, actorNodeDomain: null, // Local user postId, postContent: post.content?.slice(0, 200) || null, type: 'like', }); // Also notify bot owner if this is a bot's post const postAuthor = await db.query.users.findFirst({ where: eq(users.id, post.userId), }); if (postAuthor?.isBot && postAuthor.botOwnerId) { await db.insert(notifications).values({ userId: postAuthor.botOwnerId, actorId: user.id, actorHandle: user.handle, actorDisplayName: user.displayName, actorAvatarUrl: user.avatarUrl, actorNodeDomain: null, postId, postContent: post.content?.slice(0, 200) || null, type: 'like', }); } } // If this is a cached swarm post (has swarm: apId), also deliver to origin 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}`); } } 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'); // Get the post author's actor URL const postWithAuthor = await db.query.posts.findFirst({ where: eq(posts.id, postId), with: { author: true }, }); if (!postWithAuthor?.author) return; const author = postWithAuthor.author as { handle: string }; console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`); } catch (err) { console.error('[Federation] Error federating like:', err); } })(); } return NextResponse.json({ success: true, liked: true }); } catch (error) { if (error instanceof Error && error.message === 'Authentication required') { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } return NextResponse.json({ error: 'Failed to like post' }, { status: 500 }); } } // Unlike a post export async function DELETE(request: Request, context: RouteContext) { try { const user = await requireAuth(); const { id: rawId } = await context.params; const postId = decodeURIComponent(rawId); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; if (user.isSuspended || user.isSilenced) { return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); } // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); const originalPostId = postId.split(':')[2]; if (!targetDomain || !originalPostId) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } // Deliver unlike directly to the origin node 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.error(`[Swarm] Unlike delivery failed: ${result.error}`); return NextResponse.json({ error: 'Failed to deliver unlike to remote node' }, { status: 502 }); } console.log(`[Swarm] Unlike delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, liked: false }); } // Local post - check if it exists const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), }); if (!post) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } if (post.isRemoved) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } // Find the like const existingLike = await db.query.likes.findFirst({ where: and( eq(likes.userId, user.id), eq(likes.postId, postId) ), }); if (!existingLike) { return NextResponse.json({ error: 'Not liked' }, { status: 400 }); } // Remove like await db.delete(likes).where(eq(likes.id, existingLike.id)); // Update post's like count await db.update(posts) .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') { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 }); } }