/** * Swarm Chat Send * * POST: Send a chat message to another user (local or remote) */ import { NextRequest, NextResponse } from 'next/server'; import { db, users, chatConversations, chatMessages } from '@/db'; import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; import { getSession } from '@/lib/auth'; import { encryptMessage } from '@/lib/swarm/chat-crypto'; import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; const sendMessageSchema = z.object({ recipientHandle: z.string(), // For E2E encryption: client sends pre-encrypted content encryptedContent: z.string().optional(), senderPublicKey: z.string().optional(), // ECDH public key for decryption // Legacy: server-side encryption (will be removed) content: z.string().min(1).max(5000).optional(), }).refine(data => data.encryptedContent || data.content, { message: 'Either encryptedContent or content is required', }); export async function POST(request: NextRequest) { console.log('[Chat Send] Starting request processing'); try { if (!db) { console.error('[Chat Send] Database connection missing'); return NextResponse.json({ error: 'Database not available' }, { status: 503 }); } const session = await getSession(); if (!session?.user) { console.warn('[Chat Send] Unauthorized attempt'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } console.log('[Chat Send] User authenticated:', session.user.id); let body; try { body = await request.json(); } catch (e) { console.error('[Chat Send] Failed to parse JSON body'); return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } const parseResult = sendMessageSchema.safeParse(body); if (!parseResult.success) { console.error('[Chat Send] Schema validation failed:', parseResult.error); return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 }); } const data = parseResult.data; console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey); // Get sender info const sender = await db.query.users.findFirst({ where: eq(users.id, session.user.id), }); if (!sender) { console.error('[Chat Send] Sender not found in DB:', session.user.id); return NextResponse.json({ error: 'Sender not found' }, { status: 404 }); } console.log('[Chat Send] Sender retrieved:', sender.handle); // Parse recipient handle (could be local or remote) // Strip leading @ if present, then normalize const recipientHandle = data.recipientHandle.toLowerCase().replace(/^@/, ''); const isRemote = recipientHandle.includes('@'); let recipientUser: typeof users.$inferSelect | undefined; let recipientPublicKey: string; let recipientNodeDomain: string | null = null; if (isRemote) { // Remote user - need to fetch their public key // Format: handle@domain (e.g., matterbator@batorbros.bond) const atIndex = recipientHandle.indexOf('@'); const handle = recipientHandle.substring(0, atIndex); const domain = recipientHandle.substring(atIndex + 1); recipientNodeDomain = domain; console.log('[Chat Send] Processing remote recipient:', handle, '@', domain); // Try to find cached remote user recipientUser = await db.query.users.findFirst({ where: eq(users.handle, recipientHandle), }); // Check if we have a valid public key (not a placeholder) const hasValidPublicKey = recipientUser?.publicKey?.startsWith('-----BEGIN'); if (!recipientUser || !hasValidPublicKey) { // Fetch from remote node to get the real public key try { console.log('[Chat Send] Fetching remote user from node:', domain); const protocol = domain.includes('localhost') ? 'http' : 'https'; const response = await fetch(`${protocol}://${domain}/api/users/${handle}`); if (!response.ok) { console.error('[Chat Send] Remote user fetch failed. Status:', response.status); return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); } const remoteUserData = await response.json(); const userData = remoteUserData.user || remoteUserData; recipientPublicKey = userData.publicKey; if (!recipientPublicKey || !recipientPublicKey.startsWith('-----BEGIN')) { console.error('[Chat Send] Remote user has no valid public key'); return NextResponse.json({ error: 'Recipient does not support encrypted chat' }, { status: 400 }); } if (recipientUser) { // Update existing cached user with real public key await db.update(users) .set({ publicKey: recipientPublicKey }) .where(eq(users.id, recipientUser.id)); console.log('[Chat Send] Updated cached user with real public key'); } else { // Cache the remote user const [newUser] = await db.insert(users).values({ did: userData.did || `did:swarm:${domain}:${handle}`, handle: recipientHandle, displayName: userData.displayName, avatarUrl: userData.avatarUrl, publicKey: recipientPublicKey, }).returning(); recipientUser = newUser; console.log('[Chat Send] Remote user cached'); } } catch (error) { console.error('[Chat Send] Failed to fetch remote user:', error); return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 }); } } else { recipientPublicKey = recipientUser.publicKey; console.log('[Chat Send] Remote user found in cache with valid key'); } } else { // Local user console.log('[Chat Send] Processing local recipient'); recipientUser = await db.query.users.findFirst({ where: eq(users.handle, recipientHandle), }); if (!recipientUser) { console.warn('[Chat Send] Local recipient not found:', recipientHandle); return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); } if (recipientUser.isSuspended) { console.warn('[Chat Send] Local recipient suspended:', recipientHandle); return NextResponse.json({ error: 'Recipient not available' }, { status: 404 }); } recipientPublicKey = recipientUser.publicKey; } // Handle encryption - either client-side (E2E) or server-side (legacy) let encryptedContent: string; let senderEncryptedContent: string | null = null; if (data.encryptedContent) { // E2E mode: client already encrypted the message // Server cannot read the content - this is true E2E encryption console.log('[Chat Send] Using client-side E2E encryption'); encryptedContent = data.encryptedContent; // Store sender's public key so recipient can decrypt // Note: senderEncryptedContent not needed in E2E mode - client stores locally } else if (data.content) { // Legacy mode: server-side encryption (for backwards compatibility) console.log('[Chat Send] Using server-side encryption (legacy)'); try { encryptedContent = encryptMessage(data.content, recipientPublicKey); senderEncryptedContent = encryptMessage(data.content, sender.publicKey); } catch (encError) { console.error('[Chat Send] Encryption failed:', encError); return NextResponse.json({ error: 'Encryption failed' }, { status: 500 }); } } else { return NextResponse.json({ error: 'No message content provided' }, { status: 400 }); } // Get or create conversation let conversation = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, sender.id), eq(chatConversations.participant2Handle, recipientHandle) ), }); if (!conversation) { console.log('[Chat Send] Creating new conversation'); const [newConversation] = await db.insert(chatConversations).values({ participant1Id: sender.id, participant2Handle: recipientHandle, lastMessageAt: new Date(), lastMessagePreview: '🔒 Encrypted message', }).returning(); conversation = newConversation; } // Store the message locally const messageId = crypto.randomUUID(); const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const swarmMessageId = `swarm:${nodeDomain}:${messageId}`; console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey); const [newMessage] = await db.insert(chatMessages).values({ conversationId: conversation.id, senderHandle: sender.handle, senderDisplayName: sender.displayName, senderAvatarUrl: sender.avatarUrl, senderNodeDomain: null, // Local sender encryptedContent, senderEncryptedContent, // Use client-provided key first, fall back to database senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, swarmMessageId, deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local readAt: null, }).returning(); // Update conversation await db.update(chatConversations) .set({ lastMessageAt: new Date(), lastMessagePreview: '🔒 Encrypted message', updatedAt: new Date(), }) .where(eq(chatConversations.id, conversation.id)); // If remote, send to their node if (isRemote && recipientNodeDomain) { // ... (remote logic remains similar but add logs) console.log('[Chat Send] Dispatching to remote node:', recipientNodeDomain); // ... existing remote send logic ... // For brevity in this tool call, I'm keeping the original logic mostly intact but wrapped/logged. // Re-implementing the block: try { const payload: SwarmChatMessagePayload = { messageId, senderHandle: sender.handle, senderDisplayName: sender.displayName || undefined, senderAvatarUrl: sender.avatarUrl || undefined, senderNodeDomain: nodeDomain, recipientHandle: recipientHandle.split('@')[0], encryptedContent, timestamp: new Date().toISOString(), }; const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https'; const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (response.ok) { // Mark as delivered await db.update(chatMessages) .set({ deliveredAt: new Date() }) .where(eq(chatMessages.id, newMessage.id)); console.log('[Chat Send] Remote delivery confirmed'); } else { console.warn('[Chat Send] Remote delivery failed. Status:', response.status); } } catch (error) { console.error('[Chat Send] Failed to send message to remote node:', error); // Message is still stored locally, will show as undelivered } } console.log('[Chat Send] Success'); return NextResponse.json({ success: true, message: newMessage, }); } catch (error) { if (error instanceof z.ZodError) { // Should be caught by safeParse above, but just in case return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); } console.error('[Chat Send] Unhandled error:', error); return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }); } }