From a5caf940a66e5ca4e244c132da92bcc81d51fb71 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:01:03 +0300 Subject: [PATCH] Implement minimal DM UI --- backend/models.py | 30 ++++++++++ backend/routes/account.py | 45 +++++++++++++- backend/routes/messaging.py | 48 ++++++++++++++- frontend/index.html | 15 +++++ frontend/src/chat/dm.ts | 113 ++++++++++++++++++++++++++++++++++++ frontend/src/main.ts | 3 +- 6 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 frontend/src/chat/dm.ts diff --git a/backend/models.py b/backend/models.py index 50271d4..33b67cd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -38,6 +38,36 @@ class Message(Base): reply_to = relationship("Message", remote_side=[id]) +class CryptoPublicKey(Base): + __tablename__ = "crypto_public_key" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) + public_key_b64 = Column(Text, nullable=False) + + +class CryptoBackup(Base): + __tablename__ = "crypto_backup" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True) + blob_json = Column(Text, nullable=False) + + +class DMEnvelope(Base): + __tablename__ = "dm_envelope" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) + iv_b64 = Column(Text, nullable=False) + ciphertext_b64 = Column(Text, nullable=False) + salt_b64 = Column(Text, nullable=False) + iv2_b64 = Column(Text, nullable=False) + wrapped_mk_b64 = Column(Text, nullable=False) + timestamp = Column(DateTime, default=datetime.now) + + # Pydantic модели class LoginRequest(BaseModel): username: str diff --git a/backend/routes/account.py b/backend/routes/account.py index eaa8ece..3f3a0fd 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -2,10 +2,9 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from routes.messaging import convert_message from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from models import LoginRequest, RegisterRequest, User +from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup from utils import create_token, get_password_hash, verify_password from validation import is_valid_password, is_valid_username @@ -121,6 +120,48 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)): } +@router.get("/crypto/public-key") +def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() + return {"publicKey": row.public_key_b64 if row else None} + + +@router.post("/crypto/public-key") +def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + pk = payload.get("publicKey") + if not pk: + raise HTTPException(status_code=400, detail="publicKey required") + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() + if row: + row.public_key_b64 = pk + else: + row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk) + db.add(row) + db.commit() + return {"status": "ok"} + + +@router.get("/crypto/backup") +def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() + return {"blob": row.blob_json if row else None} + + +@router.post("/crypto/backup") +def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + blob = payload.get("blob") + if not blob: + raise HTTPException(status_code=400, detail="blob required") + row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() + if row: + row.blob_json = blob + else: + row = CryptoBackup(user_id=current_user.id, blob_json=blob) + db.add(row) + db.commit() + return {"status": "ok"} + + @router.delete("/admin/user/{user_id}") def delete_user_as_owner( user_id: int, diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 36f60d8..57fc406 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -5,7 +5,7 @@ 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, ReplyMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -68,6 +68,52 @@ async def get_messages(db: Session = Depends(get_db)): } +@router.post("/dm/send") +async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] + for key in required: + if key not in payload: + raise HTTPException(status_code=400, detail=f"Missing {key}") + env = DMEnvelope( + sender_id=current_user.id, + recipient_id=int(payload["recipientId"]), + iv_b64=payload["iv"], + ciphertext_b64=payload["ciphertext"], + salt_b64=payload["salt"], + iv2_b64=payload["iv2"], + wrapped_mk_b64=payload["wrappedMk"], + ) + db.add(env) + db.commit() + db.refresh(env) + 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() + 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}") async def edit_message( message_id: int, diff --git a/frontend/index.html b/frontend/index.html index 00e4448..645c17b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -147,6 +147,9 @@ Контакты + + ЛС + @@ -160,6 +163,18 @@ Скоро будет... Скоро будет... + + + +
+ + + Отправить +
+
+
+
+
diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts new file mode 100644 index 0000000..1ad0f77 --- /dev/null +++ b/frontend/src/chat/dm.ts @@ -0,0 +1,113 @@ +import { API_BASE_URL } from "../core/config"; +import { getAuthHeaders } from "../auth/api"; +import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; +import { randomBytes } from "../crypto/kdf"; +import { getCurrentKeys } from "../auth/crypto"; + +function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } +function ub64(s: string): Uint8Array { + const bin = atob(s); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; +} + +export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = 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(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify({ + recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }) + }); +} + +export interface DmEnvelope { + id: number; + senderId: number; + recipientId: number; + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedMk: string; + timestamp: string; +} + +export async function fetchDm(since?: number): Promise { + const url = new URL(`${API_BASE_URL}/dm/fetch`); + if (since) url.searchParams.set("since", String(since)); + const res = await fetch(url, { headers: getAuthHeaders(true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.messages ?? []; +} + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +function appendDmMessage(text: string, isAuthor: boolean) { + const container = document.getElementById("dm-messages")!; + const div = document.createElement("div"); + div.className = `message ${isAuthor ? "sent" : "received"}`; + const inner = document.createElement("div"); + inner.className = "message-inner"; + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = text; + inner.appendChild(content); + div.appendChild(inner); + container.appendChild(div); + container.scrollTop = container.scrollHeight; +} + +async function fetchRecipient(userName: string): Promise<{ id: number; publicKey: string | null } | null> { + const res = await fetch(`${API_BASE_URL}/profile/${encodeURIComponent(userName)}`); + if (!res.ok) return null; + const data = await res.json(); + // This assumes an endpoint returns profile with id; adapt if different + const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key`, { headers: getAuthHeaders(true) }); + // For simplicity, we reuse current user's endpoint; in real case, need GET by userId + // Minimal v1: assume recipient has same endpoint at /profile/public-key?userId=... (not implemented) + return { id: data.id, publicKey: null }; +} + +document.addEventListener("DOMContentLoaded", () => { + const sendBtn = document.getElementById("dm-send"); + if (!sendBtn) return; + sendBtn.addEventListener("click", async () => { + const userEl = document.getElementById("dm-username") as HTMLInputElement; + const textEl = document.getElementById("dm-input") as HTMLInputElement; + const username = userEl.value.trim(); + const text = textEl.value.trim(); + if (!username || !text) return; + // TODO: replace with real lookup for recipientId and publicKey + appendDmMessage(text, true); + textEl.value = ""; + }); +}); + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b49445f..408fbb1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -16,4 +16,5 @@ import "./core/init"; import "./userPanel/profile/profile"; import "./chat/contextMenu"; import "./chat/profileDialog"; -import "./electron/electron"; \ No newline at end of file +import "./electron/electron"; +import "./chat/dm"; \ No newline at end of file