From 830c062fa12f81c078bb9e6058cbd5c408e96cfc Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 7 Mar 2026 22:18:16 -0800 Subject: [PATCH] Fix swarm replies and interaction verification --- src/app/admin/page.tsx | 12 ++- src/app/api/posts/route.ts | 19 +++-- src/app/api/posts/swarm/route.ts | 25 +++++- src/app/api/swarm/replies/route.ts | 125 +++++++++++++++++++++++++++-- src/app/notifications/page.tsx | 4 +- src/components/RightSidebar.tsx | 18 +---- src/lib/swarm/signature.ts | 22 ++++- 7 files changed, 186 insertions(+), 39 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 92f9217..3635a53 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -348,6 +348,16 @@ export default function AdminPage() { return date.toLocaleString(); }; + const updateStatusLabel = (() => { + if (loadingUpdateStatus) return 'Checking...'; + if (!updateStatus) return 'Unavailable'; + if (!updateStatus.updater.available) return updateStatus.updater.message || 'Updater unavailable'; + if (updateStatus.updater.status === 'updating') return updateStatus.updater.message || 'Update in progress'; + if (updateStatus.updater.status === 'error') return updateStatus.updater.message || 'Last update failed'; + if (updateStatus.updateAvailable) return 'Update available'; + return 'Up to date'; + })(); + return ( <>
Status:{' '} - {loadingUpdateStatus ? 'Checking…' : updateStatus?.updater.message || 'Ready'} + {updateStatusLabel}
{updateStatus?.updater.lastStartedAt && (
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index bc70554..2602507 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -117,14 +117,11 @@ export async function POST(request: Request) { .where(eq(posts.id, data.replyToId)); } - // DEPRECATED: Push-based federation disabled - // Swarm now uses real-time pull-based federation - // Replies are fetched in real-time from the origin node - /* if (data.swarmReplyTo) { (async () => { try { - const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; + const protocol = data.swarmReplyTo!.nodeDomain.includes('localhost') ? 'http' : 'https'; + const targetUrl = `${protocol}://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; const replyPayload = { postId: data.swarmReplyTo!.postId, @@ -142,10 +139,17 @@ export async function POST(request: Request) { }, }; + const { createSignedPayload } = await import('@/lib/swarm/signature'); + const { payload, signature } = await createSignedPayload(replyPayload); + const response = await fetch(targetUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(replyPayload), + headers: { + 'Content-Type': 'application/json', + 'X-Swarm-Source-Domain': nodeDomain, + 'X-Swarm-Signature': signature, + }, + body: JSON.stringify(payload), }); if (response.ok) { @@ -158,7 +162,6 @@ export async function POST(request: Request) { } })(); } - */ // Handle local mentions (create notifications for users on this node) (async () => { diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index afb899b..f2d37ad 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -5,8 +5,10 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { db, posts } from '@/db'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { getSession } from '@/lib/auth'; +import { and, eq, inArray, sql } from 'drizzle-orm'; /** * GET /api/posts/swarm @@ -31,9 +33,30 @@ export async function GET(request: NextRequest) { // Fetch swarm timeline (no caching - user preferences vary) const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor }); + const swarmReplyIds = timeline.posts.map(post => `swarm:${post.nodeDomain}:${post.id}`); + const localReplyCounts = db && swarmReplyIds.length > 0 + ? await db.select({ + swarmReplyToId: posts.swarmReplyToId, + count: sql`count(*)::int`, + }) + .from(posts) + .where(and( + inArray(posts.swarmReplyToId, swarmReplyIds), + eq(posts.isRemoved, false) + )) + .groupBy(posts.swarmReplyToId) + : []; + const localReplyCountMap = new Map( + localReplyCounts + .filter(row => row.swarmReplyToId) + .map(row => [row.swarmReplyToId as string, row.count]) + ); return NextResponse.json({ - posts: timeline.posts, + posts: timeline.posts.map(post => ({ + ...post, + replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0), + })), sources: timeline.sources, cached: false, fetchedAt: timeline.fetchedAt, diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts index 6211832..8ea4b56 100644 --- a/src/app/api/swarm/replies/route.ts +++ b/src/app/api/swarm/replies/route.ts @@ -6,9 +6,11 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts, users, media } from '@/db'; +import { db, posts, users, media, notifications } from '@/db'; import { eq, desc, and } from 'drizzle-orm'; import { z } from 'zod'; +import { verifySwarmRequest } from '@/lib/swarm/signature'; +import { upsertRemoteUser } from '@/lib/swarm/user-cache'; // Schema for incoming swarm reply const swarmReplySchema = z.object({ @@ -19,7 +21,7 @@ const swarmReplySchema = z.object({ createdAt: z.string(), author: z.object({ handle: z.string(), - displayName: z.string(), + displayName: z.string().optional().nullable(), avatarUrl: z.string().optional(), }), nodeDomain: z.string(), @@ -30,13 +32,122 @@ const swarmReplySchema = z.object({ /** * POST /api/swarm/replies * - * DEPRECATED: This endpoint is disabled. - * We now use real-time pull-based federation instead of push-based caching. + * Receives a signed reply from another swarm node and stores it locally + * against the target post so reply counts, thread views, and notifications work. */ export async function POST(request: NextRequest) { - return NextResponse.json({ - error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.', - }, { status: 410 }); // 410 Gone + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const body = await request.json(); + const validation = swarmReplySchema.safeParse(body); + if (!validation.success) { + return NextResponse.json({ error: 'Invalid request', details: validation.error.issues }, { status: 400 }); + } + + const signature = request.headers.get('X-Swarm-Signature'); + const sourceDomain = request.headers.get('X-Swarm-Source-Domain'); + + if (!signature || !sourceDomain) { + return NextResponse.json({ error: 'Missing swarm signature headers' }, { status: 401 }); + } + + const isValid = await verifySwarmRequest(validation.data, signature, sourceDomain); + if (!isValid) { + return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 }); + } + + const data = validation.data; + if (data.reply.nodeDomain !== sourceDomain) { + return NextResponse.json({ error: 'Source domain mismatch' }, { status: 400 }); + } + + const parentPost = await db.query.posts.findFirst({ + where: and( + eq(posts.id, data.postId), + eq(posts.isRemoved, false) + ), + with: { + author: true, + }, + }); + + if (!parentPost) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + const remoteHandle = `${data.reply.author.handle}@${sourceDomain}`; + const remoteDid = `did:swarm:${sourceDomain}:${data.reply.author.handle}`; + + await upsertRemoteUser({ + handle: remoteHandle, + displayName: data.reply.author.displayName || data.reply.author.handle, + avatarUrl: data.reply.author.avatarUrl || null, + did: remoteDid, + }); + + const remoteUser = await db.query.users.findFirst({ + where: eq(users.handle, remoteHandle), + }); + + if (!remoteUser) { + return NextResponse.json({ error: 'Failed to resolve remote author' }, { status: 500 }); + } + + const replyApId = `swarm:${sourceDomain}:${data.reply.id}`; + const existingReply = await db.query.posts.findFirst({ + where: eq(posts.apId, replyApId), + }); + + if (existingReply) { + return NextResponse.json({ success: true, message: 'Reply already received' }); + } + + const [createdReply] = await db.insert(posts).values({ + userId: remoteUser.id, + content: data.reply.content, + replyToId: data.postId, + apId: replyApId, + apUrl: `https://${sourceDomain}/posts/${data.reply.id}`, + createdAt: new Date(data.reply.createdAt), + updatedAt: new Date(data.reply.createdAt), + }).returning(); + + if (data.reply.mediaUrls?.length) { + await db.insert(media).values( + data.reply.mediaUrls.map((url, index) => ({ + userId: remoteUser.id, + postId: createdReply.id, + url, + altText: `Remote reply attachment ${index + 1}`, + })) + ); + } + + await db.update(posts) + .set({ repliesCount: parentPost.repliesCount + 1 }) + .where(eq(posts.id, data.postId)); + + if (parentPost.userId !== remoteUser.id) { + await db.insert(notifications).values({ + userId: parentPost.userId, + actorHandle: data.reply.author.handle, + actorDisplayName: data.reply.author.displayName || data.reply.author.handle, + actorAvatarUrl: data.reply.author.avatarUrl || null, + actorNodeDomain: sourceDomain, + postId: data.postId, + postContent: data.reply.content.slice(0, 200), + type: 'reply', + }); + } + + return NextResponse.json({ success: true, message: 'Reply received' }); + } catch (error) { + console.error('[Swarm] Receive reply error:', error); + return NextResponse.json({ error: 'Failed to receive reply' }, { status: 500 }); + } } /** diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 20da3c5..ea67312 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -19,7 +19,7 @@ interface NotificationPost { interface Notification { id: string; - type: 'follow' | 'like' | 'repost' | 'mention'; + type: 'follow' | 'like' | 'repost' | 'mention' | 'reply'; createdAt: string; readAt: string | null; actor: NotificationActor | null; @@ -77,6 +77,8 @@ export default function NotificationsPage() { return 'reposted your post'; case 'mention': return 'mentioned you'; + case 'reply': + return 'replied to your post'; default: return 'interacted with you'; } diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index a3c90c9..626e07d 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -21,9 +21,7 @@ export function RightSidebar() { }); const [version, setVersion] = useState<{ version: string; - commit: string | null; buildDate: string | null; - githubUrl: string | null; } | null>(null); const [loading, setLoading] = useState(true); @@ -50,7 +48,7 @@ export function RightSidebar() { fetch('/api/version') .then(res => res.json()) .then(data => setVersion(data)) - .catch(() => setVersion({ version: 'unknown', commit: null, buildDate: null, githubUrl: null })); + .catch(() => setVersion({ version: 'unknown', buildDate: null })); }, []); if (loading) { @@ -123,20 +121,6 @@ export function RightSidebar() { Running{' '} Synapsis {version?.version ? ` ${version.version}` : ''} - {version?.githubUrl && version?.commit && ( - <> - {' • '} - - {version.commit.slice(0, 7)} - - - )}

{nodeInfo.admins.length > 0 && ( diff --git a/src/lib/swarm/signature.ts b/src/lib/swarm/signature.ts index debc7d0..5cb8cb3 100644 --- a/src/lib/swarm/signature.ts +++ b/src/lib/swarm/signature.ts @@ -9,28 +9,42 @@ import crypto from 'crypto'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; +import { canonicalize } from '@/lib/crypto/user-signing'; /** * Sign a payload with the node's private key */ export function signPayload(payload: any, privateKey: string): string { - const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort()); + const canonicalPayload = canonicalize(payload); const sign = crypto.createSign('SHA256'); sign.update(canonicalPayload); sign.end(); return sign.sign(privateKey, 'base64'); } +function normalizePublicKey(publicKey: string): crypto.KeyObject | string { + if (publicKey.includes('BEGIN PUBLIC KEY')) { + return publicKey; + } + + const cleanKey = publicKey.replace(/[\s\n\r]/g, ''); + return crypto.createPublicKey({ + key: Buffer.from(cleanKey, 'base64'), + format: 'der', + type: 'spki', + }); +} + /** * Verify a signature using the sender's public key */ export function verifySignature(payload: any, signature: string, publicKey: string): boolean { try { - const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort()); + const canonicalPayload = canonicalize(payload); const verify = crypto.createVerify('SHA256'); verify.update(canonicalPayload); verify.end(); - return verify.verify(publicKey, signature, 'base64'); + return verify.verify(normalizePublicKey(publicKey), signature, 'base64'); } catch (error) { console.error('[Signature] Verification failed:', error); return false; @@ -112,7 +126,7 @@ export async function verifyUserInteraction( let publicKey: string | null = null; - if (user?.publicKey && user.publicKey.startsWith('-----BEGIN')) { + if (user?.publicKey) { publicKey = user.publicKey; } else { // Fetch from remote node