From 15160b9e8623e8f178abfd0e5f1d3211c51e62a4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:46:08 -0400 Subject: [PATCH] feat: image/file sharing in chat with drive grouping by contact - Attach button in chat sends encrypted files via conversation key - Images render inline; other files show a download button - Shared files stored on server with conversation_id for access control - Drive groups files by conversation partner name (sender view) Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/files.ts | 11 +- frontend/src/pages/DrivePage.tsx | 115 ++++++++++---- frontend/src/pages/MessagesPage.tsx | 238 +++++++++++++++++++++------- 3 files changed, 273 insertions(+), 91 deletions(-) diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 0e8a012..77a48b1 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -57,8 +57,15 @@ router.post('/upload', upload.single('file'), async (req: AuthRequest, res) => { router.get('/files', async (req: AuthRequest, res) => { const result = await db.query( - `SELECT id, encrypted_name, mime_type, size_bytes, encrypted_file_key, uploaded_at - FROM files WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY uploaded_at DESC`, + `SELECT f.id, f.encrypted_name, f.mime_type, f.size_bytes, f.encrypted_file_key, + f.uploaded_at, f.conversation_id, + u.username AS shared_with_username + FROM files f + LEFT JOIN conversation_members cm + ON cm.conversation_id = f.conversation_id AND cm.user_id != $1 + LEFT JOIN users u ON u.id = cm.user_id + WHERE f.owner_id = $1 AND f.deleted_at IS NULL + ORDER BY f.uploaded_at DESC`, [req.userId] ); res.json(result.rows); diff --git a/frontend/src/pages/DrivePage.tsx b/frontend/src/pages/DrivePage.tsx index cd9173e..516ec2c 100644 --- a/frontend/src/pages/DrivePage.tsx +++ b/frontend/src/pages/DrivePage.tsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect, useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Upload, File, Trash2, Download, Lock } from 'lucide-react'; +import { Upload, File, Trash2, Download, Lock, MessageSquare } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import api from '../lib/api'; import { useAuthStore } from '../lib/store'; @@ -21,6 +21,8 @@ interface DriveFile { size_bytes: number; encrypted_file_key: string; uploaded_at: string; + conversation_id: string | null; + shared_with_username: string | null; } function formatBytes(bytes: number) { @@ -157,41 +159,86 @@ export default function DrivePage() {

No files yet — drag & drop or click Upload

) : ( -
- {files.map((f) => ( -
- -
-

- {decryptedNames[f.id] ?? '…'} -

-

- {formatBytes(f.size_bytes)} ·{' '} - {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })} -

-
- - -
- ))} -
+ )} ); } + +function FileRow({ f, name, onDownload, onDelete }: { + f: DriveFile; name: string; + onDownload: (f: DriveFile) => void; onDelete: (id: string) => void; +}) { + return ( +
+ +
+

{name}

+

+ {formatBytes(f.size_bytes)} · {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })} +

+
+ + +
+ ); +} + +function FileGroups({ files, decryptedNames, onDownload, onDelete }: { + files: DriveFile[]; + decryptedNames: Record; + onDownload: (f: DriveFile) => void; + onDelete: (id: string) => void; +}) { + const personal = files.filter((f) => !f.conversation_id); + const byPerson = files + .filter((f) => f.conversation_id && f.shared_with_username) + .reduce>((acc, f) => { + const name = f.shared_with_username!; + if (!acc[name]) acc[name] = []; + acc[name].push(f); + return acc; + }, {}); + + return ( +
+ {Object.entries(byPerson).map(([person, personFiles]) => ( +
+
+ +

{person}

+
+
+ {personFiles.map((f) => ( + + ))} +
+
+ ))} + + {personal.length > 0 && ( +
+ {Object.keys(byPerson).length > 0 && ( +
+ +

Personal

+
+ )} +
+ {personal.map((f) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index d9381e0..e8ff69c 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Send, Plus, Search, ArrowLeft } from 'lucide-react'; +import { Send, Plus, Search, ArrowLeft, Paperclip, Download, ImageIcon } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import api from '../lib/api'; import { useAuthStore, useCryptoStore } from '../lib/store'; @@ -13,6 +13,12 @@ import { sealConvKey, openSealedConvKey, importConvKey, + encryptFile, + decryptFile, + generateFileKey, + encryptFileKey, + bufToB64, + b64ToBuf, } from '../crypto'; interface Conversation { @@ -28,15 +34,98 @@ interface Message { sender_username: string; ciphertext: string; sent_at: string; + file_id?: string; plaintext?: string; } +interface ParsedMessage { + type: 'text' | 'file'; + text?: string; + fileId?: string; + fileKeyB64?: string; + fileName?: string; + mimeType?: string; +} + interface UserResult { id: string; username: string; public_key: string; } +function parsePlaintext(plain: string): ParsedMessage { + try { + const obj = JSON.parse(plain); + if (obj.type === 'file' && obj.fileId) return obj as ParsedMessage; + } catch {} + return { type: 'text', text: plain }; +} + +function FileMessage({ msg, fileKeyB64, fileName, mimeType, isMine }: { + msg: Message; fileKeyB64: string; fileName: string; mimeType: string; isMine: boolean; +}) { + const [src, setSrc] = useState(null); + const [loading, setLoading] = useState(false); + const isImage = mimeType.startsWith('image/'); + + useEffect(() => { + if (!isImage || !msg.file_id) return; + let url: string; + setLoading(true); + api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' }) + .then(async (res) => { + const fileKey = await importConvKey(b64ToBuf(fileKeyB64)); + const decrypted = await decryptFile(res.data, fileKey); + url = URL.createObjectURL(new Blob([decrypted], { type: mimeType })); + setSrc(url); + }) + .catch(() => setSrc(null)) + .finally(() => setLoading(false)); + return () => { if (url) URL.revokeObjectURL(url); }; + }, [msg.file_id, fileKeyB64, mimeType, isImage]); + + async function download() { + if (!msg.file_id) return; + const res = await api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' }); + const fileKey = await importConvKey(b64ToBuf(fileKeyB64)); + const decrypted = await decryptFile(res.data, fileKey); + const url = URL.createObjectURL(new Blob([decrypted], { type: mimeType })); + const a = document.createElement('a'); + a.href = url; a.download = fileName; a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+ {isImage ? ( + loading ? ( +
+ +
+ ) : src ? ( + {fileName} + ) : ( +
+ Failed to load +
+ ) + ) : null} +
+ +
+

+ {msg.sent_at ? formatDistanceToNow(new Date(msg.sent_at), { addSuffix: true }) : 'just now'} +

+
+ ); +} + export default function MessagesPage() { const { id: activeId } = useParams(); const navigate = useNavigate(); @@ -47,24 +136,23 @@ export default function MessagesPage() { const [messages, setMessages] = useState([]); const [newUserSearch, setNewUserSearch] = useState(''); const [searchResults, setSearchResults] = useState([]); + const [uploading, setUploading] = useState(false); const bottomRef = useRef(null); + const fileInputRef = useRef(null); const { data: conversations = [] } = useQuery({ queryKey: ['conversations'], queryFn: () => api.get('/conversations').then((r) => r.data), }); - // Load + decrypt messages when active conversation changes useEffect(() => { if (!activeId || !publicKey || !privateKey) return; - async function load() { const conv = conversations.find((c) => c.id === activeId); if (!conv) return; let convKey = getConvKey(activeId!); if (!convKey) { - // Decrypt the conversation key that was sealed for us with our X25519 public key const raw = await openSealedConvKey(conv.encrypted_key, privateKey!); convKey = await importConvKey(raw); setConvKey(activeId!, convKey); @@ -79,16 +167,14 @@ export default function MessagesPage() { ); setMessages(decrypted); } - load(); }, [activeId, conversations.length]); - // WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically) useEffect(() => { return socket.on(async (raw) => { const msg = raw as any; if (msg.type !== 'message' || msg.conversationId !== activeId) return; - if (msg.senderId === userId) return; // Own message already added optimistically + if (msg.senderId === userId) return; const convKey = getConvKey(activeId!); if (!convKey) return; @@ -96,7 +182,13 @@ export default function MessagesPage() { const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]'); setMessages((prev) => [ ...prev, - { ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sentAt ?? new Date().toISOString() }, + { + ...msg, + sender_username: msg.senderUsername ?? msg.senderId, + plaintext, + file_id: msg.fileId ?? undefined, + sent_at: msg.sentAt ?? new Date().toISOString(), + }, ]); }); }, [activeId, userId]); @@ -111,11 +203,9 @@ export default function MessagesPage() { if (!convKey) return; const plaintext = text.trim(); - setText(''); // Clear immediately for responsiveness + setText(''); const ciphertext = await encryptMessage(plaintext, convKey); - - // Optimistic update — show in UI before server confirms setMessages((prev) => [...prev, { id: `pending-${Date.now()}`, sender_id: userId!, @@ -128,31 +218,64 @@ export default function MessagesPage() { socket.send({ type: 'message', conversationId: activeId, ciphertext }); } + const sendFile = useCallback(async (file: File) => { + if (!activeId) return; + const convKey = getConvKey(activeId); + if (!convKey) return; + setUploading(true); + + try { + const fileKey = await generateFileKey(); + const encryptedBlob = await encryptFile(file, fileKey); + const encryptedFileKey = await encryptFileKey(fileKey, useAuthStore.getState().masterKey!); + const encryptedName = await encryptMessage(file.name, fileKey); + + const rawFileKey = await crypto.subtle.exportKey('raw', fileKey); + const fileKeyB64 = bufToB64(rawFileKey); + + const form = new FormData(); + form.append('file', encryptedBlob, 'blob'); + form.append('encryptedName', encryptedName); + form.append('encryptedFileKey', encryptedFileKey); + form.append('mimeType', file.type || 'application/octet-stream'); + form.append('conversationId', activeId); + + const uploadRes = await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } }); + const fileId = uploadRes.data.id; + + const payload = JSON.stringify({ type: 'file', fileId, fileKeyB64, fileName: file.name, mimeType: file.type }); + const ciphertext = await encryptMessage(payload, convKey); + + setMessages((prev) => [...prev, { + id: `pending-${Date.now()}`, + sender_id: userId!, + sender_username: useAuthStore.getState().username || userId!, + ciphertext, + plaintext: payload, + file_id: fileId, + sent_at: new Date().toISOString(), + }]); + + socket.send({ type: 'message', conversationId: activeId, ciphertext, fileId }); + qc.invalidateQueries({ queryKey: ['files'] }); + } finally { + setUploading(false); + } + }, [activeId, getConvKey, userId, qc]); + async function startConversation(recipient: UserResult) { if (!publicKey || !privateKey) return; - - // Generate random conversation key const convKeyRaw = generateConvKeyRaw(); - - // Seal a copy for each participant using their X25519 public key - // Each party can only open their own copy const [myEncKey, recipientEncKey] = await Promise.all([ sealConvKey(convKeyRaw, publicKey), sealConvKey(convKeyRaw, recipient.public_key), ]); - const res = await api.post('/conversations', { recipientId: recipient.id, - encryptedKeys: { - [userId!]: myEncKey, - [recipient.id]: recipientEncKey, - }, + encryptedKeys: { [userId!]: myEncKey, [recipient.id]: recipientEncKey }, }); - - // Cache the decrypted key in memory for immediate use const convKey = await importConvKey(convKeyRaw); setConvKey(res.data.conversationId, convKey); - qc.invalidateQueries({ queryKey: ['conversations'] }); setNewUserSearch(''); setSearchResults([]); @@ -171,7 +294,7 @@ export default function MessagesPage() { return (
- {/* Conversation list — full screen on mobile when no chat open, sidebar on desktop */} + {/* Conversation list */} - {/* Chat area — hidden on mobile when no chat open */} + {/* Chat area */}
{activeId ? ( <>
-
@@ -250,20 +364,27 @@ export default function MessagesPage() {
{messages.map((m) => { const mine = m.sender_id === userId; + const parsed = parsePlaintext(m.plaintext ?? ''); return (
-
-

{m.plaintext}

-

- {m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'} -

-
+ {parsed.type === 'file' && parsed.fileId ? ( + + ) : ( +
+

{parsed.text}

+

+ {m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'} +

+
+ )}
); })} @@ -271,14 +392,21 @@ export default function MessagesPage() {
+ { const f = e.target.files?.[0]; if (f) sendFile(f); e.target.value = ''; }} /> + setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()} /> -