diff --git a/SWARM_CHAT.md b/SWARM_CHAT.md deleted file mode 100644 index f466d6a..0000000 --- a/SWARM_CHAT.md +++ /dev/null @@ -1,160 +0,0 @@ -# Swarm Chat - -A real-time, end-to-end encrypted chat system built exclusively for the Synapsis Swarm network. - -## Features - -- **End-to-End Encryption**: Messages are encrypted using recipient's public key -- **Cross-Node Messaging**: Chat with users on any Synapsis node -- **Real-Time Delivery**: Messages delivered instantly via swarm inbox -- **Read Receipts**: See when messages are delivered and read -- **Typing Indicators**: Know when someone is typing (coming soon) -- **Native Swarm Protocol**: Built specifically for the swarm network - -## Architecture - -### Database Schema - -**chat_conversations**: Tracks conversations between users -- Stores participant info and last message preview -- Unique constraint ensures one conversation per user pair - -**chat_messages**: Individual encrypted messages -- Content encrypted with recipient's public key -- Swarm message ID for deduplication -- Delivery and read status tracking - -**chat_typing_indicators**: Real-time typing status (future) - -### API Endpoints - -**POST /api/swarm/chat/send** -- Send a message to any user (local or remote) -- Encrypts content with recipient's public key -- Delivers to remote nodes via swarm inbox - -**POST /api/swarm/chat/inbox** -- Receives messages from other swarm nodes -- Validates and stores encrypted messages -- Updates conversation metadata - -**GET /api/swarm/chat/conversations** -- Lists all conversations for current user -- Includes unread counts and last message preview - -**GET /api/swarm/chat/messages** -- Fetches messages for a conversation -- Supports cursor-based pagination -- Returns encrypted content for client-side decryption - -**PATCH /api/swarm/chat/messages** -- Marks messages as read -- Updates read receipts - -### Encryption - -Messages are encrypted using RSA-OAEP with SHA-256: - -1. **Sending**: Message encrypted with recipient's public key -2. **Storage**: Only encrypted content stored in database -3. **Decryption**: Client-side decryption using user's private key - -This ensures true end-to-end encryption - even the server cannot read messages. - -## Usage - -### Starting a Chat - -```typescript -// Send a message to start a conversation -await fetch('/api/swarm/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientHandle: 'user@remote.node', - content: 'Hello!', - }), -}); -``` - -### Receiving Messages - -Messages are automatically received via the swarm inbox endpoint. The system: -1. Validates the sender and recipient -2. Checks for duplicate messages -3. Creates or updates the conversation -4. Stores the encrypted message -5. Returns success to the sender - -### Client-Side Decryption - -```typescript -import { decryptMessage } from '@/lib/swarm/chat-crypto'; - -// Decrypt a message using user's private key -const plaintext = decryptMessage( - message.encryptedContent, - userPrivateKey -); -``` - -## Security Considerations - -1. **Private Key Storage**: User private keys should be encrypted at rest -2. **Key Derivation**: Consider using password-based key derivation -3. **Forward Secrecy**: Future enhancement - implement Signal protocol -4. **Message Signing**: Verify sender authenticity with signatures -5. **Rate Limiting**: Prevent spam and abuse - -## Future Enhancements - -- [ ] Group chats (multi-party encryption) -- [ ] Voice/video calls (WebRTC) -- [ ] File attachments (encrypted) -- [ ] Message reactions -- [ ] Message editing/deletion -- [ ] Typing indicators (real-time) -- [ ] Online/offline status -- [ ] Push notifications -- [ ] Desktop notifications -- [ ] Message search -- [ ] Conversation archiving -- [ ] Block/mute users -- [ ] Forward secrecy (Signal protocol) - -## Migration - -Run the migration to create the chat tables: - -```bash -npm run db:generate -npm run db:migrate -``` - -## Testing - -Test the chat system: - -1. Create two accounts on different nodes -2. Navigate to `/chat` on one account -3. Click "New Chat" and enter the other user's handle -4. Send a message -5. Check the other account's `/chat` page - -## Why Swarm Chat? - -Swarm Chat was built from the ground up for the Synapsis network: - -- **True E2E Encryption**: Messages encrypted with recipient's public key -- **Real-Time**: Direct delivery, no polling required -- **Proper Chat UX**: Conversations, read receipts, typing indicators -- **Lightweight**: Simple JSON protocol -- **Swarm-Native**: Built for the swarm, not retrofitted - -## Contributing - -Contributions welcome! Priority areas: -- Client-side encryption implementation -- Real-time updates (WebSocket/SSE) -- Mobile-responsive UI improvements -- Group chat support diff --git a/TURNSTILE_SETUP.md b/TURNSTILE_SETUP.md deleted file mode 100644 index 8d0bb0d..0000000 --- a/TURNSTILE_SETUP.md +++ /dev/null @@ -1,72 +0,0 @@ -# Cloudflare Turnstile Setup Guide - -Cloudflare Turnstile has been fully integrated into your Synapsis node to protect against bot registrations and logins. - -## How It Works - -1. **Admin Configuration**: Admins can add their Cloudflare Turnstile keys in the Admin Settings panel -2. **Automatic Activation**: Once both Site Key and Secret Key are configured, Turnstile is automatically enabled -3. **Frontend Integration**: The Turnstile widget appears on login and registration forms -4. **Server Verification**: All login/register requests are verified server-side with Cloudflare - -## Setup Steps - -### 1. Get Turnstile Keys from Cloudflare - -1. Go to https://dash.cloudflare.com/?to=/:account/turnstile -2. Create a new site -3. Copy your **Site Key** (public) and **Secret Key** (private) - -### 2. Configure in Admin Panel - -1. Log in as an admin -2. Go to Admin → Settings tab -3. Scroll to "Cloudflare Turnstile (Bot Protection)" section -4. Paste your Site Key and Secret Key -5. Click "Save Settings" - -### 3. Test It - -1. Log out -2. Go to the login page -3. You should see the Turnstile widget appear -4. Complete the challenge and try logging in - -## Features - -- ✅ Automatic widget rendering when keys are configured -- ✅ Works on both login and registration forms -- ✅ Server-side token verification -- ✅ Automatic widget reset on form errors -- ✅ Graceful fallback if Turnstile is not configured -- ✅ Submit button disabled until challenge is completed -- ✅ IP address forwarding for better verification - -## Technical Details - -### Database Schema -- `nodes.turnstile_site_key` - Public site key (exposed to frontend) -- `nodes.turnstile_secret_key` - Private secret key (server-side only) - -### API Endpoints Modified -- `POST /api/auth/login` - Now accepts optional `turnstileToken` -- `POST /api/auth/register` - Now accepts optional `turnstileToken` -- `GET /api/node` - Returns `turnstileSiteKey` for frontend - -### Files Modified -- `src/db/schema.ts` - Added Turnstile fields -- `src/lib/turnstile.ts` - Verification helper functions -- `src/app/api/auth/login/route.ts` - Token verification -- `src/app/api/auth/register/route.ts` - Token verification -- `src/app/api/node/route.ts` - Expose site key -- `src/app/api/admin/node/route.ts` - Save/update keys -- `src/app/admin/page.tsx` - Admin UI for configuration -- `src/app/login/page.tsx` - Frontend widget integration - -## Security Notes - -- The Secret Key is NEVER exposed to the frontend -- Only the Site Key is public -- Verification happens server-side with Cloudflare's API -- Failed verifications reject the login/registration attempt -- IP addresses are forwarded for better bot detection diff --git a/check-keys.ts b/check-keys.ts deleted file mode 100644 index 2547e89..0000000 --- a/check-keys.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { db, users } from './src/db'; -import { eq } from 'drizzle-orm'; - -async function checkKeys() { - const allUsers = await db.select({ - handle: users.handle, - hasChatPublicKey: users.chatPublicKey, - hasChatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted, - }).from(users); - - console.log('Users and their chat keys:'); - allUsers.forEach(u => { - console.log(`- ${u.handle}: chatPublicKey=${!!u.hasChatPublicKey}, chatPrivateKeyEncrypted=${!!u.hasChatPrivateKeyEncrypted}`); - }); - - process.exit(0); -} - -checkKeys().catch(console.error); diff --git a/clean-old-messages.ts b/clean-old-messages.ts deleted file mode 100644 index 157939b..0000000 --- a/clean-old-messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { db, chatMessages } from './src/db'; -import { isNull } from 'drizzle-orm'; - -async function cleanOldMessages() { - console.log('Deleting old RSA-encrypted messages...'); - - const result = await db.delete(chatMessages) - .where(isNull(chatMessages.senderChatPublicKey)); - - console.log('Deleted old messages. Now only E2E encrypted messages remain.'); - process.exit(0); -} - -cleanOldMessages().catch(console.error); diff --git a/debug_chat.ts b/debug_chat.ts deleted file mode 100644 index 6a73cfc..0000000 --- a/debug_chat.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import { db } from './src/db/index'; -import { users } from './src/db/schema'; -import { chatConversations } from './src/db/schema'; - -async function main() { - console.log('--- USERS ---'); - try { - const allUsers = await db.select().from(users); - console.log(`Found ${allUsers.length} users.`); - allUsers.forEach(u => { - console.log(`User: ${u.handle} | ID: ${u.id} | Local: ${!u.handle.includes('@')}`); - }); - - console.log('\n--- CONVERSATIONS ---'); - const convs = await db.select().from(chatConversations); - console.log(`Found ${convs.length} conversations.`); - convs.forEach(c => { - console.log(`Conv: ${c.id} | Type: ${c.type} | P1: ${c.participant1Id} | P2Handle: ${c.participant2Handle}`); - }); - - } catch (e) { - console.error('Error:', e); - } - process.exit(0); -} -main(); diff --git a/inspect_messages.ts b/inspect_messages.ts deleted file mode 100644 index 0c20387..0000000 --- a/inspect_messages.ts +++ /dev/null @@ -1,26 +0,0 @@ -import 'dotenv/config'; -import { db } from './src/db/index'; -import { chatMessages } from './src/db/schema'; -import { desc } from 'drizzle-orm'; - -async function main() { - console.log('--- LATEST CHAT MESSAGES ---'); - try { - const messages = await db.select().from(chatMessages).orderBy(desc(chatMessages.createdAt)).limit(20); - console.log(`Found ${messages.length} messages.`); - messages.forEach(m => { - console.log(`\nID: ${m.id}`); - console.log(`Sender: ${m.senderHandle}`); - console.log(`Created: ${m.createdAt}`); - console.log(`EncryptedContent (${m.encryptedContent?.length} chars): ${m.encryptedContent}`); - console.log(`SenderEncryptedContent (${m.senderEncryptedContent?.length} chars): ${m.senderEncryptedContent}`); - console.log('-----------------------------------'); - }); - - } catch (e) { - console.error('Error:', e); - } - process.exit(0); -} - -main(); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index d62143a..bf60b76 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -102,6 +102,7 @@ export async function GET(request: Request) { post: row.postId ? { id: row.postId, content: row.postContent, + authorHandle: row.actorHandle, // The actor is the post author for likes/reposts } : null, }; }); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index b55a550..5dd63e6 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -115,7 +115,10 @@ export async function POST(request: Request) { } } - // If this is a reply to a swarm post, deliver it to the origin node + // 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 { @@ -153,6 +156,7 @@ export async function POST(request: Request) { } })(); } + */ // Handle local mentions (create notifications for users on this node) (async () => { diff --git a/src/app/api/swarm/inbox/route.ts b/src/app/api/swarm/inbox/route.ts index e0aefbf..0c7bee0 100644 --- a/src/app/api/swarm/inbox/route.ts +++ b/src/app/api/swarm/inbox/route.ts @@ -43,114 +43,12 @@ const swarmPostSchema = z.object({ /** * POST /api/swarm/inbox * - * Receives a post from another swarm node. - * Stores it for local users who follow the author. + * DEPRECATED: This endpoint is disabled. + * We now use real-time pull-based federation via /api/swarm/timeline + * instead of push-based caching. */ 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 }); - } + return NextResponse.json({ + error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.', + }, { status: 410 }); // 410 Gone } diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts index 75268d4..6211832 100644 --- a/src/app/api/swarm/replies/route.ts +++ b/src/app/api/swarm/replies/route.ts @@ -30,86 +30,13 @@ const swarmReplySchema = z.object({ /** * POST /api/swarm/replies * - * Receives a reply from another node in the swarm. - * The reply is stored as a remote reply linked to the local post. + * DEPRECATED: This endpoint is disabled. + * We now use real-time pull-based federation instead of push-based caching. */ 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 = swarmReplySchema.parse(body); - - // Verify the target post exists on this node - const targetPost = await db.query.posts.findFirst({ - where: eq(posts.id, data.postId), - }); - - if (!targetPost) { - return NextResponse.json({ error: 'Post not found' }, { status: 404 }); - } - - // Check if we already have this reply (by swarm ID) - const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`; - const existingReply = await db.query.posts.findFirst({ - where: eq(posts.apId, swarmReplyId), - }); - - if (existingReply) { - return NextResponse.json({ success: true, message: 'Reply already exists' }); - } - - // We need a system user to attribute swarm replies to - // For now, we'll store them with metadata in the apId/apUrl fields - // and create a virtual representation - - // Get or create a placeholder user for this remote author - let remoteUser = await db.query.users.findFirst({ - where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`), - }); - - if (!remoteUser) { - // Create a placeholder user for the remote author - const [newUser] = await db.insert(users).values({ - did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`, - handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`, - displayName: data.reply.author.displayName, - avatarUrl: data.reply.author.avatarUrl || null, - publicKey: 'swarm-remote-user', // Placeholder - }).returning(); - remoteUser = newUser; - } - - // Create the reply post - const [replyPost] = await db.insert(posts).values({ - userId: remoteUser.id, - content: data.reply.content, - replyToId: data.postId, - apId: swarmReplyId, - apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`, - createdAt: new Date(data.reply.createdAt), - }).returning(); - - // Update the parent post's reply count - await db.update(posts) - .set({ repliesCount: targetPost.repliesCount + 1 }) - .where(eq(posts.id, data.postId)); - - console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`); - - return NextResponse.json({ - success: true, - replyId: replyPost.id, - }); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); - } - console.error('[Swarm] Reply error:', error); - return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 }); - } + return NextResponse.json({ + error: 'This endpoint is deprecated. Swarm uses real-time pull-based federation.', + }, { status: 410 }); // 410 Gone } /** diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index bc9bc66..6682176 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -489,15 +489,21 @@ export default function ChatPage() { marginLeft: msg.isSentByMe ? 'auto' : '0', flexDirection: msg.isSentByMe ? 'row-reverse' : 'row' }}> - {!msg.isSentByMe && ( -