diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 6737f5e..3663d70 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -11,8 +11,9 @@ type RouteContext = { params: Promise<{ id: string }> }; */ function extractSwarmDomain(apId: string | null): string | null { if (!apId?.startsWith('swarm:')) return null; - const parts = apId.split(':'); - return parts.length >= 2 ? parts[1] : null; + const lastColonIndex = apId.lastIndexOf(':'); + if (lastColonIndex <= 6) return null; + return apId.substring(6, lastColonIndex); } /** @@ -26,8 +27,10 @@ function isSwarmPost(apId: string | null): boolean { * 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; + if (!apId) return null; + const lastColonIndex = apId.lastIndexOf(':'); + if (lastColonIndex === -1) return null; + return apId.substring(lastColonIndex + 1); } // Like a post @@ -45,15 +48,15 @@ export async function POST(request: Request, context: RouteContext) { // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); - const originalPostId = postId.split(':')[2]; - + 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: { @@ -65,12 +68,12 @@ export async function POST(request: Request, context: RouteContext) { 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 }); } @@ -147,12 +150,12 @@ export async function POST(request: Request, context: RouteContext) { 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: { @@ -164,7 +167,7 @@ export async function POST(request: Request, context: RouteContext) { timestamp: new Date().toISOString(), }, }); - + if (result.success) { console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`); } else { @@ -223,14 +226,14 @@ export async function DELETE(request: Request, context: RouteContext) { 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: { @@ -240,12 +243,12 @@ export async function DELETE(request: Request, context: RouteContext) { 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 }); } @@ -286,12 +289,12 @@ export async function DELETE(request: Request, context: RouteContext) { 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: { @@ -301,7 +304,7 @@ export async function DELETE(request: Request, context: RouteContext) { timestamp: new Date().toISOString(), }, }); - + if (result.success) { console.log(`[Swarm] Unlike delivered to ${targetDomain}`); } else { diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index a0c3485..73b2c77 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -11,8 +11,9 @@ type RouteContext = { params: Promise<{ id: string }> }; */ function extractSwarmDomain(apId: string | null): string | null { if (!apId?.startsWith('swarm:')) return null; - const parts = apId.split(':'); - return parts.length >= 2 ? parts[1] : null; + const lastColonIndex = apId.lastIndexOf(':'); + if (lastColonIndex <= 6) return null; + return apId.substring(6, lastColonIndex); } /** @@ -26,8 +27,10 @@ function isSwarmPost(apId: string | null): boolean { * 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; + if (!apId) return null; + const lastColonIndex = apId.lastIndexOf(':'); + if (lastColonIndex === -1) return null; + return apId.substring(lastColonIndex + 1); } // Repost a post @@ -45,15 +48,15 @@ export async function POST(request: Request, context: RouteContext) { // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); - const originalPostId = postId.split(':')[2]; - + const originalPostId = extractSwarmPostId(postId); + if (!targetDomain || !originalPostId) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } // Deliver repost directly to the origin node const { deliverSwarmRepost } = await import('@/lib/swarm/interactions'); - + const result = await deliverSwarmRepost(targetDomain, { postId: originalPostId, repost: { @@ -66,12 +69,12 @@ export async function POST(request: Request, context: RouteContext) { timestamp: new Date().toISOString(), }, }); - + if (!result.success) { console.error(`[Swarm] Repost delivery failed: ${result.error}`); return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 }); } - + console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, reposted: true }); } @@ -158,12 +161,12 @@ export async function POST(request: Request, context: RouteContext) { 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: { @@ -176,7 +179,7 @@ export async function POST(request: Request, context: RouteContext) { timestamp: new Date().toISOString(), }, }); - + if (result.success) { console.log(`[Swarm] Repost delivered to ${targetDomain}`); } else { @@ -241,15 +244,15 @@ export async function DELETE(request: Request, context: RouteContext) { // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); - const originalPostId = postId.split(':')[2]; - + const originalPostId = extractSwarmPostId(postId); + if (!targetDomain || !originalPostId) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); } // Deliver unrepost directly to the origin node const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions'); - + const result = await deliverSwarmUnrepost(targetDomain, { postId: originalPostId, unrepost: { @@ -259,12 +262,12 @@ export async function DELETE(request: Request, context: RouteContext) { timestamp: new Date().toISOString(), }, }); - + if (!result.success) { console.error(`[Swarm] Unrepost delivery failed: ${result.error}`); return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 }); } - + console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, reposted: false }); } diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 323c80a..1c70dbc 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -19,11 +19,11 @@ export async function GET( // 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]; - + const lastColonIndex = id.lastIndexOf(':'); + if (lastColonIndex > 6) { + const originDomain = id.substring(6, lastColonIndex); + const originalPostId = id.substring(lastColonIndex + 1); + // Fetch from origin node in real-time try { const protocol = originDomain.includes('localhost') ? 'http' : 'https'; @@ -31,10 +31,10 @@ export async function GET( headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); - + if (res.ok) { const data = await res.json(); - + // Transform to match expected format mainPost = { id: id, @@ -64,7 +64,7 @@ export async function GET( linkPreviewDescription: data.post.linkPreviewDescription, linkPreviewImage: data.post.linkPreviewImage, }; - + // Transform replies replyPosts = (data.replies || []).map((r: any) => ({ id: `swarm:${originDomain}:${r.id}`, @@ -90,17 +90,17 @@ export async function GET( 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; @@ -108,7 +108,7 @@ export async function GET( } catch { // Not logged in or timeout } - + return NextResponse.json({ post: mainPost, replies: replyPosts, @@ -117,7 +117,7 @@ export async function GET( } catch (err) { console.error(`[Swarm] Failed to fetch post from ${originDomain}:`, err); } - + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } } @@ -257,6 +257,100 @@ export async function DELETE( const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + // Handle swarm post IDs (format: swarm:domain:uuid) + if (id.startsWith('swarm:')) { + const lastColonIndex = id.lastIndexOf(':'); + if (lastColonIndex > 6) { + const originDomain = id.substring(6, lastColonIndex); + const originalPostId = id.substring(lastColonIndex + 1); + + // We need to fetch the post from the remote node to check if the current user is the author + // The remote node should have the post with proper attribution + 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(5000), + }); + + if (res.ok) { + const data = await res.json(); + const remotePost = data.post; + + // Check authorship + // Format: handle or handle@domain + // If the user authored it, the remote post author handle should match current user handle + // AND the remote post author node domain should be THIS node + + // The remote node returns author as: + // { handle: "user", displayName: "...", nodeDomain: "our-domain" } + // OR if it's a "local" user on that node (which shouldn't correspond to us unless we possess that account) + + // In the swarm reply scenario, the remote node stores our user as a "remote user" + // Its logic: handle = "user@our-domain" + + // So we check if remotePost.author.handle starts with user.handle + // AND (remotePost.author.handle ends with @nodeDomain OR remotePost.nodeDomain == nodeDomain?) + + let isAuthor = false; + if (remotePost.author.handle === user.handle || + remotePost.author.handle === `${user.handle}@${nodeDomain}`) { + isAuthor = true; + } + + if (!isAuthor) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // It is our post (or reply). We can delete it. + // We need the original ID that WE sent when creating it. + // Ideally, the remote node preserves the apId we sent, which contains our original ID. + // But the remote node endpoint returns 'apId', let's use that if available. + + let replyIdToDelete = originalPostId; // Fallback to their ID if we can't find ours (unlikely to work for replies endpoint) + + // If we are deleting a reply we sent + // The remote node has it stored. + // We need to send DELETE /api/swarm/replies with { replyId: } + // The remote node checks `swarm:ourDomain:` + + // We need to extract from the remote post's apId + // remotePost.apId should be `swarm:ourDomain:ourId` + + if (remotePost.apId && remotePost.apId.startsWith(`swarm:${nodeDomain}:`)) { + const parts = remotePost.apId.split(':'); + if (parts.length >= 3) { + replyIdToDelete = parts[2]; + } + } + + // Propagate deletion + const deleteRes = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + replyId: replyIdToDelete, + nodeDomain, + authorHandle: user.handle, + }), + signal: AbortSignal.timeout(5000), + }); + + if (deleteRes.ok) { + return NextResponse.json({ success: true }); + } else { + return NextResponse.json({ error: 'Failed to delete on remote node' }, { status: deleteRes.status }); + } + } else { + return NextResponse.json({ error: 'Remote post not found for verification' }, { status: 404 }); + } + } catch (err) { + console.error('[Swarm] Error deleting remote post:', err); + return NextResponse.json({ error: 'Failed to communicate with remote node' }, { status: 502 }); + } + } + } + const post = await db.query.posts.findFirst({ where: eq(posts.id, id), with: { @@ -272,7 +366,7 @@ export async function DELETE( // OR if user owns the parent post (can delete replies on their posts) const isPostOwner = post.userId === user.id; const isBotOwner = post.bot && post.bot.ownerId === user.id; - + // Check if user owns the parent post (for deleting replies on their posts) let isParentPostOwner = false; if (post.replyToId) { @@ -283,7 +377,7 @@ export async function DELETE( isParentPostOwner = true; } } - + if (!isPostOwner && !isBotOwner && !isParentPostOwner) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } @@ -302,34 +396,33 @@ export async function DELETE( // 2. If this is a reply to a swarm post, notify the origin node to delete it if (post.swarmReplyToId) { - const parts = post.swarmReplyToId.split(':'); - if (parts.length >= 3) { - const originDomain = parts[1]; - - // Fire and forget - don't block on this - (async () => { - try { - const protocol = originDomain.includes('localhost') ? 'http' : 'https'; - const res = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - replyId: post.id, - nodeDomain, - authorHandle: user.handle, - }), - signal: AbortSignal.timeout(5000), - }); - - if (res.ok) { - console.log(`[Swarm] Deletion propagated to ${originDomain}`); - } else { - console.error(`[Swarm] Failed to propagate deletion: ${res.status}`); - } - } catch (err) { - console.error('[Swarm] Error propagating deletion:', err); + // Correctly parse swarm:domain:postId where domain might contain port + const lastColonIndex = post.swarmReplyToId.lastIndexOf(':'); + if (lastColonIndex > 6) { // 'swarm:'.length = 6 + const originDomain = post.swarmReplyToId.substring(6, lastColonIndex); + + // Propagate deletion to origin node + try { + const protocol = originDomain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + replyId: post.id, + nodeDomain, + authorHandle: user.handle, + }), + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + console.log(`[Swarm] Deletion propagated to ${originDomain}`); + } else { + console.error(`[Swarm] Failed to propagate deletion: ${res.status}`); } - })(); + } catch (err) { + console.error('[Swarm] Error propagating deletion:', err); + } } } diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts index 5de6972..47e76f0 100644 --- a/src/app/api/swarm/posts/[id]/route.ts +++ b/src/app/api/swarm/posts/[id]/route.ts @@ -51,10 +51,11 @@ export async function GET(request: NextRequest, context: RouteContext) { }); const author = post.author as any; - + return NextResponse.json({ post: { id: post.id, + apId: post.apId, // Expose apId for swarm coordination (e.g. deletion recovery) content: post.content, createdAt: post.createdAt.toISOString(), likesCount: post.likesCount,