From a527bf23cddd479e0b2db21bf02585224fb400b5 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 16:50:48 +0300 Subject: [PATCH 01/15] Add a crypto module --- frontend/src/crypto/asymmetric.ts | 23 +++++++++++ frontend/src/crypto/backup.ts | 68 +++++++++++++++++++++++++++++++ frontend/src/crypto/index.ts | 4 ++ frontend/src/crypto/kdf.ts | 28 +++++++++++++ frontend/src/crypto/symmetric.ts | 19 +++++++++ frontend/src/crypto/types.d.ts | 6 +++ package.json | 3 +- 7 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 frontend/src/crypto/asymmetric.ts create mode 100644 frontend/src/crypto/backup.ts create mode 100644 frontend/src/crypto/index.ts create mode 100644 frontend/src/crypto/kdf.ts create mode 100644 frontend/src/crypto/symmetric.ts create mode 100644 frontend/src/crypto/types.d.ts diff --git a/frontend/src/crypto/asymmetric.ts b/frontend/src/crypto/asymmetric.ts new file mode 100644 index 0000000..2aca8f5 --- /dev/null +++ b/frontend/src/crypto/asymmetric.ts @@ -0,0 +1,23 @@ +import nacl from "tweetnacl"; +import { hkdfExtractAndExpand } from "../crypto/kdf"; + +export interface X25519KeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export function generateX25519KeyPair(): X25519KeyPair { + const kp = nacl.box.keyPair(); + return { publicKey: kp.publicKey, privateKey: kp.secretKey }; +} + +export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { + // nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF. + return nacl.box.before(theirPublicKey, myPrivateKey); +} + +export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { + return hkdfExtractAndExpand(sharedSecret.buffer, salt, info, 32); +} + + diff --git a/frontend/src/crypto/backup.ts b/frontend/src/crypto/backup.ts new file mode 100644 index 0000000..da5d320 --- /dev/null +++ b/frontend/src/crypto/backup.ts @@ -0,0 +1,68 @@ +import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; +import { importPassword, deriveKEK, randomBytes } from "./kdf"; + +export interface PrivateKeyBundle { + version: 1; + privateKey: Uint8Array; // X25519 private key +} + +export interface EncryptedBackupBlob { + salt: Uint8Array; // for PBKDF2 derivation of KEK + iv: Uint8Array; // AES-GCM IV + ciphertext: Uint8Array; // encrypted serialized PrivateKeyBundle +} + +export function serializeBundle(bundle: PrivateKeyBundle): Uint8Array { + const header = new Uint8Array([bundle.version]); + const len = new Uint8Array(new Uint32Array([bundle.privateKey.length]).buffer); + const out = new Uint8Array(1 + 4 + bundle.privateKey.length); + out.set(header, 0); + out.set(len, 1); + out.set(bundle.privateKey, 5); + return out; +} + +export function deserializeBundle(data: Uint8Array): PrivateKeyBundle { + const version = data[0] as 1; + const len = new Uint32Array(data.slice(1, 5).buffer)[0]; + const pk = data.slice(5, 5 + len); + return { version, privateKey: pk }; +} + +export async function encryptBackupWithPassword(password: string, bundle: PrivateKeyBundle): Promise { + const salt = randomBytes(16); + const pw = await importPassword(password); + const kek = await deriveKEK(pw, salt); + const serialized = serializeBundle(bundle); + const { iv, ciphertext } = await aesGcmEncrypt(kek, serialized); + return { salt, iv, ciphertext }; +} + +export async function decryptBackupWithPassword(password: string, blob: EncryptedBackupBlob): Promise { + const pw = await importPassword(password); + const kek = await deriveKEK(pw, blob.salt); + const plaintext = await aesGcmDecrypt(kek, blob.iv, blob.ciphertext); + return deserializeBundle(plaintext); +} + +export function encodeBlob(blob: EncryptedBackupBlob): string { + function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); } + return JSON.stringify({ + salt: b64(blob.salt), + iv: b64(blob.iv), + ciphertext: b64(blob.ciphertext) + }); +} + +export function decodeBlob(json: string): EncryptedBackupBlob { + function ub64(s: string) { + 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; + } + const obj = JSON.parse(json); + return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; +} + + diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts new file mode 100644 index 0000000..cfd5e8d --- /dev/null +++ b/frontend/src/crypto/index.ts @@ -0,0 +1,4 @@ +export * from "./kdf"; +export * from "./symmetric"; +export * from "./asymmetric"; +export * from "./backup"; \ No newline at end of file diff --git a/frontend/src/crypto/kdf.ts b/frontend/src/crypto/kdf.ts new file mode 100644 index 0000000..c3c6912 --- /dev/null +++ b/frontend/src/crypto/kdf.ts @@ -0,0 +1,28 @@ +export async function importPassword(password: string): Promise { + const enc = new TextEncoder(); + return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); +} + +export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterations = 210_000): Promise { + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + passwordKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function hkdfExtractAndExpand(inputKeyMaterial: ArrayBuffer, salt: Uint8Array, info: Uint8Array, length = 32): Promise { + const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, ikmKey, length * 8); + return new Uint8Array(bits); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} + + diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts new file mode 100644 index 0000000..fc75380 --- /dev/null +++ b/frontend/src/crypto/symmetric.ts @@ -0,0 +1,19 @@ +export interface AesGcmCiphertext { + iv: Uint8Array; + ciphertext: Uint8Array; +} + +export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); + return { iv, ciphertext: new Uint8Array(ct) }; +} + +export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ciphertext: Uint8Array): Promise { + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new Uint8Array(pt); +} + +export async function importAesGcmKey(rawKey: Uint8Array): Promise { + return crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); +} \ No newline at end of file diff --git a/frontend/src/crypto/types.d.ts b/frontend/src/crypto/types.d.ts new file mode 100644 index 0000000..5a531a0 --- /dev/null +++ b/frontend/src/crypto/types.d.ts @@ -0,0 +1,6 @@ +declare module "tweetnacl" { + const nacl: any; + export default nacl; +} + + diff --git a/package.json b/package.json index 3d590b4..f06de8f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "vite-plugin-html": "^3.2.2" }, "dependencies": { - "mdui": "^2.1.4" + "mdui": "^2.1.4", + "tweetnacl": "^1.0.3" } } From 30b8544dc8b629cb43eac28aa07c13c6cac3b50d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 16:52:35 +0300 Subject: [PATCH 02/15] Implement key generation and restoration --- frontend/src/auth/auth.ts | 6 +++ frontend/src/auth/crypto.ts | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 frontend/src/auth/crypto.ts diff --git a/frontend/src/auth/auth.ts b/frontend/src/auth/auth.ts index 2aa83ba..3988c2d 100644 --- a/frontend/src/auth/auth.ts +++ b/frontend/src/auth/auth.ts @@ -10,6 +10,7 @@ import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from import { API_BASE_URL } from "../core/config"; import { loadChat, showLogin, showRegister } from "../navigation"; import { setUser } from "./api"; +import { ensureKeysOnLogin } from "./crypto"; import { id } from "../utils/utils"; /** @@ -71,6 +72,11 @@ async function handleLogin(e: Event): Promise { const data: LoginResponse = await response.json(); // Store the JWT token setUser(data.token, data.user) + try { + await ensureKeysOnLogin(password); + } catch (e) { + console.error("Key setup failed:", e); + } loadChat(); initializeProfile(); // Initialize profile after login } else { diff --git a/frontend/src/auth/crypto.ts b/frontend/src/auth/crypto.ts new file mode 100644 index 0000000..65687e1 --- /dev/null +++ b/frontend/src/auth/crypto.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from "../core/config"; +import { getAuthHeaders } from "./api"; +import { generateX25519KeyPair } from "../crypto/asymmetric"; +import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup"; + +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + +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; +} + +async function fetchPublicKey(): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +async function uploadPublicKey(publicKey: Uint8Array): Promise { + await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify({ publicKey: b64(publicKey) }) + }); +} + +async function fetchBackupBlob(): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", headers: getAuthHeaders(true) }); + if (!res.ok) return null; + const data = await res.json(); + return data?.blob ?? null; +} + +async function uploadBackupBlob(blobJson: string): Promise { + await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers: getAuthHeaders(true), + body: JSON.stringify({ blob: blobJson }) + }); +} + +export interface UserKeyPairMemory { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export function getCurrentKeys(): UserKeyPairMemory | null { + if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; + return null; +} + +export async function ensureKeysOnLogin(password: string): Promise { + // Try to restore from backup + const blobJson = await fetchBackupBlob(); + if (blobJson) { + const blob = decodeBlob(blobJson); + const bundle = await decryptBackupWithPassword(password, blob); + currentPrivateKey = bundle.privateKey; + // Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous + // In our simple scheme, we rely on server having the public key or we reupload generated one on first setup + const serverPub = await fetchPublicKey(); + if (serverPub) { + currentPublicKey = serverPub; + } else { + // We don't have the corresponding public key from server; regenerate pair to resync + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey); + const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(newBlob)); + } + return { publicKey: currentPublicKey!, privateKey: currentPrivateKey! }; + } + + // First-time setup: generate keys and upload + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey); + const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(encBlob)); + return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; +} + + From a5caf940a66e5ca4e244c132da92bcc81d51fb71 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:01:03 +0300 Subject: [PATCH 03/15] 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 From 22f7eb7a62f1e0622370a4725e50c74203e51176 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:10:36 +0300 Subject: [PATCH 04/15] Implement working DMs --- backend/routes/account.py | 18 +++++++++- frontend/index.html | 11 +----- frontend/src/chat/dm.ts | 74 ++++++++++++++++++++++++++++----------- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index 3f3a0fd..5b51f54 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -201,4 +201,20 @@ def logout( return { "status": "success", "message": "Logged out successfully" - } \ No newline at end of file + } + + +@router.get("/users") +def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + users = db.query(User).order_by(User.username.asc()).all() + return { + "users": [ + convert_user(u) for u in users if u.id != current_user.id + ] + } + + +@router.get("/crypto/public-key/of/{user_id}") +def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() + return {"publicKey": row.public_key_b64 if row else None} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 645c17b..606d2b5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -164,16 +164,7 @@ Скоро будет... Скоро будет... - - -
- - - Отправить -
-
-
-
+
diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 1ad0f77..a13e4b6 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -70,7 +70,7 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string } function appendDmMessage(text: string, isAuthor: boolean) { - const container = document.getElementById("dm-messages")!; + const container = document.getElementById("chat-messages")!; const div = document.createElement("div"); div.className = `message ${isAuthor ? "sent" : "received"}`; const inner = document.createElement("div"); @@ -84,30 +84,64 @@ function appendDmMessage(text: string, isAuthor: boolean) { 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; +// removed unused helper + +let activeDm: { userId: number; username: string; publicKey: string | null } | null = null; +let usersLoaded = false; + +async function loadUsers() { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) }); + if (!res.ok) return; 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 }; + const list = document.getElementById("dm-users")!; + list.innerHTML = ""; + (data.users || []).forEach((u: any) => { + const item = document.createElement("mdui-list-item"); + item.setAttribute("headline", u.username); + item.addEventListener("click", async () => { + activeDm = { userId: u.id, username: u.username, publicKey: null }; + document.getElementById("chat-name")!.textContent = u.username; + (document.getElementById("chat-messages") as HTMLElement).innerHTML = ""; + const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) }); + if (resPk.ok) { + const pkData = await resPk.json(); + activeDm!.publicKey = pkData.publicKey; + } + }); + list.appendChild(item); + }); } 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 = ""; + const tabs = document.querySelector(".chat-tabs mdui-tabs"); + const dmTab = tabs?.querySelector('mdui-tab[value="dms"]'); + function ensureUsersLoaded() { + if (!usersLoaded) { + usersLoaded = true; + loadUsers(); + } + } + dmTab?.addEventListener("click", ensureUsersLoaded); + (tabs as any)?.addEventListener("change", (e: any) => { + if (e?.detail?.value === "dms") ensureUsersLoaded(); }); + const form = document.getElementById("message-form"); + if (form) { + form.addEventListener("submit", async (e) => { + if (!activeDm) return; // Let global chat handler proceed + e.preventDefault(); + const input = document.getElementById("message-input") as HTMLInputElement; + const text = input.value.trim(); + if (!text) return; + appendDmMessage(text, true); + input.value = ""; + if (activeDm.publicKey) { + try { + await sendDm(activeDm.userId, activeDm.publicKey, text); + } catch {} + } + }); + } }); From eda9de699eafdca0e228796d3c14c387546c2ff2 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:11:56 +0300 Subject: [PATCH 05/15] Fix the UI --- frontend/src/chat/chat.ts | 15 ++-- frontend/src/chat/dm.ts | 72 +++++++----------- frontend/src/chat/panel.ts | 109 ++++++++++++++++++++++++++++ frontend/src/main.ts | 1 + frontend/src/userPanel/userpanel.ts | 3 + 5 files changed, 149 insertions(+), 51 deletions(-) create mode 100644 frontend/src/chat/panel.ts diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts index 120328d..a7c92c9 100644 --- a/frontend/src/chat/chat.ts +++ b/frontend/src/chat/chat.ts @@ -13,6 +13,8 @@ import { show as showContextMenu } from "./contextMenu"; import { show as showUserProfileDialog } from "./profileDialog"; import defaultAvatar from "../resources/images/default-avatar.png"; import { authToken, currentUser, getAuthHeaders } from "../auth/api"; +import { ChatPanelController, PublicChatPanel } from "./panel"; +import type { Tabs } from "mdui/components/tabs"; /** * Adds a new message to the chat interface @@ -142,6 +144,7 @@ export function loadMessages(): void { // Добавляем только новые сообщения data.messages.forEach(msg => { + console.log(msg); if (msg.id > lastMessageId) { addMessage(msg, msg.username == currentUser!.username); } @@ -184,12 +187,6 @@ export function sendMessage(): void { } } - -document.getElementById('message-form')!.addEventListener('submit', (e) => { - e.preventDefault(); - sendMessage(); -}); - /** * Updates an existing message in the chat interface * @param {Message} message - Updated message object @@ -248,4 +245,8 @@ export function handleWebSocketMessage(response: WebSocketMessage): void { } break; } -} \ No newline at end of file +} + +export const publicChatPanel = new PublicChatPanel(); + +publicChatPanel.activate(); \ No newline at end of file diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index a13e4b6..4b8dd96 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -1,9 +1,11 @@ import { API_BASE_URL } from "../core/config"; import { getAuthHeaders } from "../auth/api"; +import { DmPanel, ChatPanelController } from "./panel"; import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; +import type { Tabs } from "mdui/components/tabs"; function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } function ub64(s: string): Uint8Array { @@ -69,25 +71,9 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string return new TextDecoder().decode(msg); } -function appendDmMessage(text: string, isAuthor: boolean) { - const container = document.getElementById("chat-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; -} - -// removed unused helper - let activeDm: { userId: number; username: string; publicKey: string | null } | null = null; let usersLoaded = false; +let dmPanel: DmPanel | null = null; async function loadUsers() { const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) }); @@ -100,8 +86,21 @@ async function loadUsers() { item.setAttribute("headline", u.username); item.addEventListener("click", async () => { activeDm = { userId: u.id, username: u.username, publicKey: null }; - document.getElementById("chat-name")!.textContent = u.username; - (document.getElementById("chat-messages") as HTMLElement).innerHTML = ""; + if (!dmPanel) { + dmPanel = new DmPanel( + async (text: string) => { + if (activeDm?.publicKey) { + try { await sendDm(activeDm.userId, activeDm.publicKey, text); } catch {} + } + }, + () => { + // For v1, no DM history yet; just clear + } + ); + } + dmPanel.setTitle(u.username); + dmPanel.clearMessages(); + dmPanel.activate(); const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) }); if (resPk.ok) { const pkData = await resPk.json(); @@ -112,36 +111,21 @@ async function loadUsers() { }); } -document.addEventListener("DOMContentLoaded", () => { - const tabs = document.querySelector(".chat-tabs mdui-tabs"); - const dmTab = tabs?.querySelector('mdui-tab[value="dms"]'); +function init() { + const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs; + const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!; function ensureUsersLoaded() { if (!usersLoaded) { usersLoaded = true; loadUsers(); } } - dmTab?.addEventListener("click", ensureUsersLoaded); - (tabs as any)?.addEventListener("change", (e: any) => { - if (e?.detail?.value === "dms") ensureUsersLoaded(); + dmTab.addEventListener("click", ensureUsersLoaded); + tabs.addEventListener("change", (e: any) => { + if (e.detail?.value === "dms") { + ensureUsersLoaded(); + } }); - const form = document.getElementById("message-form"); - if (form) { - form.addEventListener("submit", async (e) => { - if (!activeDm) return; // Let global chat handler proceed - e.preventDefault(); - const input = document.getElementById("message-input") as HTMLInputElement; - const text = input.value.trim(); - if (!text) return; - appendDmMessage(text, true); - input.value = ""; - if (activeDm.publicKey) { - try { - await sendDm(activeDm.userId, activeDm.publicKey, text); - } catch {} - } - }); - } -}); - +} +init(); \ No newline at end of file diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts new file mode 100644 index 0000000..6abd786 --- /dev/null +++ b/frontend/src/chat/panel.ts @@ -0,0 +1,109 @@ +import { authToken, currentUser } from "../auth/api"; +import type { WebSocketMessage } from "../core/types"; +import { websocket } from "../websocket"; +import { loadMessages } from "./chat"; + +const titleEl = document.getElementById("chat-name")!; +const messages = document.getElementById("chat-messages")!; +const input = document.getElementById("message-input") as HTMLInputElement; +const form = document.getElementById("message-form") as HTMLFormElement; + +export abstract class ChatPanelController { + static active: ChatPanelController | null = null; + static mounted = false; + + activate(): void { + ChatPanelController.active = this; + if (currentUser) { + this.loadMessages(); + } + } + + setTitle(title: string): void { + titleEl.textContent = title; + } + + clearMessages(): void { + messages.innerHTML = ""; + } + + appendSimple(text: string, isAuthor: boolean): void { + 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); + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; + } + + protected abstract onSubmit(text: string): void | Promise; + protected abstract loadMessages(): void | Promise; + + static mountOnce(): void { + if (this.mounted) return; + this.mounted = true; + if (!form) return; + form.addEventListener( + "submit", + (e) => { + if (!ChatPanelController.active) return; // let others handle + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const text = input.value.trim(); + if (!text) return; + Promise.resolve(ChatPanelController.active.onSubmit(text)).finally(() => { + input.value = ""; + }); + }, + true + ); + } +} + +export class PublicChatPanel extends ChatPanelController { + protected async onSubmit(text: string): Promise { + const payload: WebSocketMessage = { + data: { content: text }, + credentials: { scheme: "Bearer", credentials: authToken! }, + type: "sendMessage" + }; + await new Promise((resolve) => { + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + resolve(); + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + }); + } + + protected loadMessages(): void { + return loadMessages(); + } +} + +export class DmPanel extends ChatPanelController { + private sender: (text: string) => Promise; + private loader: () => Promise | void; + constructor(sender: (text: string) => Promise, loader: () => Promise | void) { + super(); + this.sender = sender; + this.loader = loader; + } + protected async onSubmit(text: string): Promise { + this.appendSimple(text, true); + await this.sender(text); + } + protected loadMessages(): void | Promise { + return this.loader(); + } +} + +ChatPanelController.mountOnce(); \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 408fbb1..b3c1090 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -17,4 +17,5 @@ import "./userPanel/profile/profile"; import "./chat/contextMenu"; import "./chat/profileDialog"; import "./electron/electron"; +import "./chat/panel"; import "./chat/dm"; \ No newline at end of file diff --git a/frontend/src/userPanel/userpanel.ts b/frontend/src/userPanel/userpanel.ts index 8ecec8a..1ec52bd 100644 --- a/frontend/src/userPanel/userpanel.ts +++ b/frontend/src/userPanel/userpanel.ts @@ -8,6 +8,7 @@ import { Dialog } from "mdui/components/dialog"; import { loadProfilePicture } from "./profile/upload"; import { id } from "../utils/utils"; +import { publicChatPanel } from "../chat/chat"; // сварачивание и разворачивание чата const chatCollapseBtn = id('hide-chat')!; @@ -74,12 +75,14 @@ function setupChatSwitching(): void { chat1.addEventListener('click', () => { animateChatSwitch(() => { chatName.textContent = 'Общий чат'; + publicChatPanel.activate(); }); }); chat2.addEventListener('click', () => { animateChatSwitch(() => { chatName.textContent = 'Общий чат 2'; + publicChatPanel.activate(); }); }); } From 8acb2dd50777d724de90239a0e77ee6b68bd90e1 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:40:47 +0300 Subject: [PATCH 06/15] Implement real-time WebSocket --- backend/routes/messaging.py | 80 +++++++++++++++++++++++++++++++++++++ frontend/src/chat/dm.ts | 53 ++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 57fc406..f009f03 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -114,6 +114,36 @@ async def dm_fetch(since: int | None = None, current_user: User = Depends(get_cu } +@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 = ( + db.query(DMEnvelope) + .filter( + ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) + | ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id)) + ) + .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, @@ -193,6 +223,7 @@ async def reply_message( class MessaggingSocketManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] + self.user_by_ws: dict[WebSocket, int] = {} async def send_error(self, websocket: WebSocket, type: str, e: HTTPException): await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}}) @@ -221,6 +252,7 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: @@ -230,6 +262,7 @@ class MessaggingSocketManager: current_user = get_current_user_inner() if not current_user: raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) @@ -242,6 +275,46 @@ class MessaggingSocketManager: await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "dmSend": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + self.user_by_ws[websocket] = current_user.id + payload = data["data"] + 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) + await self.send_to_user(env.recipient_id, { + "type": "dmNew", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "salt": env.salt_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "timestamp": env.timestamp.isoformat(), + } + }) + 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 == "editMessage": try: current_user = get_current_user_inner() @@ -310,11 +383,18 @@ class MessaggingSocketManager: logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") finally: self.connections.remove(websocket) + if websocket in self.user_by_ws: + del self.user_by_ws[websocket] async def broadcast(self, message: dict): for websocket in self.connections: await websocket.send_json(message) + async def send_to_user(self, user_id: int, message: dict): + for websocket in self.connections: + if self.user_by_ws.get(websocket) == user_id: + await websocket.send_json(message) + messagingManager = MessaggingSocketManager() @router.websocket("/chat/ws") diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 4b8dd96..6d3e720 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -5,6 +5,8 @@ import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; +import { websocket } from "../websocket"; +import type { WebSocketMessage } from "../core/types"; import type { Tabs } from "mdui/components/tabs"; function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } @@ -90,11 +92,33 @@ async function loadUsers() { dmPanel = new DmPanel( async (text: string) => { if (activeDm?.publicKey) { - try { await sendDm(activeDm.userId, activeDm.publicKey, text); } catch {} + // WebSocket realtime send + const keys = getCurrentKeys(); + if (!keys) return; + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text)); + const wrap = await aesGcmEncrypt(wk, mk); + const payload: WebSocketMessage = { + type: "dmSend", + credentials: { scheme: "Bearer", credentials: (await import("../auth/api")).authToken! }, + data: { + recipientId: activeDm.userId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } + }; + websocket.send(JSON.stringify(payload)); } }, () => { - // For v1, no DM history yet; just clear + // For v1, load history for this DM } ); } @@ -124,8 +148,31 @@ function init() { tabs.addEventListener("change", (e: any) => { if (e.detail?.value === "dms") { ensureUsersLoaded(); + dmPanel?.activate(); } }); } -init(); \ No newline at end of file +init(); + +// realtime incoming DMs +websocket.addEventListener("message", async (e) => { + try { + const msg = JSON.parse((e as MessageEvent).data); + if (msg?.type === "dmNew" && activeDm && msg.data.senderId === activeDm.userId) { + const plaintext = await decryptDm(msg.data, activeDm.publicKey!); + const container = document.getElementById("chat-messages")!; + const div = document.createElement("div"); + div.className = "message received"; + const inner = document.createElement("div"); + inner.className = "message-inner"; + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = plaintext; + inner.appendChild(content); + div.appendChild(inner); + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + } catch {} +}); \ No newline at end of file From bddf34de2d03f1fab89141e898e8d476ff268bda Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 17:47:36 +0300 Subject: [PATCH 07/15] Working DMs --- backend/routes/messaging.py | 6 ++++++ frontend/src/chat/dm.ts | 33 +++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index f009f03..79a3ef3 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -246,6 +246,12 @@ class MessaggingSocketManager: return None if type == "ping": + try: + current_user = get_current_user_inner() + if current_user: + self.user_by_ws[websocket] = current_user.id + except HTTPException: + pass await websocket.send_json({"type": "ping", "data": {"status": "success"}}) elif type == "getMessages": try: diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 6d3e720..9e67c46 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -117,8 +117,32 @@ async function loadUsers() { websocket.send(JSON.stringify(payload)); } }, - () => { - // For v1, load history for this DM + async () => { + // Load DM history for the active conversation + if (!activeDm?.publicKey) return; + const res = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { headers: getAuthHeaders(true) }); + if (!res.ok) return; + const data = await res.json(); + const messages: DmEnvelope[] = data.messages || []; + const container = document.getElementById("chat-messages")!; + container.innerHTML = ""; + for (const env of messages) { + try { + const text = await decryptDm(env, activeDm.publicKey); + const div = document.createElement("div"); + const isAuthor = env.senderId !== activeDm.userId; + 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); + } catch {} + } + container.scrollTop = container.scrollHeight; } ); } @@ -159,11 +183,12 @@ init(); websocket.addEventListener("message", async (e) => { try { const msg = JSON.parse((e as MessageEvent).data); - if (msg?.type === "dmNew" && activeDm && msg.data.senderId === activeDm.userId) { + if (msg?.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { const plaintext = await decryptDm(msg.data, activeDm.publicKey!); const container = document.getElementById("chat-messages")!; const div = document.createElement("div"); - div.className = "message received"; + const isAuthor = msg.data.senderId !== activeDm.userId; + div.className = `message ${isAuthor ? "sent" : "received"}`; const inner = document.createElement("div"); inner.className = "message-inner"; const content = document.createElement("div"); From 2151a0b4a1cc389903c23345374115a99c17ec20 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 18:16:47 +0300 Subject: [PATCH 08/15] Refactor the code --- frontend/src/auth/api.ts | 18 ++++- frontend/src/auth/crypto.ts | 34 ++++---- frontend/src/chat/chat.ts | 25 ++---- frontend/src/chat/contextMenu.ts | 84 ++++++++------------ frontend/src/chat/dm.ts | 124 +++++++++++++++++------------- frontend/src/chat/panel.ts | 12 +-- frontend/src/core/types.d.ts | 23 ++++++ frontend/src/crypto/asymmetric.ts | 6 +- frontend/src/crypto/kdf.ts | 19 +++-- frontend/src/crypto/symmetric.ts | 16 ++-- frontend/src/crypto/types.d.ts | 4 +- frontend/src/utils/utils.ts | 9 +++ frontend/src/websocket.ts | 15 ++++ frontend/tsconfig.json | 1 + 14 files changed, 220 insertions(+), 170 deletions(-) diff --git a/frontend/src/auth/api.ts b/frontend/src/auth/api.ts index 692c3b5..2855fb4 100644 --- a/frontend/src/auth/api.ts +++ b/frontend/src/auth/api.ts @@ -1,7 +1,8 @@ import { API_BASE_URL } from "../core/config"; import { showLogin } from "../navigation"; -import type { Headers, User } from "../core/types"; +import type { Headers, User, WebSocketMessage } from "../core/types"; import { clearAlerts } from "./auth"; +import { request } from "../websocket"; /** * Current authenticated user information @@ -23,6 +24,21 @@ export let authToken: string | null = null; export function setUser(token: string, user: User) { authToken = token currentUser = user + + try { + const payload: WebSocketMessage = { + type: "ping", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: {} + } + + request(payload).then(() => { + console.log("Ping succeeded") + }) + } catch {} } /** diff --git a/frontend/src/auth/crypto.ts b/frontend/src/auth/crypto.ts index 65687e1..902cf7b 100644 --- a/frontend/src/auth/crypto.ts +++ b/frontend/src/auth/crypto.ts @@ -2,18 +2,12 @@ import { API_BASE_URL } from "../core/config"; import { getAuthHeaders } from "./api"; import { generateX25519KeyPair } from "../crypto/asymmetric"; import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup"; +import { b64, ub64 } from "../utils/utils"; +import type { BackupBlob, UploadPublicKeyRequest } from "../core/types"; let currentPublicKey: Uint8Array | null = null; let currentPrivateKey: Uint8Array | null = null; -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; -} - async function fetchPublicKey(): Promise { const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) }); if (!res.ok) return null; @@ -23,25 +17,37 @@ async function fetchPublicKey(): Promise { } async function uploadPublicKey(publicKey: Uint8Array): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "POST", headers: getAuthHeaders(true), - body: JSON.stringify({ publicKey: b64(publicKey) }) + body: JSON.stringify(payload) }); } async function fetchBackupBlob(): Promise { - const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", headers: getAuthHeaders(true) }); - if (!res.ok) return null; - const data = await res.json(); - return data?.blob ?? null; + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers: getAuthHeaders(true) + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } } async function uploadBackupBlob(blobJson: string): Promise { + const payload: BackupBlob = { blob: blobJson } + await fetch(`${API_BASE_URL}/crypto/backup`, { method: "POST", headers: getAuthHeaders(true), - body: JSON.stringify({ blob: blobJson }) + body: JSON.stringify(payload) }); } diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts index a7c92c9..914362b 100644 --- a/frontend/src/chat/chat.ts +++ b/frontend/src/chat/chat.ts @@ -6,15 +6,14 @@ */ import { API_BASE_URL } from "../core/config"; -import { websocket } from "../websocket"; +import { request } from "../websocket"; import type { Message, Messages, WebSocketMessage } from "../core/types"; import { formatTime } from "../utils/utils"; import { show as showContextMenu } from "./contextMenu"; import { show as showUserProfileDialog } from "./profileDialog"; import defaultAvatar from "../resources/images/default-avatar.png"; import { authToken, currentUser, getAuthHeaders } from "../auth/api"; -import { ChatPanelController, PublicChatPanel } from "./panel"; -import type { Tabs } from "mdui/components/tabs"; +import { PublicChatPanel } from "./panel"; /** * Adds a new message to the chat interface @@ -156,12 +155,12 @@ export function loadMessages(): void { /** * Sends a message via WebSocket */ -export function sendMessage(): void { +export async function sendMessage(): Promise { const input = document.querySelector('.message-input') as HTMLInputElement; const message = input.value.trim(); if (message) { - const payload: WebSocketMessage = { + const response = await request({ data: { content: message }, @@ -170,20 +169,12 @@ export function sendMessage(): void { credentials: authToken! }, type: "sendMessage" - } + }) - let callback: ((e: MessageEvent) => void) | null = null - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data) - console.log(response) - if (!response.error) { - input.value = ""; - } + console.log(response) + if (!response.error) { + input.value = ""; } - websocket.addEventListener("message", callback); - - websocket.send(JSON.stringify(payload)); } } diff --git a/frontend/src/chat/contextMenu.ts b/frontend/src/chat/contextMenu.ts index 92f2056..579cbba 100644 --- a/frontend/src/chat/contextMenu.ts +++ b/frontend/src/chat/contextMenu.ts @@ -5,8 +5,8 @@ * @version 1.0.0 */ -import { websocket } from "../websocket"; -import type { Message, WebSocketMessage } from "../core/types"; +import { request } from "../websocket"; +import type { Message } from "../core/types"; import { showSuccess, showError } from "../utils/notification"; import { delay, id } from "../utils/utils"; import type { Dialog } from "mdui/components/dialog"; @@ -184,7 +184,7 @@ function hideEditDialog(): void { * Saves the edited message * @private */ -function saveEdit(): void { +async function saveEdit(): Promise { if (!currentMessage) return; const textField = editDialog.querySelector('#edit-message-input') as TextField; @@ -195,7 +195,7 @@ function saveEdit(): void { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "editMessage", data: { message_id: currentMessage.id, @@ -205,22 +205,14 @@ function saveEdit(): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message edited successfully'); - hideEditDialog(); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + hideEditDialog(); + } } /** @@ -257,7 +249,7 @@ function hideReplyDialog(): void { * Sends the reply message * @private */ -function sendReply(): void { +async function sendReply(): Promise { if (!currentMessage) return; const textField = replyDialog.querySelector('#reply-message-input') as TextField; @@ -268,7 +260,7 @@ function sendReply(): void { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "replyMessage", data: { content: content, @@ -278,25 +270,17 @@ function sendReply(): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Reply sent successfully'); - hideReplyDialog(); - if (textField) { - textField.value = ''; - } + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + hideReplyDialog(); + if (textField) { + textField.value = ''; } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + } } /** @@ -304,12 +288,12 @@ function sendReply(): void { * @param {Message} message - The message to delete * @private */ -function deleteMessage(message: Message): void { +async function deleteMessage(message: Message): Promise { if (!confirm('Are you sure you want to delete this message?')) { return; } - const payload: WebSocketMessage = { + const response = await request({ type: "deleteMessage", data: { message_id: message.id @@ -318,21 +302,13 @@ function deleteMessage(message: Message): void { scheme: "Bearer", credentials: authToken! } - }; + }); - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - const response: WebSocketMessage = JSON.parse(e.data); - - if (response.error) { - showError(response.error.detail); - } else { - showSuccess('Message deleted successfully'); - } - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } } init(); \ No newline at end of file diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 9e67c46..ebf3f86 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -1,21 +1,14 @@ import { API_BASE_URL } from "../core/config"; -import { getAuthHeaders } from "../auth/api"; -import { DmPanel, ChatPanelController } from "./panel"; +import { authToken, getAuthHeaders } from "../auth/api"; +import { DmPanel } from "./panel"; import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; -import { websocket } from "../websocket"; -import type { WebSocketMessage } from "../core/types"; +import { request, websocket } from "../websocket"; +import type { FetchDMResponse, SendDMRequest, WebSocketMessage } from "../core/types"; import type { Tabs } from "mdui/components/tabs"; - -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; -} +import { b64, ub64 } from "../utils/utils"; export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise { const keys = getCurrentKeys(); @@ -27,11 +20,12 @@ export async function sendDm(recipientId: number, recipientPublicKeyB64: string, 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, + recipientId: recipientId, iv: b64(encMsg.iv), ciphertext: b64(encMsg.ciphertext), salt: b64(wkSalt), @@ -56,19 +50,30 @@ export interface DmEnvelope { 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 ?? []; + + const response = await fetch(url, { + headers: getAuthHeaders(true) + }); + + if (response.ok) { + const data: FetchDMResponse = await response.json(); + return data.messages ?? []; + } else { + return []; + } } export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key 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)); + + // Decrypt const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); return new TextDecoder().decode(msg); } @@ -95,54 +100,66 @@ async function loadUsers() { // WebSocket realtime send const keys = getCurrentKeys(); if (!keys) return; + + // Encryption key const mk = randomBytes(32); const wkSalt = randomBytes(16); const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey)); const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text)); const wrap = await aesGcmEncrypt(wk, mk); - const payload: WebSocketMessage = { + + const payload: SendDMRequest = { + recipientId: activeDm.userId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } + + request({ type: "dmSend", - credentials: { scheme: "Bearer", credentials: (await import("../auth/api")).authToken! }, - data: { - recipientId: activeDm.userId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) - } - }; - websocket.send(JSON.stringify(payload)); + credentials: { + scheme: "Bearer", + credentials: authToken! + }, + data: payload + }); } }, async () => { // Load DM history for the active conversation if (!activeDm?.publicKey) return; - const res = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { headers: getAuthHeaders(true) }); - if (!res.ok) return; - const data = await res.json(); - const messages: DmEnvelope[] = data.messages || []; - const container = document.getElementById("chat-messages")!; - container.innerHTML = ""; - for (const env of messages) { - try { - const text = await decryptDm(env, activeDm.publicKey); - const div = document.createElement("div"); - const isAuthor = env.senderId !== activeDm.userId; - 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); - } catch {} + const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { + headers: getAuthHeaders(true) + }); + if (response.ok) { + const data = await response.json(); + const messages: DmEnvelope[] = data.messages || []; + const container = document.getElementById("chat-messages")!; + container.innerHTML = ""; + for (const env of messages) { + try { + const text = await decryptDm(env, activeDm.publicKey); + const div = document.createElement("div"); + const isAuthor = env.senderId !== activeDm.userId; + 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); + } catch {} + } + container.scrollTop = container.scrollHeight; } - container.scrollTop = container.scrollHeight; } ); } @@ -182,9 +199,10 @@ init(); // realtime incoming DMs websocket.addEventListener("message", async (e) => { try { - const msg = JSON.parse((e as MessageEvent).data); - if (msg?.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { + const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data); + if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { const plaintext = await decryptDm(msg.data, activeDm.publicKey!); + const container = document.getElementById("chat-messages")!; const div = document.createElement("div"); const isAuthor = msg.data.senderId !== activeDm.userId; diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts index 6abd786..9a99522 100644 --- a/frontend/src/chat/panel.ts +++ b/frontend/src/chat/panel.ts @@ -1,6 +1,6 @@ import { authToken, currentUser } from "../auth/api"; import type { WebSocketMessage } from "../core/types"; -import { websocket } from "../websocket"; +import { request } from "../websocket"; import { loadMessages } from "./chat"; const titleEl = document.getElementById("chat-name")!; @@ -73,15 +73,7 @@ export class PublicChatPanel extends ChatPanelController { credentials: { scheme: "Bearer", credentials: authToken! }, type: "sendMessage" }; - await new Promise((resolve) => { - let callback: ((e: MessageEvent) => void) | null = null; - callback = (e) => { - websocket.removeEventListener("message", callback!); - resolve(); - }; - websocket.addEventListener("message", callback); - websocket.send(JSON.stringify(payload)); - }); + await request(payload); } protected loadMessages(): void { diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 4447a1d..c76c51b 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -5,6 +5,8 @@ * @version 1.0.0 */ +import type { DmEnvelope } from "../chat/dm"; + /** * HTTP headers object type * @typedef {Object.} Headers @@ -136,6 +138,19 @@ export interface RegisterRequest { confirm_password: string; } +export interface UploadPublicKeyRequest { + publicKey: string; +} + +export interface SendDMRequest { + recipientId: number; + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedMk: string; +} + // Responses /** @@ -149,6 +164,14 @@ export interface LoginResponse { token: string; } +export interface BackupBlob { + blob: string; +} + +export interface FetchDMResponse { + messages: DmEnvelope[] +} + // --------------- // WebSocket types // --------------- diff --git a/frontend/src/crypto/asymmetric.ts b/frontend/src/crypto/asymmetric.ts index 2aca8f5..a086efa 100644 --- a/frontend/src/crypto/asymmetric.ts +++ b/frontend/src/crypto/asymmetric.ts @@ -17,7 +17,5 @@ export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8 } export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { - return hkdfExtractAndExpand(sharedSecret.buffer, salt, info, 32); -} - - + return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); +} \ No newline at end of file diff --git a/frontend/src/crypto/kdf.ts b/frontend/src/crypto/kdf.ts index c3c6912..133d90c 100644 --- a/frontend/src/crypto/kdf.ts +++ b/frontend/src/crypto/kdf.ts @@ -3,9 +3,10 @@ export async function importPassword(password: string): Promise { return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); } -export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterations = 210_000): Promise { +export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise { + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; return crypto.subtle.deriveKey( - { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + { name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" }, passwordKey, { name: "AES-GCM", length: 256 }, false, @@ -13,9 +14,13 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterat ); } -export async function hkdfExtractAndExpand(inputKeyMaterial: ArrayBuffer, salt: Uint8Array, info: Uint8Array, length = 32): Promise { - const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, { name: "HKDF" }, false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, ikmKey, length * 8); +export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { + const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; + const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; + + const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); return new Uint8Array(bits); } @@ -23,6 +28,4 @@ export function randomBytes(length: number): Uint8Array { const out = new Uint8Array(length); crypto.getRandomValues(out); return out; -} - - +} \ No newline at end of file diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts index fc75380..f0b1780 100644 --- a/frontend/src/crypto/symmetric.ts +++ b/frontend/src/crypto/symmetric.ts @@ -3,17 +3,21 @@ export interface AesGcmCiphertext { ciphertext: Uint8Array; } -export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array): Promise { +export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); + const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext; + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer); return { iv, ciphertext: new Uint8Array(ct) }; } -export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ciphertext: Uint8Array): Promise { - const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); +export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { + const ivBuffer = iv instanceof Uint8Array ? iv.buffer as ArrayBuffer : iv; + const ciphertextBuffer = ciphertext instanceof Uint8Array ? ciphertext.buffer as ArrayBuffer : ciphertext; + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer }, key, ciphertextBuffer); return new Uint8Array(pt); } -export async function importAesGcmKey(rawKey: Uint8Array): Promise { - return crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); +export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise { + const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey; + return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); } \ No newline at end of file diff --git a/frontend/src/crypto/types.d.ts b/frontend/src/crypto/types.d.ts index 5a531a0..13bbec3 100644 --- a/frontend/src/crypto/types.d.ts +++ b/frontend/src/crypto/types.d.ts @@ -1,6 +1,4 @@ declare module "tweetnacl" { const nacl: any; export default nacl; -} - - +} \ No newline at end of file diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 184c0ef..b3b10de 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -32,6 +32,15 @@ export function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } + +export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); } +export 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 function id(id: string): T { return document.getElementById(id) as unknown as T } \ No newline at end of file diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 28b0199..afd28a2 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -7,6 +7,7 @@ import { handleWebSocketMessage } from "./chat/chat"; import { API_WS_BASE_URL } from "./core/config"; +import type { WebSocketMessage } from "./core/types"; import { delay } from "./utils/utils"; /** @@ -29,6 +30,20 @@ function create(): WebSocket { */ export let websocket: WebSocket = create(); +export function request(payload: WebSocketMessage): Promise { + return new Promise((resolve, reject) => { + let listener: ((e: MessageEvent) => void) | null = null; + listener = (e) => { + resolve(JSON.parse(e.data)); + websocket.removeEventListener("message", listener!); + } + websocket.addEventListener("message", listener); + websocket.send(JSON.stringify(payload)) + + setTimeout(() => reject("Request timed out"), 10000); + }) +} + /** * This function will wait 3 seconds and them attempts to reconnect the WebSocket. * If it fails, tries again in an endless loop until the connection is established diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 28db92d..cd5be70 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,6 +14,7 @@ "noEmit": true, /* Linting */ + "strict": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true From 5b301f0707c28790e69c77402fc63756d8fc8a65 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 25 Aug 2025 22:10:50 +0300 Subject: [PATCH 09/15] Improve DMs UI --- frontend/index.html | 6 ++ frontend/src/chat/contextMenu.ts | 11 ++- frontend/src/chat/dm.ts | 123 ++++++++++++++++++++++------- frontend/src/chat/panel.ts | 51 +++++++++++- frontend/src/chat/profileDialog.ts | 37 ++++++++- frontend/src/core/types.d.ts | 1 + 6 files changed, 198 insertions(+), 31 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 606d2b5..63d5d66 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -417,6 +417,12 @@ +
+ +
diff --git a/frontend/src/chat/contextMenu.ts b/frontend/src/chat/contextMenu.ts index 579cbba..7c960b1 100644 --- a/frontend/src/chat/contextMenu.ts +++ b/frontend/src/chat/contextMenu.ts @@ -84,9 +84,16 @@ export function show(message: Message, x: number, y: number): void { const isAuthor = message.username === currentUser?.username; const isOwner = currentUser?.admin; - + + // Check if we're in a DM conversation + const isInDm = document.querySelector('.chat-tabs mdui-tab[value="dms"]')?.getAttribute('active') === 'true'; + + // Show edit only for own messages editItem.style.display = isAuthor ? 'flex' : 'none'; - deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none'; + + // Show delete for own messages, admin on any message, or in DMs for any message + const canDelete = isAuthor || isOwner || isInDm; + deleteItem.style.display = canDelete ? 'flex' : 'none'; // Position the menu properly menu.style.display = 'block'; diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index ebf3f86..7b0e1d0 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -1,12 +1,12 @@ import { API_BASE_URL } from "../core/config"; -import { authToken, getAuthHeaders } from "../auth/api"; +import { authToken, getAuthHeaders, currentUser } from "../auth/api"; import { DmPanel } from "./panel"; import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request, websocket } from "../websocket"; -import type { FetchDMResponse, SendDMRequest, WebSocketMessage } from "../core/types"; +import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User } from "../core/types"; import type { Tabs } from "mdui/components/tabs"; import { b64, ub64 } from "../utils/utils"; @@ -88,9 +88,48 @@ async function loadUsers() { const data = await res.json(); const list = document.getElementById("dm-users")!; list.innerHTML = ""; - (data.users || []).forEach((u: any) => { + (data.users || []).forEach((u: User) => { const item = document.createElement("mdui-list-item"); + + // Add avatar + const avatar = document.createElement("img"); + avatar.src = u.profile_picture || "./src/resources/images/default-avatar.png"; + avatar.alt = u.username; + avatar.slot = "icon"; + avatar.style.width = "40px"; + avatar.style.height = "40px"; + avatar.style.borderRadius = "50%"; + avatar.style.objectFit = "cover"; + + // Handle avatar load error + avatar.addEventListener("error", () => { + avatar.src = "./src/resources/images/default-avatar.png"; + }); + + item.appendChild(avatar); + + // Set headline (username) item.setAttribute("headline", u.username); + + // Add last message placeholder (will be loaded lazily) + const lastMessageEl = document.createElement("div"); + lastMessageEl.slot = "supporting-text"; + lastMessageEl.textContent = "Loading..."; + lastMessageEl.style.fontSize = "12px"; + lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)"; + item.appendChild(lastMessageEl); + + // Load last message when element becomes visible + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadLastMessage(u.id, lastMessageEl); + observer.unobserve(entry.target); + } + }); + }); + observer.observe(item); + item.addEventListener("click", async () => { activeDm = { userId: u.id, username: u.username, publicKey: null }; if (!dmPanel) { @@ -133,7 +172,7 @@ async function loadUsers() { }, async () => { // Load DM history for the active conversation - if (!activeDm?.publicKey) return; + if (!activeDm?.publicKey || !dmPanel) return; const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { headers: getAuthHeaders(true) }); @@ -145,17 +184,16 @@ async function loadUsers() { for (const env of messages) { try { const text = await decryptDm(env, activeDm.publicKey); - const div = document.createElement("div"); const isAuthor = env.senderId !== activeDm.userId; - 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); + const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"); + dmPanel.appendMessageWithId({ + id: env.id, + content: text, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false + }); } catch {} } container.scrollTop = container.scrollHeight; @@ -163,9 +201,18 @@ async function loadUsers() { } ); } + dmPanel.setOtherUser(u.username); dmPanel.setTitle(u.username); dmPanel.clearMessages(); dmPanel.activate(); + + // Add profile click functionality to chat header + const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement; + if (chatHeaderAvatar) { + chatHeaderAvatar.style.cursor = 'pointer'; + chatHeaderAvatar.onclick = () => dmPanel?.onProfileClicked(); + } + const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) }); if (resPk.ok) { const pkData = await resPk.json(); @@ -176,6 +223,30 @@ async function loadUsers() { }); } +async function loadLastMessage(userId: number, element: HTMLElement): Promise { + try { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=1`, { + headers: getAuthHeaders(true) + }); + if (response.ok) { + const data = await response.json(); + const messages: DmEnvelope[] = data.messages || []; + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + // For now, just show "Last message" since we can't decrypt without the public key + // In a real implementation, you'd need to store the public key or decrypt here + element.textContent = "Last message"; + } else { + element.textContent = "No messages yet"; + } + } else { + element.textContent = "No messages yet"; + } + } catch (error) { + element.textContent = "No messages yet"; + } +} + function init() { const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs; const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!; @@ -203,19 +274,17 @@ websocket.addEventListener("message", async (e) => { if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { const plaintext = await decryptDm(msg.data, activeDm.publicKey!); - const container = document.getElementById("chat-messages")!; - const div = document.createElement("div"); - const isAuthor = msg.data.senderId !== activeDm.userId; - 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 = plaintext; - inner.appendChild(content); - div.appendChild(inner); - container.appendChild(div); - container.scrollTop = container.scrollHeight; + if (dmPanel) { + const isAuthor = msg.data.senderId !== activeDm.userId; + dmPanel.appendMessageWithId({ + id: msg.data.id, + content: plaintext, + username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"), + timestamp: msg.data.timestamp, + is_read: false, + is_edited: false + }); + } } } catch {} }); \ No newline at end of file diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts index 9a99522..542dba3 100644 --- a/frontend/src/chat/panel.ts +++ b/frontend/src/chat/panel.ts @@ -1,7 +1,9 @@ import { authToken, currentUser } from "../auth/api"; -import type { WebSocketMessage } from "../core/types"; +import type { Message, WebSocketMessage } from "../core/types"; import { request } from "../websocket"; import { loadMessages } from "./chat"; +import { show as showContextMenu } from "./contextMenu"; +import { show as showProfileDialog } from "./profileDialog"; const titleEl = document.getElementById("chat-name")!; const messages = document.getElementById("chat-messages")!; @@ -43,6 +45,7 @@ export abstract class ChatPanelController { protected abstract onSubmit(text: string): void | Promise; protected abstract loadMessages(): void | Promise; + public abstract onProfileClicked(): void; static mountOnce(): void { if (this.mounted) return; @@ -79,23 +82,69 @@ export class PublicChatPanel extends ChatPanelController { protected loadMessages(): void { return loadMessages(); } + + public onProfileClicked(): void { + // Public chat doesn't have a specific profile to show + } } export class DmPanel extends ChatPanelController { private sender: (text: string) => Promise; private loader: () => Promise | void; + private otherUsername: string | null = null; + constructor(sender: (text: string) => Promise, loader: () => Promise | void) { super(); this.sender = sender; this.loader = loader; } + + setOtherUser(username: string): void { + this.otherUsername = username; + } + protected async onSubmit(text: string): Promise { this.appendSimple(text, true); await this.sender(text); } + protected loadMessages(): void | Promise { return this.loader(); } + + public onProfileClicked(): void { + if (this.otherUsername) { + // Import and show the profile dialog + showProfileDialog(this.otherUsername!); + } + } + + appendMessageWithId(message: Message): void { + const div = document.createElement("div"); + const isAuthor = message.username === currentUser?.username; + div.className = `message ${isAuthor ? "sent" : "received"}`; + div.setAttribute("data-message-id", message.id.toString()); + div.setAttribute("data-timestamp", message.timestamp); + + const inner = document.createElement("div"); + inner.className = "message-inner"; + + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = message.content; + + inner.appendChild(content); + div.appendChild(inner); + + // Add context menu support + div.addEventListener("contextmenu", (e) => { + e.preventDefault(); + showContextMenu(message, e.clientX, e.clientY); + }); + + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; + } } ChatPanelController.mountOnce(); \ No newline at end of file diff --git a/frontend/src/chat/profileDialog.ts b/frontend/src/chat/profileDialog.ts index 3018ee8..f200fa5 100644 --- a/frontend/src/chat/profileDialog.ts +++ b/frontend/src/chat/profileDialog.ts @@ -9,7 +9,7 @@ import { getAuthHeaders, currentUser } from "../auth/api"; import { API_BASE_URL } from "../core/config"; import type { UserProfile } from "../core/types"; import { showError, showSuccess } from "../utils/notification"; -import { formatTime, id } from "../utils/utils"; +import { delay, formatTime, id } from "../utils/utils"; import defaultAvatar from "../resources/images/default-avatar.png"; import type { Dialog } from "mdui/components/dialog"; import type { TextField } from "mdui/components/text-field"; @@ -36,6 +36,41 @@ function bindEvents(): void { editBioBtn?.addEventListener('click', () => startEditBio()); saveBioBtn?.addEventListener('click', () => saveBio()); cancelBioBtn?.addEventListener('click', () => cancelEditBio()); + + // DM button event + const dmButton = dialog?.querySelector('#dm-button'); + dmButton?.addEventListener('click', () => startDm()); +} + +/** + * Starts a direct message conversation + * @private + */ +async function startDm(): Promise { + if (!currentProfile || isOwnProfile) return; + + // Hide the profile dialog + hide(); + + // Switch to DMs tab + const tabs = document.querySelector('.chat-tabs mdui-tabs') as any; + if (tabs) { + tabs.value = 'dms'; + } + + // Find and click on the user in the DM users list + await delay(100); + const dmUsersList = document.getElementById("dm-users"); + if (dmUsersList) { + const userItems = dmUsersList.querySelectorAll('mdui-list-item'); + for (const item of userItems) { + const headline = item.getAttribute('headline'); + if (headline === currentProfile?.username) { + (item as HTMLElement).click(); + break; + } + } + } } /** diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index c76c51b..8f3bf4a 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -85,6 +85,7 @@ export interface User { username: string; admin?: boolean; bio?: string; + profile_picture: string; } /** From bf55005d38c53754c6ec306a8aa4639474ba0ff2 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 26 Aug 2025 00:19:12 +0300 Subject: [PATCH 10/15] Refactor code to use the new 'id' function --- frontend/src/chat/profileDialog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/chat/profileDialog.ts b/frontend/src/chat/profileDialog.ts index f200fa5..3b8906e 100644 --- a/frontend/src/chat/profileDialog.ts +++ b/frontend/src/chat/profileDialog.ts @@ -11,6 +11,7 @@ import type { UserProfile } from "../core/types"; import { showError, showSuccess } from "../utils/notification"; import { delay, formatTime, id } from "../utils/utils"; import defaultAvatar from "../resources/images/default-avatar.png"; +import type { Tabs } from "mdui/components/tabs"; import type { Dialog } from "mdui/components/dialog"; import type { TextField } from "mdui/components/text-field"; @@ -53,7 +54,7 @@ async function startDm(): Promise { hide(); // Switch to DMs tab - const tabs = document.querySelector('.chat-tabs mdui-tabs') as any; + const tabs = document.querySelector('.chat-tabs mdui-tabs') as Tabs; if (tabs) { tabs.value = 'dms'; } From 1fb01a4404e90a2db2e4f81be4f0ef7dcf5647fa Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 26 Aug 2025 18:09:55 +0300 Subject: [PATCH 11/15] Fix DMs displaying --- .cursor/rules/ui.mdc | 3 +- frontend/index.html | 4 +- frontend/src/auth/auth.ts | 4 +- frontend/src/chat/chat.ts | 29 ------- frontend/src/chat/dm.ts | 168 ++++++++++++++++++++++++++++++------- frontend/src/chat/panel.ts | 33 +++++++- frontend/src/navigation.ts | 11 +-- 7 files changed, 176 insertions(+), 76 deletions(-) diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index 6791c5c..737cd03 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -5,4 +5,5 @@ alwaysApply: true When you work with UI: 1. Use MDUI components as HTML elements -2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. +3. The supporting text slot for MDUI lists is "description". \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 63d5d66..e8e87be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -418,8 +418,8 @@
-
diff --git a/frontend/src/auth/auth.ts b/frontend/src/auth/auth.ts index 3988c2d..5d0da0b 100644 --- a/frontend/src/auth/auth.ts +++ b/frontend/src/auth/auth.ts @@ -8,7 +8,7 @@ import { initializeProfile } from "../userPanel/profile/profile"; import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types"; import { API_BASE_URL } from "../core/config"; -import { loadChat, showLogin, showRegister } from "../navigation"; +import { showChat, showLogin, showRegister } from "../navigation"; import { setUser } from "./api"; import { ensureKeysOnLogin } from "./crypto"; import { id } from "../utils/utils"; @@ -77,7 +77,7 @@ async function handleLogin(e: Event): Promise { } catch (e) { console.error("Key setup failed:", e); } - loadChat(); + showChat(); initializeProfile(); // Initialize profile after login } else { const data: ErrorResponse = await response.json(); diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts index 914362b..df130f7 100644 --- a/frontend/src/chat/chat.ts +++ b/frontend/src/chat/chat.ts @@ -123,35 +123,6 @@ export function addMessage(message: Message, isAuthor: boolean): void { messagesContainer.scrollTop = messagesContainer.scrollHeight; } -/** - * Loads chat messages from the server - */ -export function loadMessages(): void { - fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders() - }) - .then(response => response.json()) - .then((data: Messages) => { - if (data.messages && data.messages.length > 0) { - const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; - - const lastMessage = messagesContainer.lastElementChild as HTMLElement - let lastMessageId: number = 0 - if (lastMessage) { - lastMessageId = Number(lastMessage.dataset.id) - } - - // Добавляем только новые сообщения - data.messages.forEach(msg => { - console.log(msg); - if (msg.id > lastMessageId) { - addMessage(msg, msg.username == currentUser!.username); - } - }); - } - }); -} - /** * Sends a message via WebSocket */ diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 7b0e1d0..0d9de54 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -81,6 +81,23 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string let activeDm: { userId: number; username: string; publicKey: string | null } | null = null; let usersLoaded = false; let dmPanel: DmPanel | null = null; +const dmBadgeByUserId: Map = new Map(); +const dmSupportingTextByUserId: Map = new Map(); + +function getLastReadId(userId: number): number { + try { + const v = localStorage.getItem(`dmLastRead:${userId}`); + return v ? Number(v) : 0; + } catch { + return 0; + } +} + +function setLastReadId(userId: number, id: number): void { + try { + localStorage.setItem(`dmLastRead:${userId}`, String(id)); + } catch {} +} async function loadUsers() { const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) }); @@ -90,6 +107,7 @@ async function loadUsers() { list.innerHTML = ""; (data.users || []).forEach((u: User) => { const item = document.createElement("mdui-list-item"); + item.id = `dm-user-${u.id}`; // Add avatar const avatar = document.createElement("img"); @@ -111,19 +129,28 @@ async function loadUsers() { // Set headline (username) item.setAttribute("headline", u.username); - // Add last message placeholder (will be loaded lazily) + // Add supporting text container (hidden until loaded) const lastMessageEl = document.createElement("div"); - lastMessageEl.slot = "supporting-text"; - lastMessageEl.textContent = "Loading..."; + lastMessageEl.slot = "description"; lastMessageEl.style.fontSize = "12px"; lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)"; + lastMessageEl.style.whiteSpace = "pre-line"; + lastMessageEl.style.display = "none"; item.appendChild(lastMessageEl); + dmSupportingTextByUserId.set(u.id, lastMessageEl); + + // Add unread badge (hidden by default) + const badge = document.createElement("mdui-badge"); + badge.setAttribute("slot", "end-icon"); + badge.style.display = "none"; + item.appendChild(badge); + dmBadgeByUserId.set(u.id, badge); // Load last message when element becomes visible const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - loadLastMessage(u.id, lastMessageEl); + loadLastMessage(u.id); observer.unobserve(entry.target); } }); @@ -181,9 +208,11 @@ async function loadUsers() { const messages: DmEnvelope[] = data.messages || []; const container = document.getElementById("chat-messages")!; container.innerHTML = ""; + let maxIncomingId = 0; for (const env of messages) { try { - const text = await decryptDm(env, activeDm.publicKey); + // Always use other user's public key for ECDH (our private + their public) + const text = await decryptDm(env, activeDm.publicKey!); const isAuthor = env.senderId !== activeDm.userId; const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"); dmPanel.appendMessageWithId({ @@ -194,9 +223,20 @@ async function loadUsers() { is_read: false, is_edited: false }); + if (env.senderId === activeDm.userId && env.id > maxIncomingId) { + maxIncomingId = env.id; + } } catch {} } container.scrollTop = container.scrollHeight; + if (maxIncomingId > 0) { + setLastReadId(activeDm.userId, maxIncomingId); + const badgeEl = dmBadgeByUserId.get(activeDm.userId); + if (badgeEl) { + badgeEl.style.display = "none"; + badgeEl.textContent = ""; + } + } } } ); @@ -204,7 +244,6 @@ async function loadUsers() { dmPanel.setOtherUser(u.username); dmPanel.setTitle(u.username); dmPanel.clearMessages(); - dmPanel.activate(); // Add profile click functionality to chat header const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement; @@ -218,32 +257,76 @@ async function loadUsers() { const pkData = await resPk.json(); activeDm!.publicKey = pkData.publicKey; } + + // Only activate after we have the public key so loader can decrypt + dmPanel.activate(); + // Clear unread badge on open + const badgeEl = dmBadgeByUserId.get(u.id); + if (badgeEl) { + badgeEl.textContent = ""; + badgeEl.style.display = "none"; + } }); list.appendChild(item); }); } -async function loadLastMessage(userId: number, element: HTMLElement): Promise { +async function loadLastMessage(userId: number): Promise { try { - const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=1`, { + const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(true) }); + if (!pkRes.ok) return; + const pkData = await pkRes.json(); + const otherPk = pkData.publicKey as string; + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=50`, { headers: getAuthHeaders(true) }); if (response.ok) { const data = await response.json(); const messages: DmEnvelope[] = data.messages || []; - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - // For now, just show "Last message" since we can't decrypt without the public key - // In a real implementation, you'd need to store the public key or decrypt here - element.textContent = "Last message"; + const supporting = dmSupportingTextByUserId.get(userId); + const badgeEl = dmBadgeByUserId.get(userId); + if (!supporting || !badgeEl) return; + let lastPlaintext: string | null = null; + let lastEnv: DmEnvelope | null = null; + for (const env of messages) { + if (!lastEnv || env.id > lastEnv.id) lastEnv = env; + } + if (lastEnv) { + try { lastPlaintext = await decryptDm(lastEnv, otherPk); } catch {} + } + if (lastPlaintext && lastPlaintext.trim().length > 0) { + const lines = lastPlaintext.split(/\r?\n/).slice(0, 2); + supporting.textContent = lines.join("\n"); + supporting.style.display = "block"; } else { - element.textContent = "No messages yet"; + supporting.textContent = ""; + supporting.style.display = "none"; + } + const lastRead = getLastReadId(userId); + let unread = 0; + for (const env of messages) { + if (env.senderId === userId && env.id > lastRead) unread++; + } + if (unread > 0) { + badgeEl.textContent = String(unread); + badgeEl.style.display = "inline-flex"; + } else { + badgeEl.textContent = ""; + badgeEl.style.display = "none"; } } else { - element.textContent = "No messages yet"; + const supporting = dmSupportingTextByUserId.get(userId); + if (supporting) { + supporting.textContent = ""; + supporting.style.display = "none"; + } } } catch (error) { - element.textContent = "No messages yet"; + const supporting = dmSupportingTextByUserId.get(userId); + if (supporting) { + supporting.textContent = ""; + supporting.style.display = "none"; + } } } @@ -271,19 +354,46 @@ init(); websocket.addEventListener("message", async (e) => { try { const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data); - if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { - const plaintext = await decryptDm(msg.data, activeDm.publicKey!); - - if (dmPanel) { - const isAuthor = msg.data.senderId !== activeDm.userId; - dmPanel.appendMessageWithId({ - id: msg.data.id, - content: plaintext, - username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"), - timestamp: msg.data.timestamp, - is_read: false, - is_edited: false - }); + if (msg.type === "dmNew") { + if (activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { + // Always use other user's public key (our private is implied by getCurrentKeys) + const plaintext = await decryptDm(msg.data, activeDm.publicKey!); + if (dmPanel) { + const isAuthor = msg.data.senderId !== activeDm.userId; + dmPanel.appendMessageWithId({ + id: msg.data.id, + content: plaintext, + username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"), + timestamp: msg.data.timestamp, + is_read: false, + is_edited: false + }); + } + if (msg.data.senderId === activeDm.userId) { + setLastReadId(activeDm.userId, Math.max(getLastReadId(activeDm.userId), msg.data.id)); + } + } else { + const otherUserId = msg.data.senderId; + const badgeEl = dmBadgeByUserId.get(otherUserId); + if (badgeEl) { + const current = Number(badgeEl.textContent || 0); + const next = (current || 0) + 1; + badgeEl.textContent = String(next); + badgeEl.style.display = "inline-flex"; + } + try { + const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${otherUserId}`, { headers: getAuthHeaders(true) }); + if (pkRes.ok) { + const pkData = await pkRes.json(); + const plaintext = await decryptDm(msg.data, pkData.publicKey); + const supporting = dmSupportingTextByUserId.get(otherUserId); + if (supporting && plaintext) { + const lines = plaintext.split(/\r?\n/).slice(0, 2); + supporting.textContent = lines.join("\n"); + supporting.style.display = lines.length ? "block" : "none"; + } + } + } catch {} } } } catch {} diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts index 542dba3..7f888c3 100644 --- a/frontend/src/chat/panel.ts +++ b/frontend/src/chat/panel.ts @@ -1,7 +1,8 @@ -import { authToken, currentUser } from "../auth/api"; -import type { Message, WebSocketMessage } from "../core/types"; +import { authToken, currentUser, getAuthHeaders } from "../auth/api"; +import { API_BASE_URL } from "../core/config"; +import type { Message, Messages, WebSocketMessage } from "../core/types"; import { request } from "../websocket"; -import { loadMessages } from "./chat"; +import { addMessage } from "./chat"; import { show as showContextMenu } from "./contextMenu"; import { show as showProfileDialog } from "./profileDialog"; @@ -80,7 +81,31 @@ export class PublicChatPanel extends ChatPanelController { } protected loadMessages(): void { - return loadMessages(); + fetch(`${API_BASE_URL}/get_messages`, { + headers: getAuthHeaders() + }) + .then(response => response.json()) + .then((data: Messages) => { + if (data.messages && data.messages.length > 0) { + messages.innerHTML = ""; + + const messagesContainer = document.querySelector('.chat-messages') as HTMLElement; + + const lastMessage = messagesContainer.lastElementChild as HTMLElement + let lastMessageId: number = 0 + if (lastMessage) { + lastMessageId = Number(lastMessage.dataset.id) + } + + // Добавляем только новые сообщения + data.messages.forEach(msg => { + console.log(msg); + if (msg.id > lastMessageId) { + addMessage(msg, msg.username == currentUser!.username); + } + }); + } + }); } public onProfileClicked(): void { diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts index f72e4b1..81081dc 100644 --- a/frontend/src/navigation.ts +++ b/frontend/src/navigation.ts @@ -1,5 +1,5 @@ import { clearAlerts } from "./auth/auth"; -import { loadMessages } from "./chat/chat"; +import { publicChatPanel } from "./chat/chat"; import { id } from "./utils/utils"; const loginForm = id("login-form"); @@ -36,14 +36,7 @@ export function showChat(): void { loginForm.style.display = 'none'; registerForm.style.display = 'none'; chatInterface.style.display = 'block'; - loadMessages(); titleBar.classList.remove("color-surface"); -} -/** - * Loads the chat interface and initializes messaging. - */ -export function loadChat(): void { - showChat(); - loadMessages(); + publicChatPanel.activate(); } \ No newline at end of file From ebeeaa29a6648b8293ed3da0377660be64c2619a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 26 Aug 2025 19:15:33 +0300 Subject: [PATCH 12/15] Fix crypto module --- frontend/src/chat/dm.ts | 4 +++- frontend/src/crypto/symmetric.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 0d9de54..7714eb3 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -226,7 +226,9 @@ async function loadUsers() { if (env.senderId === activeDm.userId && env.id > maxIncomingId) { maxIncomingId = env.id; } - } catch {} + } catch (e) { + console.error("Error while loading message:", e); + } } container.scrollTop = container.scrollHeight; if (maxIncomingId > 0) { diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts index f0b1780..4de472b 100644 --- a/frontend/src/crypto/symmetric.ts +++ b/frontend/src/crypto/symmetric.ts @@ -11,9 +11,10 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra } export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { - const ivBuffer = iv instanceof Uint8Array ? iv.buffer as ArrayBuffer : iv; - const ciphertextBuffer = ciphertext instanceof Uint8Array ? ciphertext.buffer as ArrayBuffer : ciphertext; - const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer }, key, ciphertextBuffer); + // WebCrypto AES-GCM requires Uint8Array for iv and data; passing ArrayBuffer slices can break auth tag boundaries + const ivBytes = iv instanceof Uint8Array ? iv : new Uint8Array(iv); + const ctBytes = ciphertext instanceof Uint8Array ? ciphertext : new Uint8Array(ciphertext); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBytes }, key, ctBytes); return new Uint8Array(pt); } From 4a21923c69faac26324811d4de0d13a29f0d759d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 26 Aug 2025 19:34:26 +0300 Subject: [PATCH 13/15] Fix messages appearing in the wrong chat --- frontend/src/chat/chat.ts | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts index df130f7..37c4951 100644 --- a/frontend/src/chat/chat.ts +++ b/frontend/src/chat/chat.ts @@ -13,7 +13,7 @@ import { show as showContextMenu } from "./contextMenu"; import { show as showUserProfileDialog } from "./profileDialog"; import defaultAvatar from "../resources/images/default-avatar.png"; import { authToken, currentUser, getAuthHeaders } from "../auth/api"; -import { PublicChatPanel } from "./panel"; +import { ChatPanelController, PublicChatPanel } from "./panel"; /** * Adds a new message to the chat interface @@ -189,23 +189,25 @@ export function removeMessage(messageId: number): void { * @param {WebSocketMessage} response - WebSocket response */ export function handleWebSocketMessage(response: WebSocketMessage): void { - switch (response.type) { - case 'messageEdited': - if (response.data) { - updateMessage(response.data); - } - break; - case 'messageDeleted': - if (response.data && response.data.message_id) { - removeMessage(response.data.message_id); - } - break; - case 'newMessage': - if (response.data) { - const isAuthor = response.data.username === currentUser?.username; - addMessage(response.data, isAuthor); - } - break; + if (ChatPanelController.active == publicChatPanel) { + switch (response.type) { + case 'messageEdited': + if (response.data) { + updateMessage(response.data); + } + break; + case 'messageDeleted': + if (response.data && response.data.message_id) { + removeMessage(response.data.message_id); + } + break; + case 'newMessage': + if (response.data) { + const isAuthor = response.data.username === currentUser?.username; + addMessage(response.data, isAuthor); + } + break; + } } } From 43be3a29af14e311a974f534a73cd32944b526e3 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 26 Aug 2025 19:58:35 +0300 Subject: [PATCH 14/15] Fix type issues --- frontend/src/chat/dm.ts | 14 ++------------ frontend/src/core/types.d.ts | 13 ++++++++++++- frontend/src/crypto/symmetric.ts | 17 ++++++++++++----- frontend/src/crypto/types.d.ts | 4 ---- 4 files changed, 26 insertions(+), 22 deletions(-) delete mode 100644 frontend/src/crypto/types.d.ts diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index 7714eb3..13450c3 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -6,7 +6,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetr import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request, websocket } from "../websocket"; -import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User } from "../core/types"; +import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User, DmEnvelope } from "../core/types"; import type { Tabs } from "mdui/components/tabs"; import { b64, ub64 } from "../utils/utils"; @@ -35,17 +35,7 @@ export async function sendDm(recipientId: number, recipientPublicKeyB64: string, }); } -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`); diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 8f3bf4a..b4cb17a 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -5,7 +5,6 @@ * @version 1.0.0 */ -import type { DmEnvelope } from "../chat/dm"; /** * HTTP headers object type @@ -169,6 +168,18 @@ export interface BackupBlob { blob: string; } +export interface DmEnvelope { + id: number; + senderId: number; + recipientId: number; + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedMk: string; + timestamp: string; +} + export interface FetchDMResponse { messages: DmEnvelope[] } diff --git a/frontend/src/crypto/symmetric.ts b/frontend/src/crypto/symmetric.ts index 4de472b..45b85d0 100644 --- a/frontend/src/crypto/symmetric.ts +++ b/frontend/src/crypto/symmetric.ts @@ -11,11 +11,18 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra } export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { - // WebCrypto AES-GCM requires Uint8Array for iv and data; passing ArrayBuffer slices can break auth tag boundaries - const ivBytes = iv instanceof Uint8Array ? iv : new Uint8Array(iv); - const ctBytes = ciphertext instanceof Uint8Array ? ciphertext : new Uint8Array(ciphertext); - const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBytes }, key, ctBytes); - return new Uint8Array(pt); + // Normalize IV to ArrayBuffer (12 bytes for AES-GCM) + const ivBuf: ArrayBuffer = iv instanceof Uint8Array + ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) + : (iv as ArrayBuffer); + + // Normalize ciphertext to a contiguous ArrayBuffer slice + const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array + ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) + : (ciphertext as ArrayBuffer); + + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuf }, key, ctBuf); + return new Uint8Array(pt as ArrayBuffer); } export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise { diff --git a/frontend/src/crypto/types.d.ts b/frontend/src/crypto/types.d.ts deleted file mode 100644 index 13bbec3..0000000 --- a/frontend/src/crypto/types.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "tweetnacl" { - const nacl: any; - export default nacl; -} \ No newline at end of file From 98f65b4470d333a81c19122cd98a78631399ff72 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 28 Aug 2025 11:23:54 +0300 Subject: [PATCH 15/15] Remove unnecessary file --- frontend/src/crypto/index.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 frontend/src/crypto/index.ts diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts deleted file mode 100644 index cfd5e8d..0000000 --- a/frontend/src/crypto/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./kdf"; -export * from "./symmetric"; -export * from "./asymmetric"; -export * from "./backup"; \ No newline at end of file