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/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..5b51f54 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,
@@ -160,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/backend/routes/messaging.py b/backend/routes/messaging.py
index 36f60d8..79a3ef3 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,82 @@ 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.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,
@@ -147,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}})
@@ -169,12 +246,19 @@ 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:
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:
@@ -184,6 +268,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"])
@@ -196,6 +281,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()
@@ -264,11 +389,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/index.html b/frontend/index.html
index 00e4448..e8e87be 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -147,6 +147,9 @@
Контакты
+
+ ЛС
+
@@ -160,6 +163,9 @@
Скоро будет...
Скоро будет...
+
+
+
@@ -411,6 +417,12 @@
+
+
+
+ Send Message
+
+
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/auth.ts b/frontend/src/auth/auth.ts
index 2aa83ba..5d0da0b 100644
--- a/frontend/src/auth/auth.ts
+++ b/frontend/src/auth/auth.ts
@@ -8,8 +8,9 @@
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";
/**
@@ -71,7 +72,12 @@ async function handleLogin(e: Event): Promise {
const data: LoginResponse = await response.json();
// Store the JWT token
setUser(data.token, data.user)
- loadChat();
+ try {
+ await ensureKeysOnLogin(password);
+ } catch (e) {
+ console.error("Key setup failed:", e);
+ }
+ showChat();
initializeProfile(); // Initialize profile after login
} else {
const data: ErrorResponse = await response.json();
diff --git a/frontend/src/auth/crypto.ts b/frontend/src/auth/crypto.ts
new file mode 100644
index 0000000..902cf7b
--- /dev/null
+++ b/frontend/src/auth/crypto.ts
@@ -0,0 +1,98 @@
+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;
+
+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 {
+ const payload: UploadPublicKeyRequest = {
+ publicKey: b64(publicKey)
+ }
+
+ await fetch(`${API_BASE_URL}/crypto/public-key`, {
+ method: "POST",
+ headers: getAuthHeaders(true),
+ 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) {
+ 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(payload)
+ });
+}
+
+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 };
+}
+
+
diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts
index 120328d..37c4951 100644
--- a/frontend/src/chat/chat.ts
+++ b/frontend/src/chat/chat.ts
@@ -6,13 +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";
/**
* Adds a new message to the chat interface
@@ -122,43 +123,15 @@ 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 => {
- if (msg.id > lastMessageId) {
- addMessage(msg, msg.username == currentUser!.username);
- }
- });
- }
- });
-}
-
/**
* 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
},
@@ -167,29 +140,15 @@ 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));
}
}
-
-document.getElementById('message-form')!.addEventListener('submit', (e) => {
- e.preventDefault();
- sendMessage();
-});
-
/**
* Updates an existing message in the chat interface
* @param {Message} message - Updated message object
@@ -230,22 +189,28 @@ 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;
+ }
}
-}
\ 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/contextMenu.ts b/frontend/src/chat/contextMenu.ts
index 92f2056..7c960b1 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";
@@ -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';
@@ -184,7 +191,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 +202,7 @@ function saveEdit(): void {
return;
}
- const payload: WebSocketMessage = {
+ const response = await request({
type: "editMessage",
data: {
message_id: currentMessage.id,
@@ -205,22 +212,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 +256,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 +267,7 @@ function sendReply(): void {
return;
}
- const payload: WebSocketMessage = {
+ const response = await request({
type: "replyMessage",
data: {
content: content,
@@ -278,25 +277,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 +295,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 +309,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
new file mode 100644
index 0000000..13450c3
--- /dev/null
+++ b/frontend/src/chat/dm.ts
@@ -0,0 +1,392 @@
+import { API_BASE_URL } from "../core/config";
+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, User, DmEnvelope } from "../core/types";
+import type { Tabs } from "mdui/components/tabs";
+import { b64, ub64 } from "../utils/utils";
+
+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: recipientId,
+ iv: b64(encMsg.iv),
+ ciphertext: b64(encMsg.ciphertext),
+ salt: b64(wkSalt),
+ iv2: b64(wrap.iv),
+ wrappedMk: b64(wrap.ciphertext)
+ })
+ });
+}
+
+
+
+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 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);
+}
+
+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) });
+ if (!res.ok) return;
+ const data = await res.json();
+ const list = document.getElementById("dm-users")!;
+ 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");
+ 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 supporting text container (hidden until loaded)
+ const lastMessageEl = document.createElement("div");
+ 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);
+ observer.unobserve(entry.target);
+ }
+ });
+ });
+ observer.observe(item);
+
+ item.addEventListener("click", async () => {
+ activeDm = { userId: u.id, username: u.username, publicKey: null };
+ if (!dmPanel) {
+ dmPanel = new DmPanel(
+ async (text: string) => {
+ if (activeDm?.publicKey) {
+ // 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: 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: authToken!
+ },
+ data: payload
+ });
+ }
+ },
+ async () => {
+ // Load DM history for the active conversation
+ if (!activeDm?.publicKey || !dmPanel) return;
+ 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 = "";
+ let maxIncomingId = 0;
+ for (const env of messages) {
+ try {
+ // 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({
+ id: env.id,
+ content: text,
+ username: username,
+ timestamp: env.timestamp,
+ is_read: false,
+ is_edited: false
+ });
+ if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
+ maxIncomingId = env.id;
+ }
+ } catch (e) {
+ console.error("Error while loading message:", e);
+ }
+ }
+ 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 = "";
+ }
+ }
+ }
+ }
+ );
+ }
+ dmPanel.setOtherUser(u.username);
+ dmPanel.setTitle(u.username);
+ dmPanel.clearMessages();
+
+ // 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();
+ 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): Promise {
+ try {
+ 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 || [];
+ 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 {
+ 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 {
+ const supporting = dmSupportingTextByUserId.get(userId);
+ if (supporting) {
+ supporting.textContent = "";
+ supporting.style.display = "none";
+ }
+ }
+ } catch (error) {
+ const supporting = dmSupportingTextByUserId.get(userId);
+ if (supporting) {
+ supporting.textContent = "";
+ supporting.style.display = "none";
+ }
+ }
+}
+
+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.addEventListener("change", (e: any) => {
+ if (e.detail?.value === "dms") {
+ ensureUsersLoaded();
+ dmPanel?.activate();
+ }
+ });
+}
+
+init();
+
+// realtime incoming DMs
+websocket.addEventListener("message", async (e) => {
+ try {
+ const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
+ 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 {}
+});
\ 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..7f888c3
--- /dev/null
+++ b/frontend/src/chat/panel.ts
@@ -0,0 +1,175 @@
+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 { addMessage } 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")!;
+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;
+ public abstract onProfileClicked(): void;
+
+ 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 request(payload);
+ }
+
+ protected loadMessages(): void {
+ 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 {
+ // 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..3b8906e 100644
--- a/frontend/src/chat/profileDialog.ts
+++ b/frontend/src/chat/profileDialog.ts
@@ -9,8 +9,9 @@ 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 { Tabs } from "mdui/components/tabs";
import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field";
@@ -36,6 +37,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 Tabs;
+ 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 4447a1d..b4cb17a 100644
--- a/frontend/src/core/types.d.ts
+++ b/frontend/src/core/types.d.ts
@@ -5,6 +5,7 @@
* @version 1.0.0
*/
+
/**
* HTTP headers object type
* @typedef {Object.} Headers
@@ -83,6 +84,7 @@ export interface User {
username: string;
admin?: boolean;
bio?: string;
+ profile_picture: string;
}
/**
@@ -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,26 @@ export interface LoginResponse {
token: string;
}
+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[]
+}
+
// ---------------
// WebSocket types
// ---------------
diff --git a/frontend/src/crypto/asymmetric.ts b/frontend/src/crypto/asymmetric.ts
new file mode 100644
index 0000000..a086efa
--- /dev/null
+++ b/frontend/src/crypto/asymmetric.ts
@@ -0,0 +1,21 @@
+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 as ArrayBuffer, salt, info, 32);
+}
\ No newline at end of file
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/kdf.ts b/frontend/src/crypto/kdf.ts
new file mode 100644
index 0000000..133d90c
--- /dev/null
+++ b/frontend/src/crypto/kdf.ts
@@ -0,0 +1,31 @@
+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 | ArrayBuffer, iterations = 210_000): Promise {
+ const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
+ return crypto.subtle.deriveKey(
+ { name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
+ passwordKey,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ );
+}
+
+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);
+}
+
+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
new file mode 100644
index 0000000..45b85d0
--- /dev/null
+++ b/frontend/src/crypto/symmetric.ts
@@ -0,0 +1,31 @@
+export interface AesGcmCiphertext {
+ iv: Uint8Array;
+ ciphertext: Uint8Array;
+}
+
+export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise {
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ 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 | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise {
+ // 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 {
+ 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/main.ts b/frontend/src/main.ts
index b49445f..b3c1090 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -16,4 +16,6 @@ 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/panel";
+import "./chat/dm";
\ No newline at end of file
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
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();
});
});
}
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
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"
}
}