diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6253597..fc3dc8d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; const loginSchema = z.object({ email: z.string().email(), password: z.string(), - turnstileToken: z.string().optional(), + turnstileToken: z.string().optional().nullable(), }); export async function POST(request: Request) { @@ -14,7 +14,7 @@ export async function POST(request: Request) { const body = await request.json(); const data = loginSchema.parse(body); - // Verify Turnstile token if provided + // Verify Turnstile token only if it's provided (meaning Turnstile is enabled) if (data.turnstileToken) { const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined; const isValid = await verifyTurnstileToken(data.turnstileToken, ip); diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts index 4175a62..99a0dfc 100644 --- a/src/app/api/chat/keys/route.ts +++ b/src/app/api/chat/keys/route.ts @@ -53,9 +53,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 }); } - // Validate it looks like a base64 SPKI key - if (chatPublicKey.length < 50 || chatPublicKey.length > 500) { - return NextResponse.json({ error: 'Invalid public key format' }, { status: 400 }); + // Validate it looks like a base64 SPKI key (should be ~120 chars for P-256) + if (chatPublicKey.length < 100 || chatPublicKey.length > 200) { + console.error('[Chat Keys API] Invalid public key length:', chatPublicKey.length); + return NextResponse.json({ + error: `Invalid public key format: expected ~120 characters, got ${chatPublicKey.length}` + }, { status: 400 }); + } + + // Additional validation: try to decode as base64 + try { + const decoded = Buffer.from(chatPublicKey, 'base64'); + if (decoded.length < 80 || decoded.length > 100) { + console.error('[Chat Keys API] Invalid decoded key size:', decoded.length); + return NextResponse.json({ + error: `Invalid public key: decoded size ${decoded.length} bytes (expected ~91 bytes for P-256)` + }, { status: 400 }); + } + } catch (e) { + console.error('[Chat Keys API] Failed to decode public key as base64:', e); + return NextResponse.json({ error: 'Public key is not valid base64' }, { status: 400 }); } // chatPrivateKeyEncrypted should be a JSON string with encrypted data diff --git a/src/app/api/chat/unread/route.ts b/src/app/api/chat/unread/route.ts new file mode 100644 index 0000000..844755b --- /dev/null +++ b/src/app/api/chat/unread/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { db, chatConversations, chatMessages } from '@/db'; +import { eq, and, isNull, inArray } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get user's conversations + const conversations = await db.query.chatConversations.findMany({ + where: eq(chatConversations.participant1Id, session.user.id), + }); + + if (conversations.length === 0) { + return NextResponse.json({ unreadCount: 0 }); + } + + // Count unread messages across all conversations + const conversationIds = conversations.map(c => c.id); + + const unreadMessages = await db.query.chatMessages.findMany({ + where: and( + inArray(chatMessages.conversationId, conversationIds), + isNull(chatMessages.readAt) + ), + }); + + // Filter out messages sent by the current user + const unreadCount = unreadMessages.filter( + msg => msg.senderHandle !== session.user.handle + ).length; + + return NextResponse.json({ unreadCount }); + } catch (error) { + console.error('Get unread chat count error:', error); + return NextResponse.json({ error: 'Failed to get unread count' }, { status: 500 }); + } +} diff --git a/src/app/api/debug/all-chat-keys/route.ts b/src/app/api/debug/all-chat-keys/route.ts new file mode 100644 index 0000000..6ef8dac --- /dev/null +++ b/src/app/api/debug/all-chat-keys/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { db, users } from '@/db'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const allUsers = await db.select({ + id: users.id, + handle: users.handle, + chatPublicKey: users.chatPublicKey, + chatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted, + }).from(users); + + const results = allUsers.map(user => { + let publicKeyInfo = null; + if (user.chatPublicKey) { + try { + const decoded = Buffer.from(user.chatPublicKey, 'base64'); + publicKeyInfo = { + stringLength: user.chatPublicKey.length, + decodedBytes: decoded.byteLength, + isValidSize: decoded.byteLength === 91, + isCorrupted: decoded.byteLength !== 91, + }; + } catch (e) { + publicKeyInfo = { + error: 'Not valid base64', + stringLength: user.chatPublicKey.length, + isCorrupted: true, + }; + } + } + + return { + handle: user.handle, + hasPublicKey: !!user.chatPublicKey, + publicKeyInfo, + encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0, + }; + }); + + const corrupted = results.filter(r => r.publicKeyInfo?.isCorrupted); + + return NextResponse.json({ + total: results.length, + withKeys: results.filter(r => r.hasPublicKey).length, + corrupted: corrupted.length, + corruptedUsers: corrupted.map(r => r.handle), + allUsers: results, + }); + } catch (error) { + console.error('Debug error:', error); + return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 }); + } +} diff --git a/src/app/api/debug/chat-keys/route.ts b/src/app/api/debug/chat-keys/route.ts new file mode 100644 index 0000000..b18f353 --- /dev/null +++ b/src/app/api/debug/chat-keys/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { db, users } from '@/db'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await db.query.users.findFirst({ + where: (users, { eq }) => eq(users.id, session.user.id), + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + let publicKeyInfo = null; + if (user.chatPublicKey) { + try { + const decoded = Buffer.from(user.chatPublicKey, 'base64'); + publicKeyInfo = { + stringLength: user.chatPublicKey.length, + decodedBytes: decoded.byteLength, + isValidSize: decoded.byteLength === 91, + firstChars: user.chatPublicKey.substring(0, 20), + }; + } catch (e) { + publicKeyInfo = { + error: 'Not valid base64', + stringLength: user.chatPublicKey.length, + firstChars: user.chatPublicKey.substring(0, 20), + }; + } + } + + return NextResponse.json({ + handle: user.handle, + hasPublicKey: !!user.chatPublicKey, + hasEncryptedPrivateKey: !!user.chatPrivateKeyEncrypted, + publicKeyInfo, + encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0, + }); + } catch (error) { + console.error('Debug error:', error); + return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 }); + } +} diff --git a/src/app/api/debug/chat-messages/route.ts b/src/app/api/debug/chat-messages/route.ts new file mode 100644 index 0000000..0535b00 --- /dev/null +++ b/src/app/api/debug/chat-messages/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { db, chatMessages } from '@/db'; +import { getSession } from '@/lib/auth'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const messages = await db.select({ + id: chatMessages.id, + senderHandle: chatMessages.senderHandle, + senderChatPublicKey: chatMessages.senderChatPublicKey, + createdAt: chatMessages.createdAt, + }).from(chatMessages).limit(50); + + const results = messages.map(msg => { + let keyInfo = null; + if (msg.senderChatPublicKey) { + try { + const decoded = Buffer.from(msg.senderChatPublicKey, 'base64'); + keyInfo = { + stringLength: msg.senderChatPublicKey.length, + decodedBytes: decoded.byteLength, + isValidSize: decoded.byteLength === 91, + isCorrupted: decoded.byteLength !== 91, + firstChars: msg.senderChatPublicKey.substring(0, 20), + }; + } catch (e) { + keyInfo = { + error: 'Not valid base64', + stringLength: msg.senderChatPublicKey.length, + isCorrupted: true, + firstChars: msg.senderChatPublicKey.substring(0, 20), + }; + } + } + + return { + id: msg.id, + senderHandle: msg.senderHandle, + createdAt: msg.createdAt, + hasSenderKey: !!msg.senderChatPublicKey, + keyInfo, + }; + }); + + const corrupted = results.filter(r => r.keyInfo?.isCorrupted); + + return NextResponse.json({ + total: results.length, + withKeys: results.filter(r => r.hasSenderKey).length, + corrupted: corrupted.length, + corruptedMessages: corrupted, + allMessages: results, + }); + } catch (error) { + console.error('Debug error:', error); + return NextResponse.json({ error: 'Failed to debug messages' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts index 8544bda..47f401f 100644 --- a/src/app/api/swarm/chat/conversations/route.ts +++ b/src/app/api/swarm/chat/conversations/route.ts @@ -67,7 +67,7 @@ export async function GET(request: NextRequest) { handle: cachedUser.handle, displayName: cachedUser.displayName || cachedUser.handle, avatarUrl: cachedUser.avatarUrl, - chatPublicKey: cachedUser.publicKey, // This is the chat public key + chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat }; } diff --git a/src/app/api/swarm/chat/send/route.ts b/src/app/api/swarm/chat/send/route.ts index 9e4a4a6..b453fe4 100644 --- a/src/app/api/swarm/chat/send/route.ts +++ b/src/app/api/swarm/chat/send/route.ts @@ -197,7 +197,7 @@ export async function POST(request: NextRequest) { participant1Id: sender.id, participant2Handle: recipientHandle, lastMessageAt: new Date(), - lastMessagePreview: '🔒 Encrypted message', + lastMessagePreview: 'New message', }).returning(); conversation = newConversation; } @@ -227,11 +227,68 @@ export async function POST(request: NextRequest) { await db.update(chatConversations) .set({ lastMessageAt: new Date(), - lastMessagePreview: '🔒 Encrypted message', + lastMessagePreview: 'New message', updatedAt: new Date(), }) .where(eq(chatConversations.id, conversation.id)); + // For LOCAL recipients, create/update their conversation too + if (!isRemote && recipientUser) { + try { + console.log('[Chat Send] Creating reciprocal conversation for local recipient:', recipientUser.handle); + + // Check if recipient has a conversation with sender + let recipientConversation = await db.query.chatConversations.findFirst({ + where: and( + eq(chatConversations.participant1Id, recipientUser.id), + eq(chatConversations.participant2Handle, sender.handle) + ), + }); + + if (!recipientConversation) { + // Create conversation for recipient + const [newRecipientConv] = await db.insert(chatConversations).values({ + participant1Id: recipientUser.id, + participant2Handle: sender.handle, + lastMessageAt: new Date(), + lastMessagePreview: 'New message', + }).returning(); + recipientConversation = newRecipientConv; + console.log('[Chat Send] Created new conversation for recipient'); + } else { + // Update existing conversation + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: 'New message', + updatedAt: new Date(), + }) + .where(eq(chatConversations.id, recipientConversation.id)); + console.log('[Chat Send] Updated existing conversation for recipient'); + } + + // Insert message into recipient's conversation + await db.insert(chatMessages).values({ + conversationId: recipientConversation.id, + senderHandle: sender.handle, + senderDisplayName: sender.displayName, + senderAvatarUrl: sender.avatarUrl, + senderNodeDomain: null, + encryptedContent, + senderEncryptedContent, + senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, + swarmMessageId: `${swarmMessageId}-recipient`, // Make it unique for recipient's copy + deliveredAt: new Date(), + readAt: null, + }); + + console.log('[Chat Send] Reciprocal message created for local recipient'); + } catch (recipError) { + console.error('[Chat Send] Failed to create reciprocal conversation:', recipError); + // Don't fail the whole request - sender's message was still saved + } + } + // If remote, send to their node if (isRemote && recipientNodeDomain) { // ... (remote logic remains similar but add logs) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 0000000..bc9bc66 --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,694 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; +import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus } from 'lucide-react'; +import { formatFullHandle } from '@/lib/utils/handle'; +import { useRouter } from 'next/navigation'; + +interface Conversation { + id: string; + participant2: { + handle: string; + displayName: string; + avatarUrl: string | null; + chatPublicKey: string | null; + }; + lastMessageAt: string; + lastMessagePreview: string; + unreadCount: number; +} + +interface Message { + id: string; + senderHandle: string; + senderDisplayName?: string; + senderAvatarUrl?: string; + senderPublicKey?: string; + encryptedContent: string; + decryptedContent?: string; + isSentByMe: boolean; + deliveredAt?: string; + readAt?: string; + createdAt: string; +} + +export default function ChatPage() { + const { user } = useAuth(); + const router = useRouter(); + const { keys, isReady, hasKeys, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption(); + + // Chat Data State + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [newChatHandle, setNewChatHandle] = useState(''); + const [showNewChat, setShowNewChat] = useState(false); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [recipientPublicKey, setRecipientPublicKey] = useState(null); + + // Password/Key State + const [showPasswordInput, setShowPasswordInput] = useState(false); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [isProcessingPassword, setIsProcessingPassword] = useState(false); + + // Encryption loading state to prevent flash + const [encryptionChecked, setEncryptionChecked] = useState(false); + + const messagesEndRef = useRef(null); + + // Wait for encryption to be ready before showing UI + useEffect(() => { + if (isReady) { + setEncryptionChecked(true); + } + }, [isReady]); + + // Redirect if not logged in + useEffect(() => { + if (!user) { + router.push('/login'); + } + }, [user, router]); + + // Load conversations + useEffect(() => { + if (user && hasKeys) { + loadConversations(true); // Initial load with spinner + + // Poll for new conversations every 5 seconds (no spinner) + const pollInterval = setInterval(() => { + loadConversations(false); + }, 5000); + + return () => clearInterval(pollInterval); + } + }, [user, hasKeys]); + + // Load messages when conversation is selected + useEffect(() => { + if (selectedConversation && hasKeys) { + loadMessages(selectedConversation.id); + markAsRead(selectedConversation.id); + fetchRecipientKey(selectedConversation.participant2.handle); + + // Poll for new messages every 3 seconds + const pollInterval = setInterval(() => { + loadMessages(selectedConversation.id); + }, 3000); + + return () => clearInterval(pollInterval); + } + }, [selectedConversation, hasKeys]); + + // Auto-scroll to bottom of messages + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages]); + + const fetchRecipientKey = async (handle: string) => { + try { + const res = await fetch(`/api/users/${encodeURIComponent(handle)}`); + const data = await res.json(); + setRecipientPublicKey(data.user?.chatPublicKey || null); + } catch { + setRecipientPublicKey(null); + } + }; + + const loadConversations = async (isInitialLoad = false) => { + if (isInitialLoad) setLoading(true); + try { + const res = await fetch('/api/swarm/chat/conversations'); + const data = await res.json(); + setConversations(data.conversations || []); + } catch (e) { + console.error("Failed to load conversations", e); + } finally { + if (isInitialLoad) setLoading(false); + } + }; + + const loadMessages = async (conversationId: string) => { + try { + let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey; + + if (!chatPartnerKey && selectedConversation?.participant2?.handle) { + try { + const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); + const userData = await userRes.json(); + console.log('[Chat] Fetched user data:', { + handle: selectedConversation.participant2.handle, + hasChatPublicKey: !!userData.user?.chatPublicKey, + hasPublicKey: !!userData.user?.publicKey, + chatPublicKeyLength: userData.user?.chatPublicKey?.length, + publicKeyLength: userData.user?.publicKey?.length, + chatPublicKeyStart: userData.user?.chatPublicKey?.substring(0, 20), + publicKeyStart: userData.user?.publicKey?.substring(0, 20), + }); + chatPartnerKey = userData.user?.chatPublicKey || null; + if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey); + } catch (e) { console.error(e); } + } + + const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); + const data = await res.json(); + + const decrypted = await Promise.all((data.messages || []).map(async (msg: Message & { isE2E?: boolean }) => { + try { + const isE2E = !!msg.senderPublicKey; + if (!isE2E) return { ...msg, decryptedContent: '[Legacy message - incompatible encryption]' }; + + const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey; + if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing encryption key]' }; + + console.log('[Chat] Decrypting message:', { + messageId: msg.id, + isSentByMe: msg.isSentByMe, + keyLength: otherPartyKey?.length, + keySource: msg.isSentByMe ? 'chatPartnerKey' : 'msg.senderPublicKey', + firstChars: otherPartyKey?.substring(0, 20) + }); + + if (msg.encryptedContent) { + const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey); + return { ...msg, decryptedContent: decrypted }; + } + } catch (err) { + console.warn('[Chat] Failed to decrypt message:', msg.id, err); + } + return { ...msg, decryptedContent: '[Unable to decrypt - incompatible format]' }; + })); + + setMessages(decrypted); + } catch (err) { console.error(err); } + }; + + const markAsRead = async (conversationId: string) => { + try { + await fetch('/api/swarm/chat/messages', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId }) + }); + setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); + } catch { } + }; + + const sendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return; + setSending(true); + try { + console.log('[Send] Starting encryption...', { + messageLength: newMessage.length, + recipientHandle: selectedConversation.participant2.handle, + hasRecipientKey: !!recipientPublicKey, + recipientKeyLength: recipientPublicKey?.length + }); + + const encrypted = await encryptMessage(newMessage, recipientPublicKey); + console.log('[Send] Message encrypted, sending to server...'); + + const res = await fetch('/api/swarm/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientHandle: selectedConversation.participant2.handle, + encryptedContent: encrypted, + senderPublicKey: keys?.publicKey + }) + }); + + console.log('[Send] Server response:', res.status, res.statusText); + + if (!res.ok) { + const errorData = await res.json(); + console.error('[Send] Server error:', errorData); + alert(`Failed to send: ${errorData.error || 'Unknown error'}`); + return; + } + + const result = await res.json(); + console.log('[Send] Success:', result); + + setNewMessage(''); + await loadMessages(selectedConversation.id); + loadConversations(false); + } catch (err) { + console.error('[Send] Error:', err); + alert(`Failed to send message: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setSending(false); + } + }; + + const startNewChat = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newChatHandle.trim()) return; + setSending(true); + try { + const cleanHandle = newChatHandle.replace(/^@/, ''); + const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); + const data = await res.json(); + if (!data.user?.chatPublicKey) { + alert('This user has not enabled encrypted chat yet.'); + return; + } + const encrypted = await encryptMessage('Hey! 👋', data.user.chatPublicKey); + const sendRes = await fetch('/api/swarm/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientHandle: cleanHandle, + encryptedContent: encrypted, + senderPublicKey: keys?.publicKey + }) + }); + if (sendRes.ok) { + setShowNewChat(false); + setNewChatHandle(''); + loadConversations(false); + } + } catch { } + finally { setSending(false); } + }; + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password) return; + setPasswordError(''); + setIsProcessingPassword(true); + try { + if (needsPasswordToRestore) { + const success = await restoreKeysWithPassword(password); + if (!success) { setPasswordError('Incorrect password.'); return; } + } else { + await generateAndRegisterKeys(password); + } + setShowPasswordInput(false); + setPassword(''); + } catch (err) { + setPasswordError('Failed. Please try again.'); + } finally { + setIsProcessingPassword(false); + } + }; + + const filteredConversations = conversations.filter((conv) => + conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || + conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + if (!user) return null; + + // Show loading while checking encryption status + if (!encryptionChecked) { + return ( +
+ +
+ ); + } + + // Encryption Setup Screen + if (!hasKeys) { + return ( +
+
+
+ +
+ +

+ {needsPasswordToRestore ? 'Unlock Messages' : 'Secure Messaging'} +

+ +

+ {needsPasswordToRestore + ? 'Enter your password to decrypt your conversation history.' + : 'End-to-end encryption keeps your personal messages private.'} +

+ + {needsPasswordToRestore && ( +
+ Having issues? You can{' '} + + {' '}and start fresh. +
+ )} + + {!showPasswordInput ? ( + + ) : ( +
+ setPassword(e.target.value)} + autoFocus + /> + + {passwordError && ( +
+ {passwordError} +
+ )} + +
+ + +
+
+ )} +
+
+ ); + } + + // Thread View + if (selectedConversation) { + return ( + <> + {/* Header */} +
+
+ +
+ {selectedConversation.participant2.avatarUrl ? ( + + ) : ( + selectedConversation.participant2.displayName[0] + )} +
+
+
{selectedConversation.participant2.displayName}
+
+ {formatFullHandle(selectedConversation.participant2.handle)} +
+
+
+
+ + {/* Messages */} +
+
+ {messages.map(msg => ( +
+ {!msg.isSentByMe && ( +
+ {msg.senderAvatarUrl ? ( + + ) : ( + msg.senderDisplayName?.[0] + )} +
+ )} + +
+
+ {msg.decryptedContent || msg.encryptedContent} +
+
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+
+ ))} +
+
+
+ + {/* Input */} +
+
+ setNewMessage(e.target.value)} + /> + +
+
+ + ); + } + + // Conversations List + return ( + <> + {/* Header */} +
+
+

Messages

+ {!showNewChat && ( + + )} +
+ + {showNewChat ? ( +
+ setNewChatHandle(e.target.value)} + autoFocus + /> +
+ + +
+
+ ) : ( +
+ + setSearchQuery(e.target.value)} + /> +
+ )} +
+ + {/* Conversations */} + {loading ? ( +
+ +
+ ) : filteredConversations.length === 0 ? ( +
+ +

+ No conversations yet +

+

+ Start a conversation with someone on the network. +

+ +
+ ) : ( + filteredConversations.map(conv => ( +
setSelectedConversation(conv)} + className="post" + style={{ cursor: 'pointer' }} + > +
+
+ {conv.participant2.avatarUrl ? ( + + ) : ( + conv.participant2.displayName[0] + )} +
+
+
+ {conv.participant2.displayName} + {conv.unreadCount > 0 && ( + + {conv.unreadCount} + + )} +
+
+ {formatFullHandle(conv.participant2.handle)} +
+
0 ? 'var(--foreground)' : 'var(--foreground-secondary)', + fontWeight: conv.unreadCount > 0 ? 500 : 400, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }}> + {conv.lastMessagePreview} +
+
+
+
+ )) + )} + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ac63150..c37930d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -225,9 +225,21 @@ export default function LoginPage() { try { const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register'; + + // Only include turnstileToken if Turnstile is enabled (site key exists) const body = mode === 'login' - ? { email, password, turnstileToken } - : { email, password, handle, displayName, turnstileToken }; + ? { + email, + password, + ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) + } + : { + email, + password, + handle, + displayName, + ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) + }; const res = await fetch(endpoint, { method: 'POST', diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx deleted file mode 100644 index dfa0380..0000000 --- a/src/components/ChatWidget.tsx +++ /dev/null @@ -1,511 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { useAuth } from '@/lib/contexts/AuthContext'; -import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; -import { MessageCircle, Send, ArrowLeft, Search, Plus, Lock, Shield, Key, X, ChevronDown, CheckCheck, Loader2, Mail } from 'lucide-react'; -import { formatFullHandle } from '@/lib/utils/handle'; -import Link from 'next/link'; - -interface Conversation { - id: string; - participant2: { - handle: string; - displayName: string; - avatarUrl: string | null; - chatPublicKey: string | null; - }; - lastMessageAt: string; - lastMessagePreview: string; - unreadCount: number; -} - -interface Message { - id: string; - senderHandle: string; - senderDisplayName?: string; - senderAvatarUrl?: string; - senderPublicKey?: string; - encryptedContent: string; - decryptedContent?: string; - isSentByMe: boolean; - deliveredAt?: string; - readAt?: string; - createdAt: string; -} - -export function ChatWidget() { - const { user } = useAuth(); - const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption(); - - // Widget State - const [isOpen, setIsOpen] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); // For minimize/maximize behavior when open - - // Chat Data State - const [conversations, setConversations] = useState([]); - const [selectedConversation, setSelectedConversation] = useState(null); - const [messages, setMessages] = useState([]); - const [newMessage, setNewMessage] = useState(''); - const [newChatHandle, setNewChatHandle] = useState(''); - const [showNewChat, setShowNewChat] = useState(false); - const [loading, setLoading] = useState(true); - const [sending, setSending] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [recipientPublicKey, setRecipientPublicKey] = useState(null); - - // Password/Key State - const [showPasswordInput, setShowPasswordInput] = useState(false); - const [password, setPassword] = useState(''); - const [passwordError, setPasswordError] = useState(''); - const [isProcessingPassword, setIsProcessingPassword] = useState(false); - - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - - // Listen for global open event - useEffect(() => { - const handleOpenEvent = () => { - setIsOpen(true); - setIsExpanded(true); - }; - window.addEventListener('open-chat-widget', handleOpenEvent); - return () => window.removeEventListener('open-chat-widget', handleOpenEvent); - }, []); - - // Load conversations when widget opens or auth changes - useEffect(() => { - if (user && hasKeys && isOpen) { - loadConversations(); - } - }, [user, hasKeys, isOpen]); - - // Load messages when conversation is selected - useEffect(() => { - if (selectedConversation && hasKeys) { - loadMessages(selectedConversation.id); - markAsRead(selectedConversation.id); - fetchRecipientKey(selectedConversation.participant2.handle); - } - }, [selectedConversation, hasKeys]); - - // Auto-scroll to bottom of messages - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [messages, isOpen, isExpanded]); - - // -- Data Fetching Utils (Ported from page.tsx) -- - const fetchRecipientKey = async (handle: string) => { - try { - const res = await fetch(`/api/users/${encodeURIComponent(handle)}`); - const data = await res.json(); - setRecipientPublicKey(data.user?.chatPublicKey || null); - } catch { - setRecipientPublicKey(null); - } - }; - - const loadConversations = async () => { - setLoading(true); - try { - const res = await fetch('/api/swarm/chat/conversations'); - const data = await res.json(); - setConversations(data.conversations || []); - } catch (e) { - console.error("Failed to load conversations", e); - } finally { - setLoading(false); - } - }; - - const loadMessages = async (conversationId: string) => { - try { - // Ensure we have the recipient's key logic (simplified for brevity, main logic preserved) - let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey; - - if (!chatPartnerKey && selectedConversation?.participant2?.handle) { - try { - const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); - const userData = await userRes.json(); - chatPartnerKey = userData.user?.chatPublicKey || null; - if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey); - } catch (e) { console.error(e); } - } - - const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); - const data = await res.json(); - - const decrypted = await Promise.all((data.messages || []).map(async (msg: Message & { isE2E?: boolean }) => { - try { - const isE2E = !!msg.senderPublicKey; - if (!isE2E) return { ...msg, decryptedContent: '[Legacy encrypted message]' }; - - const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey; - if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing decryption key]' }; - - if (msg.encryptedContent) { - const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey); - return { ...msg, decryptedContent: decrypted }; - } - } catch (err) { } - return { ...msg, decryptedContent: '[Unable to decrypt]' }; - })); - - setMessages(decrypted); - } catch (err) { console.error(err); } - }; - - const markAsRead = async (conversationId: string) => { try { await fetch('/api/swarm/chat/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }) }); setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); } catch { } }; - - const sendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return; - setSending(true); - try { - const encrypted = await encryptMessage(newMessage, recipientPublicKey); - const res = await fetch('/api/swarm/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientHandle: selectedConversation.participant2.handle, - encryptedContent: encrypted, - senderPublicKey: keys?.publicKey - }) - }); - - if (res.ok) { - setNewMessage(''); - await loadMessages(selectedConversation.id); - loadConversations(); - } - } catch (err) { console.error(err); } - finally { setSending(false); } - }; - - const startNewChat = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newChatHandle.trim()) return; - setSending(true); - try { - const cleanHandle = newChatHandle.replace(/^@/, ''); - const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); - const data = await res.json(); - if (!data.user?.chatPublicKey) { - alert('This user has not enabled encrypted chat yet.'); - return; - } - const encrypted = await encryptMessage('Hey! 👋', data.user.chatPublicKey); - const sendRes = await fetch('/api/swarm/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientHandle: cleanHandle, - encryptedContent: encrypted, - senderPublicKey: keys?.publicKey - }) - }); - if (sendRes.ok) { - setShowNewChat(false); - setNewChatHandle(''); - loadConversations(); - } - } catch { } - finally { setSending(false); } - }; - - const handlePasswordSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!password) return; - setPasswordError(''); - setIsProcessingPassword(true); - try { - if (needsPasswordToRestore) { - const success = await restoreKeysWithPassword(password); - if (!success) { setPasswordError('Incorrect password.'); return; } - } else { - await generateAndRegisterKeys(password); - } - setShowPasswordInput(false); - setPassword(''); - } catch (err) { - setPasswordError('Failed. Please try again.'); - } finally { - setIsProcessingPassword(false); - } - }; - - const filteredConversations: Conversation[] = conversations.filter((conv: Conversation) => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())); - - // -- Render Logic -- - - // If not signed in, show nothing or maybe a prompt? User said "widget", usually hidden if not meaningful. - if (!user) return null; - - // Collapsed State (Just the Fab/Button) - if (!isOpen) { - return ( -
- -
- ); - } - - // Expanded Widget - return ( -
- - {/* Header */} -
setIsExpanded(!isExpanded)} - > -
- {selectedConversation && isExpanded ? ( - - ) : null} -

Messages

-
-
- {!selectedConversation && isExpanded && ( - - )} - - -
-
- - {/* Content (Only visible if expanded) */} - {isExpanded && ( -
- - {/* Encryption Setup Overlay */} - {!hasKeys && ( -
- -

{needsPasswordToRestore ? 'Unlock Messages' : 'Enable Encryption'}

-

{needsPasswordToRestore ? 'Enter your password to restore chat.' : 'Set up secure messaging.'}

- - {!showPasswordInput ? ( - - ) : ( -
- setPassword(e.target.value)} - autoFocus - /> - {passwordError &&

{passwordError}

} - -
- )} -
- )} - - {/* Main View Switching */} - {hasKeys && ( - <> - {selectedConversation ? ( - // Thread View -
- {/* Thread Header */} -
-
- {selectedConversation.participant2.avatarUrl ? ( - - ) : ( - {selectedConversation.participant2.displayName[0]} - )} -
-
-

{selectedConversation.participant2.displayName}

-

{formatFullHandle(selectedConversation.participant2.handle)}

-
-
- - {/* Messages List */} -
-
-
- End-to-end encrypted -
-
- - {messages.map(msg => ( -
- {/* Avatar for received messages */} - {!msg.isSentByMe && ( -
- {msg.senderAvatarUrl ? : {msg.senderDisplayName?.[0]}} -
- )} - -
-
- {msg.decryptedContent || msg.encryptedContent} -
-
- {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {msg.isSentByMe && ( - - {msg.readAt ? : msg.deliveredAt ? : null} - - )} -
-
-
- ))} -
-
- - {/* Input Area */} -
- setNewMessage(e.target.value)} - /> - -
-
- ) : ( - // Conversation List View -
- {showNewChat ? ( -
-
- setNewChatHandle(e.target.value)} - autoFocus - /> -
- - -
-
-
- ) : ( - <> - {/* Search Bar */} -
-
- - setSearchQuery(e.target.value)} - /> -
-
- - {/* List */} -
- {loading ? ( -
- ) : filteredConversations.length === 0 ? ( -
-

Welcome to your inbox!

-

Drop a potential swarm of thoughts to people.

- -
- ) : ( - filteredConversations.map((conv: any) => ( -
setSelectedConversation(conv)} - className={`flex items-center gap-3 p-3 hover:bg-[#161616] cursor-pointer transition-colors ${selectedConversation?.id === conv.id ? 'bg-[#161616] border-r-2 border-blue-500' : ''}`} - > -
-
- {conv.participant2.avatarUrl ? ( - - ) : ( - {conv.participant2.displayName[0]} - )} -
-
-
-
- {conv.participant2.displayName} - {formatFullHandle(conv.participant2.handle)} -
-
- 0 ? 'text-white font-medium' : 'text-gray-500'}`}> - {conv.lastMessagePreview} - - {conv.unreadCount > 0 && ( - - {conv.unreadCount} - - )} -
-
-
- )) - )} -
- - )} -
- )} - - )} - -
- )} -
- ); -} diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index abb7037..c737444 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -4,7 +4,6 @@ import { usePathname } from 'next/navigation'; import { Sidebar } from './Sidebar'; import { RightSidebar } from './RightSidebar'; import { useAuth } from '@/lib/contexts/AuthContext'; -import { ChatWidget } from './ChatWidget'; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const { loading } = useAuth(); @@ -17,7 +16,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { pathname?.startsWith('/install'); // Hide right sidebar on chat page for more space - const hideRightSidebar = pathname?.startsWith('/chat'); + const hideRightSidebar = false; if (loading) { return ( @@ -57,7 +56,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { {children} {!hideRightSidebar && } -
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ad0572c..81c1890 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ export function Sidebar() { const router = useRouter(); const [customLogoUrl, setCustomLogoUrl] = useState(undefined); const [unreadCount, setUnreadCount] = useState(0); + const [unreadChatCount, setUnreadChatCount] = useState(0); const [loggingOut, setLoggingOut] = useState(false); useEffect(() => { @@ -47,6 +48,25 @@ export function Sidebar() { return () => clearInterval(interval); }, [user]); + // Fetch unread chat count + useEffect(() => { + if (!user) return; + + const fetchUnreadChats = () => { + fetch('/api/chat/unread') + .then(res => res.json()) + .then(data => { + setUnreadChatCount(data.unreadCount || 0); + }) + .catch(() => { }); + }; + + fetchUnreadChats(); + // Poll every 10 seconds + const interval = setInterval(fetchUnreadChats, 10000); + return () => clearInterval(interval); + }, [user]); + // Home is exact match const isHome = pathname === '/'; @@ -84,33 +104,38 @@ export function Sidebar() { Explore {user && ( - + - Notifications - {unreadCount > 0 && ( - - )} + + Notifications + {unreadCount > 0 && ( + + )} + )} {user && ( - + + Chat + {unreadChatCount > 0 && ( + + )} + + )} {user && ( diff --git a/src/lib/hooks/useChatEncryption.ts b/src/lib/hooks/useChatEncryption.ts index 433c2a1..ba3cab4 100644 --- a/src/lib/hooks/useChatEncryption.ts +++ b/src/lib/hooks/useChatEncryption.ts @@ -30,6 +30,10 @@ export function useChatEncryption() { // Check for existing keys on mount useEffect(() => { + // Only run in browser + if (typeof window === 'undefined') { + return; + } checkKeys(); }, []); @@ -91,6 +95,9 @@ export function useChatEncryption() { // Generate new keys and register with server (encrypted backup) const generateAndRegisterKeys = useCallback(async (password: string) => { + if (typeof window === 'undefined') { + throw new Error('Key generation can only be performed in the browser'); + } setIsRegistering(true); try { // Generate ECDH key pair using Web Crypto API @@ -106,9 +113,18 @@ export function useChatEncryption() { const publicKey = bufferToBase64(publicKeyBuffer); const privateKey = bufferToBase64(privateKeyBuffer); + console.log('[GenerateKeys] Generated keys:', { + publicKeyLength: publicKey.length, + privateKeyLength: privateKey.length, + publicKeyBytes: publicKeyBuffer.byteLength, + privateKeyBytes: privateKeyBuffer.byteLength + }); + // Encrypt private key with password for server backup const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password); + console.log('[GenerateKeys] Encrypted private key length:', encryptedPrivateKey.length); + // Register public key + encrypted private key backup with server FIRST const response = await fetch('/api/chat/keys', { method: 'POST', @@ -147,6 +163,9 @@ export function useChatEncryption() { message: string, recipientPublicKey: string ): Promise => { + if (typeof window === 'undefined') { + throw new Error('Encryption can only be performed in the browser'); + } if (!keys?.privateKey) { throw new Error('No chat keys available'); } @@ -178,17 +197,21 @@ export function useChatEncryption() { encryptedMessage: string, senderPublicKey: string ): Promise => { + // Early browser check before any operations + if (typeof window === 'undefined') { + return '[Decryption only available in browser]'; + } + try { if (!keys?.privateKey) { console.error('[Decrypt] No private key available'); - throw new Error('No chat keys available'); + return '[No decryption key available]'; + } + + if (!senderPublicKey) { + console.error('[Decrypt] No sender public key provided'); + return '[Sender key missing]'; } - - console.log('[Decrypt] Starting decryption', { - hasPrivateKey: !!keys.privateKey, - hasSenderPublicKey: !!senderPublicKey, - encryptedLength: encryptedMessage.length - }); const myPrivateKey = await importPrivateKey(keys.privateKey); const theirPublicKey = await importPublicKey(senderPublicKey); @@ -203,11 +226,6 @@ export function useChatEncryption() { const iv = combined.slice(0, 12); const ciphertext = combined.slice(12); - console.log('[Decrypt] Decrypting with shared key', { - ivLength: iv.byteLength, - ciphertextLength: ciphertext.byteLength - }); - const decrypted = await window.crypto.subtle.decrypt( { name: 'AES-GCM', iv }, sharedKey, @@ -215,13 +233,22 @@ export function useChatEncryption() { ); const decoder = new TextDecoder(); - const result = decoder.decode(decrypted); - console.log('[Decrypt] Success:', result.substring(0, 50)); - return result; + return decoder.decode(decrypted); } catch (error) { - console.error('[Decrypt] Failed:', error); - // Return a safe placeholder that won't crash the UI - return '[Message cannot be decrypted]'; + console.warn('[Decrypt] Failed:', error instanceof Error ? error.message : error); + // Return a descriptive placeholder based on the error + if (error instanceof Error) { + if (error.message.includes('public key') || error.message.includes('import key')) { + return '[Incompatible encryption format]'; + } + if (error.message.includes('private key')) { + return '[Invalid private key]'; + } + if (error.message.includes('base64') || error.message.includes('decode')) { + return '[Corrupted message data]'; + } + } + return '[Cannot decrypt message]'; } }, [keys]); @@ -251,6 +278,10 @@ export function useChatEncryption() { // ============================================ async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise { + if (typeof window === 'undefined') { + throw new Error('Encryption can only be performed in the browser'); + } + const encoder = new TextEncoder(); const salt = window.crypto.getRandomValues(new Uint8Array(16)); const iv = window.crypto.getRandomValues(new Uint8Array(12)); @@ -293,6 +324,10 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin } async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise { + if (typeof window === 'undefined') { + throw new Error('Decryption can only be performed in the browser'); + } + const { salt, iv, ciphertext } = JSON.parse(encryptedData); const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -334,17 +369,58 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st // ============================================ async function importPublicKey(publicKeyBase64: string): Promise { - const keyBuffer = base64ToBuffer(publicKeyBase64); - return window.crypto.subtle.importKey( - 'spki', - keyBuffer, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [] - ); + if (typeof window === 'undefined') { + throw new Error('Crypto operations can only be performed in the browser'); + } + + // Validate the key format + if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') { + throw new Error('Invalid public key: must be a non-empty string'); + } + + try { + const keyBuffer = base64ToBuffer(publicKeyBase64); + + // Try SPKI format first (standard format, typically ~91 bytes for P-256) + try { + return await window.crypto.subtle.importKey( + 'spki', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } catch (spkiError) { + // Try raw format (65 bytes for uncompressed P-256 public key) + // Raw format is: 0x04 + X coordinate (32 bytes) + Y coordinate (32 bytes) + if (keyBuffer.byteLength === 65) { + try { + return await window.crypto.subtle.importKey( + 'raw', + keyBuffer, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } catch (rawError) { + // Both formats failed + } + } + + // If neither worked, throw a descriptive error + throw new Error(`Cannot import key (${keyBuffer.byteLength} bytes): incompatible format`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.warn('[ImportPublicKey] Failed:', errorMsg); + throw new Error(`Failed to import public key: ${errorMsg}`); + } } async function importPrivateKey(privateKeyBase64: string): Promise { + if (typeof window === 'undefined') { + throw new Error('Crypto operations can only be performed in the browser'); + } const keyBuffer = base64ToBuffer(privateKeyBase64); return window.crypto.subtle.importKey( 'pkcs8', @@ -359,6 +435,10 @@ async function deriveSharedKey( myPrivateKey: CryptoKey, theirPublicKey: CryptoKey ): Promise { + if (typeof window === 'undefined') { + throw new Error('Key derivation can only be performed in the browser'); + } + return window.crypto.subtle.deriveKey( { name: 'ECDH', public: theirPublicKey }, myPrivateKey, @@ -373,6 +453,11 @@ async function deriveSharedKey( // ============================================ function bufferToBase64(buffer: ArrayBuffer): string { + // btoa is available in both browser and Node 16+, but let's be safe + if (typeof btoa === 'undefined') { + throw new Error('Base64 encoding not available in this environment'); + } + const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { @@ -382,7 +467,7 @@ function bufferToBase64(buffer: ArrayBuffer): string { } function base64ToBuffer(base64: string): ArrayBuffer { - // Gracefull handle null/undefined + // Gracefully handle null/undefined if (!base64) return new ArrayBuffer(0); // Check for JSON (legacy format) @@ -401,6 +486,11 @@ function base64ToBuffer(base64: string): ArrayBuffer { .replace(/_/g, '/'); try { + // atob is available in both browser and Node 16+, but let's be safe + if (typeof atob === 'undefined') { + throw new Error('Base64 decoding not available in this environment'); + } + const binary = atob(cleaned); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { @@ -409,8 +499,6 @@ function base64ToBuffer(base64: string): ArrayBuffer { return bytes.buffer; } catch (e) { console.error('[base64ToBuffer] Failed to decode base64:', e); - // Return empty buffer or throw specific error? - // Throwing allows decryptMessage to catch and return placeholder throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`); } }