diff --git a/backend/models.py b/backend/models.py index 13bec2f..c50210e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -45,10 +45,6 @@ class MessageFile(Base): id = Column(Integer, primary_key=True, index=True) message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) path = Column(Text, nullable=False) - encrypted = Column(Boolean, default=False, nullable=False) - filename = Column(String(255), nullable=True) - content_type = Column(String(255), nullable=True) - size = Column(Integer, nullable=True) message = relationship("Message", back_populates="files") @@ -80,7 +76,22 @@ class DMEnvelope(Base): salt_b64 = Column(Text, nullable=False) iv2_b64 = Column(Text, nullable=False) wrapped_mk_b64 = Column(Text, nullable=False) + reply_to_id = Column(Integer, nullable=True) timestamp = Column(DateTime, default=datetime.now) + files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") + + +class DMFile(Base): + __tablename__ = "dm_file" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) + name = Column(Text, nullable=False) + path = Column(Text, nullable=False) + + message = relationship("DMEnvelope", back_populates="files") class PushSubscription(Base): diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 81b83f6..e17e0eb 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,14 +4,13 @@ from pathlib import Path import os import re import uuid -from typing import Iterable from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile from push_service import push_service from PIL import Image import io @@ -150,11 +149,7 @@ async def send_message( mf = MessageFile( message_id=new_message.id, - path=str(out_path), - encrypted=False, - filename=original_name, - content_type=up.content_type, - size=len(content), + path=str(out_path) ) db.add(mf) db.commit() @@ -203,7 +198,6 @@ async def dm_send( files: list[UploadFile] = File(default=[]), fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files ): - import json if dm_payload and payload is None: try: payload = json.loads(dm_payload) @@ -226,6 +220,7 @@ async def dm_send( salt_b64=payload["salt"], iv2_b64=payload["iv2"], wrapped_mk_b64=payload["wrappedMk"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, ) db.add(env) db.commit() @@ -235,12 +230,12 @@ async def dm_send( if files: # Validate total size total_size = 0 - for up in files: - if hasattr(up, "size") and up.size is not None: - total_size += int(up.size) + for file in files: + if hasattr(file, "size") and file.size is not None: + total_size += int(file.size) else: - data = await up.read() - up.file.seek(0) + data = await file.read() + file.file.seek(0) total_size += len(data) if total_size > MAX_TOTAL_SIZE: raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") @@ -254,21 +249,31 @@ async def dm_send( except Exception: names = [] - for idx, up in enumerate(files): - provided = names[idx] if idx < len(names) else None + for i, file in enumerate(files): + provided = names[i] if i < len(names) else None # Sanitize provided name to avoid path traversal if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided): provided = None - original_name = provided or Path(up.filename or "file").name + original_name = provided or Path(file.filename or "file").name # Save using provided/original name to allow client to reference path directly safe_name = original_name - out_path = FILES_ENCRYPTED_DIR / safe_name + out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}" + out_path = FILES_ENCRYPTED_DIR / out_name - content = await up.read() + content = await file.read() with open(out_path, "wb") as f: f.write(content) - # We do not store linkage to public messages for DMs; paths will be referenced inside encrypted JSON + # Save DM file record + df = DMFile( + message_id=env.id, + sender_id=current_user.id, + recipient_id=env.recipient_id, + path=f"/api/uploads/files/encrypted/{out_name}", + name=safe_name + ) + db.add(df) + db.commit() # Send push notification for DM try: @@ -278,7 +283,6 @@ async def dm_send( # Realtime notify both users for HTTP requests try: - from .messaging import messagingManager # self import payload_ws = { "type": "dmNew", "data": { @@ -291,6 +295,7 @@ async def dm_send( "iv2": env.iv2_b64, "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, } } await messagingManager.send_to_user(env.recipient_id, payload_ws) @@ -300,13 +305,7 @@ async def dm_send( return {"status": "ok", "id": env.id} - -@router.get("/dm/fetch") -async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) - if since: - q = q.filter(DMEnvelope.id > since) - envs = q.order_by(DMEnvelope.id.asc()).all() +def convert_envelopes(envs: list[DMEnvelope]): return { "status": "ok", "messages": [ @@ -320,15 +319,23 @@ async def dm_fetch(since: int | None = None, current_user: User = Depends(get_cu "iv2": e.iv2_b64, "wrappedMk": e.wrapped_mk_b64, "timestamp": e.timestamp.isoformat(), + "files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files] } for e in envs ] } +@router.get("/dm/fetch") +async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) + if since: + q = q.filter(DMEnvelope.id > since) + return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all()) + @router.get("/dm/history/{other_user_id}") async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - envs = ( + return convert_envelopes( db.query(DMEnvelope) .filter( ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) @@ -337,23 +344,6 @@ async def dm_history(other_user_id: int, current_user: User = Depends(get_curren .order_by(DMEnvelope.id.asc()) .all() ) - return { - "status": "ok", - "messages": [ - { - "id": e.id, - "senderId": e.sender_id, - "recipientId": e.recipient_id, - "iv": e.iv_b64, - "ciphertext": e.ciphertext_b64, - "salt": e.salt_b64, - "iv2": e.iv2_b64, - "wrappedMk": e.wrapped_mk_b64, - "timestamp": e.timestamp.isoformat(), - } - for e in envs - ] - } @router.put("/edit_message/{message_id}") @@ -503,6 +493,7 @@ class MessaggingSocketManager: salt_b64=payload["salt"], iv2_b64=payload["iv2"], wrapped_mk_b64=payload["wrappedMk"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, ) db.add(env) db.commit() @@ -520,6 +511,7 @@ class MessaggingSocketManager: "iv2": env.iv2_b64, "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, } } @@ -552,6 +544,76 @@ class MessaggingSocketManager: await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "dmEdit": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + payload = data["data"] + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + # Replace ciphertext and iv + env.iv_b64 = payload["iv"] + env.ciphertext_b64 = payload["ciphertext"] + env.iv2_b64 = payload["iv2"] + env.wrapped_mk_b64 = payload["wrappedMk"] + env.salt_b64 = payload["salt"] + db.commit() + db.refresh(env) + + payload_ws = { + "type": "dmEdited", + "data": { + "id": env.id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "salt": env.salt_b64, + "timestamp": env.timestamp.isoformat(), + } + } + await self.send_to_user(env.recipient_id, payload_ws) + await self.send_to_user(env.sender_id, payload_ws) + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "dmDelete": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + payload = data["data"] + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(env) + db.commit() + + payload_ws = { + "type": "dmDeleted", + "data": { + "id": env_id, + "senderId": current_user.id, + "recipientId": payload.get("recipientId") + } + } + await self.send_to_user(env.recipient_id, payload_ws) + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) + await self.send_to_user(env.sender_id, payload_ws) + except HTTPException as e: + await self.send_error(websocket, type, e) elif type == "deleteMessage": try: current_user = get_current_user_inner() @@ -609,7 +671,7 @@ async def chat_websocket( # File serving endpoints -@router.get("/files/normal/{filename}") +@router.get("/uploads/files/normal/{filename}") async def get_file_normal(filename: str): if not re.match(r"^[A-Za-z0-9._-]+$", filename): raise HTTPException(status_code=400, detail="Invalid file name") @@ -619,11 +681,22 @@ async def get_file_normal(filename: str): return FileResponse(str(path)) -@router.get("/files/encrypted/{filename}") -async def get_file_encrypted(filename: str): +@router.get("/uploads/files/encrypted/{filename}") +async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)): if not re.match(r"^[A-Za-z0-9._-]+$", filename): raise HTTPException(status_code=400, detail="Invalid file name") path = FILES_ENCRYPTED_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="File not found") + + match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name) + if match: + sender_id = int(match.group(1)) + recipient_id = int(match.group(2)) + + if not current_user.id in [sender_id, recipient_id]: + raise HTTPException(403) + else: + raise HTTPException(500) + return FileResponse(str(path)) \ No newline at end of file diff --git a/frontend/src/api/dmApi.ts b/frontend/src/api/dmApi.ts index f5a0cf0..86194a9 100644 --- a/frontend/src/api/dmApi.ts +++ b/frontend/src/api/dmApi.ts @@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s import { randomBytes } from "../utils/crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request } from "../core/websocket"; -import type { SendDMRequest, DmEnvelope, User } from "../core/types"; +import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; import { b64, ub64 } from "../utils/utils"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { @@ -46,7 +46,7 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } -export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise { +export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); @@ -69,6 +69,7 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey iv2: b64(wrap.iv), wrappedMk: b64(wrap.ciphertext) }; + if (replyToId) payload.replyToId = replyToId; await request({ type: "dmSend", @@ -78,4 +79,93 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey }, data: payload }); -} \ No newline at end of file +} + +export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; // server uses provided name + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Merge files metadata into plaintext JSON and encrypt + let obj: DmEncryptedJSON; + try { + obj = JSON.parse(plaintextJson); + } catch { + obj = { type: "text", data: { content: String(plaintextJson) } }; + } + + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); + const wrap = await aesGcmEncrypt(wk, mk); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext), + salt: b64(wkSalt) + } + } as DMEditWebSocketMessage); +} + +export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 90b5b19..7d8d852 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -155,6 +155,7 @@ export interface SendDMRequest { salt: string; iv2: string; wrappedMk: string; + replyToId?: number; } // Responses @@ -174,22 +175,54 @@ export interface BackupBlob { blob: string; } -export interface DmEnvelope { - id: number; - senderId: number; - recipientId: number; +export interface BaseDmEnvelope { iv: string; ciphertext: string; salt: string; iv2: string; wrappedMk: string; + recipientId: number; +} + +export interface DmEnvelope extends BaseDmEnvelope { + id: number; + senderId: number; + files?: DmFile[]; timestamp: string; } +export interface DmFile { + name: string; + id: number; + path: string; +} + +export interface DmEditedPayload { + id: number; + iv: string; + ciphertext: string; + timestamp: string +} + +export interface DmDeletedPayload { + id: number; + senderId: number; + recipientId: number +} + export interface FetchDMResponse { messages: DmEnvelope[] } +export interface DmEncryptedJSON { + type: "text", + data: { + content: string; + reply_to_id?: number; + files?: Attachment[]; + } +} + // --------------- // WebSocket types // --------------- @@ -231,6 +264,18 @@ export interface WebSocketCredentials { credentials: string; } +export interface DMEditWebSocketMessage extends WebSocketMessage { + type: "dmEdit", + data: { + id: number; + iv: string; + ciphertext: string; + iv2: string; + wrappedMk: string; + salt: string; + } +} + export interface Attachment { path: string; encrypted: boolean; @@ -239,6 +284,23 @@ export interface Attachment { size?: number; } +// ----------- +// Encrypted message JSON (plaintext structure before encryption) +// ----------- + +export type ChatMessageKind = "text"; // Extendable for future kinds + +export interface EncryptedTextMessageData { + content: string; + files?: Attachment[]; + reply_to_id?: number | null; +} + +export interface EncryptedMessageJson { + type: ChatMessageKind; + data: EncryptedTextMessageData; +} + // ----------- // React types // ----------- diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 03ff3bd..6d5087b 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -232,7 +232,7 @@ .quote.reply-preview { user-select: none; - margin-bottom: 10px; + margin: 10px; } .message-attachments { diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index d4d0787..e7b419d 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -18,7 +18,20 @@ interface ChatInputWrapperProps { onCloseEdit?: () => void; } -export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) { +export function ChatInputWrapper( + { + onSendMessage, + onSaveEdit, + replyTo, + replyToVisible, + onClearReply, + onCloseReply, + editingMessage, + editVisible = false, + onClearEdit, + onCloseEdit + }: ChatInputWrapperProps +) { const [message, setMessage] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); const [attachmentsVisible, setAttachmentsVisible] = useState(false); diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index c835004..e1e3799 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -8,7 +8,6 @@ import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu" import { fetchUserProfile } from "../../../api/profileApi"; import { useEffect, useState, type ReactNode } from "react"; import { delay } from "../../../utils/utils"; -import { request } from "../../../core/websocket"; import { MaterialDialog } from "../core/Dialog"; interface ChatMessagesProps { @@ -17,9 +16,11 @@ interface ChatMessagesProps { children?: ReactNode; onReplySelect?: (message: MessageType) => void; onEditSelect?: (message: MessageType) => void; + onDelete?: (id: number) => void; + dmRecipientPublicKey?: string; } -export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect }: ChatMessagesProps) { +export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) { const { messages: hookMessages } = useChat(); const { user } = useAppState(); @@ -38,7 +39,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o // Delete dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [toBeDeleted, setToBeDeleted] = useState(null); + const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); useEffect(() => { if (!deleteDialogOpen) { @@ -88,28 +89,31 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o }; async function confirmDelete() { - if (toBeDeleted) { - if (!user.authToken) return; - - try { - await request({ - type: "deleteMessage", - data: { message_id: toBeDeleted }, - credentials: { - scheme: "Bearer", - credentials: user.authToken - } - }); - } catch (error) { - console.error("Failed to delete message:", error); - } - - setDeleteDialogOpen(false); + if (!toBeDeleted || !user.authToken) return; + try { + onDelete?.(toBeDeleted.id); + // if (toBeDeleted.isDm) { + // // For DM, send dmDelete + // await request({ + // type: "dmDelete", + // data: { id: toBeDeleted.id }, + // credentials: { scheme: "Bearer", credentials: user.authToken } + // }); + // } else { + // await request({ + // type: "deleteMessage", + // data: { message_id: toBeDeleted.id }, + // credentials: { scheme: "Bearer", credentials: user.authToken } + // }); + // } + } catch (error) { + console.error("Failed to delete message:", error); } + setDeleteDialogOpen(false); } async function handleDelete(message: MessageType) { - setToBeDeleted(message.id); + setToBeDeleted({ id: message.id, isDm }); setDeleteDialogOpen(true); } @@ -124,7 +128,9 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onProfileClick={handleProfileClick} onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} - isDm={isDm} /> + isDm={isDm} + dmRecipientPublicKey={dmRecipientPublicKey} + dmEnvelope={(message as any).dmEnvelope} /> ))} {children} diff --git a/frontend/src/ui/components/chat/DMPanel.tsx b/frontend/src/ui/components/chat/DMPanel.tsx deleted file mode 100644 index bb055b3..0000000 --- a/frontend/src/ui/components/chat/DMPanel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { useAppState } from "../../state"; -import { useDM } from "../../hooks/useDM"; -import { ChatMessages } from "./ChatMessages"; -import defaultAvatar from "../../../resources/images/default-avatar.png"; - -export function DMPanel() { - const { chat } = useAppState(); - const { sendDMMessage, isLoadingHistory } = useDM(); - const [message, setMessage] = useState(""); - const messagesEndRef = useRef(null); - - const activeDm = chat.activeDm; - - // Scroll to bottom when messages change - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [chat.messages]); - - const handleSendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (!message.trim() || !activeDm?.publicKey) return; - - try { - await sendDMMessage(activeDm.userId, activeDm.publicKey, message); - setMessage(""); - } catch (error) { - console.error("Failed to send DM:", error); - } - }; - - const handleProfileClick = () => { - // TODO: Implement profile dialog for DM user - console.log("Profile clicked for DM user:", activeDm?.username); - }; - - if (!activeDm) { - return ( -
-
- Avatar -
-
-

Выберите пользователя

-

- - Выберите пользователя для начала разговора -

-
-
-
-
-
- Выберите пользователя из списка для начала личных сообщений -
-
-
- ); - } - - return ( -
-
- Avatar -
-
-

{activeDm.username}

-

- - Личные сообщения -

-
- Свернуть чат -
-
- -
- {isLoadingHistory ? ( -
- Загрузка сообщений... -
- ) : ( - <> - -
- - )} -
- -
-
-
- setMessage(e.target.value)} - /> - -
-
-
-
- ); -} diff --git a/frontend/src/ui/components/chat/DMUsersList.tsx b/frontend/src/ui/components/chat/DMUsersList.tsx index df01f1c..cd2b1a5 100644 --- a/frontend/src/ui/components/chat/DMUsersList.tsx +++ b/frontend/src/ui/components/chat/DMUsersList.tsx @@ -38,10 +38,7 @@ export function DMUsersList() { if (!user.publicKey) { // Get public key if not already loaded const authToken = useAppState.getState().user.authToken; - if (!authToken) { - console.error("No auth token available"); - return; - } + if (!authToken) return; const publicKey = await fetchUserPublicKey(user.id, authToken); if (publicKey) { diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index cd3d30c..5f6d56b 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -5,6 +5,12 @@ import Quote from "../core/Quote"; import { parse } from "marked"; import DOMPurify from "dompurify"; import { useEffect, useState } from "react"; +import { getCurrentKeys } from "../../../auth/crypto"; +import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"; +import { getAuthHeaders } from "../../../auth/api"; +import { useAppState } from "../../state"; +import { ub64 } from "../../../utils/utils"; interface MessageProps { message: MessageType; @@ -13,10 +19,18 @@ interface MessageProps { onContextMenu: (e: React.MouseEvent, message: MessageType) => void; isLoadingProfile?: boolean; isDm?: boolean; + dmRecipientPublicKey?: string; + dmEnvelope?: { + salt: string; + iv2: string; + wrappedMk: string; + }; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { - const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() }); +export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) { + const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); + const [decryptedFiles, setDecryptedFiles] = useState>(new Map()); + const { user } = useAppState(); useEffect(() => { (async () => { @@ -28,6 +42,54 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo })(); }, [message]); + const decryptFile = async (file: any): Promise => { + if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; + + // Check if already decrypted + if (decryptedFiles.has(file.path)) { + return decryptedFiles.get(file.path) || null; + } + + try { + // Fetch encrypted file + const response = await fetch(file.path, { + headers: getAuthHeaders(user.authToken!) + }); + if (!response.ok) throw new Error("Failed to fetch file"); + + const encryptedData = await response.arrayBuffer(); + + // Get current user's keys + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Derive shared secret with the recipient's public key + const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); + + // Derive wrapping key using the salt from the DM envelope + const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Unwrap the message key + const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); + + // Decrypt the file using the message key + const iv = new Uint8Array(encryptedData, 0, 12); + const ciphertext = new Uint8Array(encryptedData, 12); + const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext); + + // Create blob URL for download + const blob = new Blob([decrypted.buffer as ArrayBuffer]); + const url = URL.createObjectURL(blob); + + setDecryptedFiles(prev => new Map(prev).set(file.path, url)); + return url; + } catch (error) { + console.error("Failed to decrypt file:", error); + return null; + } + }; + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -81,13 +143,31 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo {message.files.map((file, idx) => { const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || "")); + const downloadUrl = decryptedFiles.get(file.path) || file.path; return ( diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 2cf4ac4..76939f0 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -153,6 +153,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen { if (editMessage || editVisible) { setPendingAction({ type: "reply", message: message }); @@ -169,6 +170,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen setEditMessage(message); } }} + onDelete={(id) => panel.handleDeleteMessage(id)} >
diff --git a/frontend/src/ui/hooks/useDM.ts b/frontend/src/ui/hooks/useDM.ts index af7f5d7..8b7cc8b 100644 --- a/frontend/src/ui/hooks/useDM.ts +++ b/frontend/src/ui/hooks/useDM.ts @@ -7,7 +7,7 @@ import { decryptDm, sendDMViaWebSocket } from "../../api/dmApi"; -import type { User, Message } from "../../core/types"; +import type { User, Message, DmEncryptedJSON } from "../../core/types"; import { websocket } from "../../core/websocket"; interface DMUser extends User { @@ -41,7 +41,8 @@ export function useDM() { let lastPlaintext: string | null = null; try { - lastPlaintext = await decryptDm(lastMessage, publicKey); + lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; + console.log(lastPlaintext); } catch (error) { console.error("Failed to decrypt last message:", error); } @@ -93,50 +94,7 @@ export function useDM() { // Load last messages and unread counts for visible users // Call loadUserLastMessage directly without dependency for (const dmUser of dmUsersWithState) { - if (!user.authToken) continue; - - try { - // Get public key - const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); - if (!publicKey) continue; - - // Get message history - const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); - if (messages.length === 0) continue; - - // Find last message - const lastMessage = messages[messages.length - 1]; - let lastPlaintext: string | null = null; - - try { - lastPlaintext = await decryptDm(lastMessage, publicKey); - } catch (error) { - console.error("Failed to decrypt last message:", error); - } - - // Calculate unread count - const lastReadId = getLastReadId(dmUser.id); - let unreadCount = 0; - for (const msg of messages) { - if (msg.senderId === dmUser.id && msg.id > lastReadId) { - unreadCount++; - } - } - - // Update user state - setDmUsersState(prev => prev.map(u => - u.id === dmUser.id - ? { - ...u, - lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined, - unreadCount, - publicKey - } - : u - )); - } catch (error) { - console.error("Failed to load last message for user:", dmUser.id, error); - } + await loadUserLastMessage(dmUser); } } catch (error) { console.error("Failed to load DM users:", error); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index a00e84f..14af6de 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -1,11 +1,13 @@ -import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; +import { MessagePanel } from "./MessagePanel"; import { fetchDMHistory, decryptDm, sendDMViaWebSocket, - sendDmWithFiles + sendDmWithFiles, + editDmEnvelope, + deleteDmEnvelope } from "../../api/dmApi"; -import type { Message, WebSocketMessage } from "../../core/types"; +import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types"; import type { UserState } from "../state"; export interface DMPanelData { @@ -21,11 +23,9 @@ export class DMPanel extends MessagePanel { private messagesLoaded: boolean = false; constructor( - user: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void + user: UserState ) { - super("dm", user, callbacks, onStateChange); + super("dm", user); } isDm(): boolean { @@ -42,6 +42,45 @@ export class DMPanel extends MessagePanel { // DM doesn't need special cleanup } + private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { + const plaintext = await decryptDm(env, this.dmData!.publicKey); + const isAuthor = env.senderId !== this.dmData!.userId; + const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; + + // Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } } + let content = plaintext; + let reply_to_id: number | undefined = undefined; + try { + const obj = JSON.parse(plaintext) as DmEncryptedJSON; + if (obj && obj.type === "text" && obj.data) { + content = obj.data.content; + reply_to_id = Number(obj.data.reply_to_id) || undefined; + } + } catch {} + + const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = { + id: env.id, + content: content, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false, + files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [], + dmEnvelope: { + salt: env.salt, + iv2: env.iv2, + wrappedMk: env.wrappedMk + } + }; + + if (reply_to_id) { + const referenced = decryptedMessages.find(m => m.id === reply_to_id); + if (referenced) dmMsg.reply_to = referenced; + } + + return dmMsg; + } + async loadMessages(): Promise { if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return; @@ -53,18 +92,8 @@ export class DMPanel extends MessagePanel { for (const env of messages) { try { - const text = await decryptDm(env, this.dmData!.publicKey); - const isAuthor = env.senderId !== this.dmData!.userId; - const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; - - decryptedMessages.push({ - id: env.id, - content: text, - username: username, - timestamp: env.timestamp, - is_read: false, - is_edited: false - }); + const dmMsg = await this.parseTextPayload(env, decryptedMessages); + decryptedMessages.push(dmMsg); if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { maxIncomingId = env.id; @@ -89,19 +118,27 @@ export class DMPanel extends MessagePanel { } } - async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise { + async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; try { + const payload: DmEncryptedJSON = { + type: "text", + data: { + content: content.trim(), + reply_to_id: replyToId ?? undefined + } + } + const json = JSON.stringify(payload); + if (files.length === 0) { await sendDMViaWebSocket( - this.dmData.userId, - this.dmData.publicKey, - content, + this.dmData.userId, + this.dmData.publicKey, + json, this.currentUser.authToken ); } else { - const json = JSON.stringify({ type: "text", data: { content: content.trim() } }); await sendDmWithFiles( this.dmData.userId, this.dmData.publicKey, @@ -130,25 +167,16 @@ export class DMPanel extends MessagePanel { // Handle incoming WebSocket DM messages handleWebSocketMessage = async (response: WebSocketMessage): Promise => { if (response.type === "dmNew" && this.dmData) { - const { senderId, recipientId, ...envelope } = response.data; + const envelope = response.data as DmEnvelope; // If this is for the active DM conversation - if (senderId === this.dmData.userId || recipientId === this.dmData.userId) { + if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { try { - const plaintext = await decryptDm(envelope, this.dmData.publicKey); - const isAuthor = senderId !== this.dmData.userId; - - this.addMessage({ - id: envelope.id, - content: plaintext, - username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username, - timestamp: envelope.timestamp, - is_read: false, - is_edited: false - }); + const dmMsg = await this.parseTextPayload(envelope, this.getMessages()); + this.addMessage(dmMsg); // Update last read if it's from the other user - if (senderId === this.dmData.userId) { + if (envelope.senderId === this.dmData.userId) { this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id)); } } catch (error) { @@ -156,6 +184,43 @@ export class DMPanel extends MessagePanel { } } } + if (response.type === "dmEdited" && this.dmData) { + const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; + try { + // Decrypt new content in-place + const plaintext = await decryptDm( + { + id, + senderId: 0, + recipientId: 0, + iv, + ciphertext, + salt, + iv2, + wrappedMk, + timestamp: new Date().toISOString() + }, + this.dmData.publicKey + ); + let content = plaintext; + let files: Message["files"] | undefined = undefined; + try { + const obj = JSON.parse(plaintext) as EncryptedMessageJson; + if (obj.type === "text" && obj.data) { + content = obj.data.content; + files = obj.data.files; + } + } catch {} + const updates: Partial = { content, is_edited: true, files }; + this.updateMessage(id, updates); + } catch (e) { + this.updateMessage(id, { is_edited: true }); + } + } + if (response.type === "dmDeleted" && this.dmData) { + const { id } = response.data; + this.removeMessage(id); + } }; // Reset for DM switching @@ -191,4 +256,29 @@ export class DMPanel extends MessagePanel { localStorage.setItem(`dmLastRead:${userId}`, String(id)); } catch {} } + + async handleDeleteMessage(messageId: number): Promise { + if (!this.currentUser.authToken || !this.dmData) return; + // Fire and forget; UI will update via dmDeleted + await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken); + } + + async handleEditMessage(messageId: number, content: string): Promise { + if (!this.currentUser.authToken || !this.dmData) return; + const msg = this.getMessages().find(m => m.id === messageId); + // Build encrypted JSON preserving files and reply_to if present + const payload: EncryptedMessageJson = { + type: "text", + data: { + content: content, + files: msg?.files, + reply_to_id: msg?.reply_to?.id ?? undefined + } + }; + editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { + console.error("Failed to edit DM:", e); + }); + } + + handleProfileClick(): void {} } diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index 960b437..b1e74b0 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -21,15 +21,12 @@ export interface MessagePanelCallbacks { export abstract class MessagePanel { protected state: MessagePanelState; - protected callbacks: MessagePanelCallbacks; - public onStateChange: ((state: MessagePanelState) => void) | null; - protected currentUser: UserState; + public onStateChange: ((state: MessagePanelState) => void) | null = () => {}; + protected readonly currentUser: UserState; constructor( id: string, currentUser: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void ) { this.state = { id, @@ -40,8 +37,6 @@ export abstract class MessagePanel { isTyping: false }; this.currentUser = currentUser; - this.callbacks = callbacks; - this.onStateChange = onStateChange; } // Abstract methods that must be implemented by subclasses @@ -115,23 +110,10 @@ export abstract class MessagePanel { } // Event handlers - handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => { + handleSendMessage(content: string, replyToId?: number, files: File[] = []): void { this.sendMessage(content, replyToId, files); }; - - handleEditMessage = (messageId: number, content: string): void => { - this.callbacks.onEditMessage(messageId, content); - }; - - handleDeleteMessage = (messageId: number): void => { - this.callbacks.onDeleteMessage(messageId); - }; - - handleReplyToMessage = (messageId: number, content: string): void => { - this.callbacks.onReplyToMessage(messageId, content); - }; - - handleProfileClick = (): void => { - this.callbacks.onProfileClick(); - }; + abstract handleEditMessage(messageId: number, content: string): Promise; + abstract handleDeleteMessage(messageId: number): Promise; + abstract handleProfileClick(): void; } diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index fe997ac..8bdf755 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -1,4 +1,4 @@ -import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; +import { MessagePanel } from "./MessagePanel"; import { API_BASE_URL } from "../../core/config"; import { getAuthHeaders } from "../../auth/api"; import { request } from "../../core/websocket"; @@ -10,11 +10,9 @@ export class PublicChatPanel extends MessagePanel { constructor( chatName: string, - currentUser: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void + currentUser: UserState ) { - super(`public-${chatName}`, currentUser, callbacks, onStateChange); + super(`public-${chatName}`, currentUser); this.updateState({ title: chatName, online: true // Public chats are always "online" @@ -137,4 +135,36 @@ export class PublicChatPanel extends MessagePanel { setAuthToken(authToken: string): void { this.currentUser.authToken = authToken; } + + async handleEditMessage(messageId: number, content: string): Promise { + if (!this.currentUser.authToken) return; + try { + await request({ + type: "editMessage", + data: { + message_id: messageId, + content: content + }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken + } + }); + } catch (error) { + console.error("Failed to edit message:", error); + } + } + + async handleDeleteMessage(id: number): Promise { + await request({ + type: "deleteMessage", + data: { message_id: id }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken! + } + }); + } + + handleProfileClick(): void {} } \ No newline at end of file diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 377d8ac..bf31eb4 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -277,37 +277,7 @@ export const useAppState = create((set, get) => ({ // Create or get public chat panel let publicChatPanel = chat.publicChatPanel; if (!publicChatPanel) { - const callbacks = { - onSendMessage: (_content: string) => {}, - onEditMessage: async (messageId: number, content: string) => { - if (!user.authToken) return; - try { - await request({ - type: "editMessage", - data: { - message_id: messageId, - content: content - }, - credentials: { - scheme: "Bearer", - credentials: user.authToken - } - }); - } catch (error) { - console.error("Failed to edit message:", error); - } - }, - onDeleteMessage: (_messageId: number) => {}, - onReplyToMessage: (_messageId: number, _content: string) => {}, - onProfileClick: () => {} - }; - - publicChatPanel = new PublicChatPanel( - chatName, - user, - callbacks, - () => {} // State change handled by MessagePanelRenderer - ); + publicChatPanel = new PublicChatPanel(chatName, user); } else { publicChatPanel.setChatName(chatName); publicChatPanel.setAuthToken(user.authToken); @@ -346,19 +316,7 @@ export const useAppState = create((set, get) => ({ // Create or get DM panel let dmPanel = chat.dmPanel; if (!dmPanel) { - const callbacks = { - onSendMessage: (_content: string) => {}, - onEditMessage: (_messageId: number, _content: string) => {}, - onDeleteMessage: (_messageId: number) => {}, - onReplyToMessage: (_messageId: number, _content: string) => {}, - onProfileClick: () => {} - }; - - dmPanel = new DMPanel( - user, - callbacks, - () => {} // State change handled by MessagePanelRenderer - ); + dmPanel = new DMPanel(user); } else { dmPanel.setAuthToken(user.authToken); }