Add private messages

This commit is contained in:
2025-08-28 22:49:07 +03:00
Unverified
25 changed files with 1256 additions and 143 deletions
+1
View File
@@ -6,3 +6,4 @@ When you work with UI:
1. Use MDUI components as HTML elements 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. 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".
+30
View File
@@ -38,6 +38,36 @@ class Message(Base):
reply_to = relationship("Message", remote_side=[id]) 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 модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
+59 -2
View File
@@ -2,10 +2,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from routes.messaging import convert_message
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db 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 utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username 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}") @router.delete("/admin/user/{user_id}")
def delete_user_as_owner( def delete_user_as_owner(
user_id: int, user_id: int,
@@ -161,3 +202,19 @@ def logout(
"status": "success", "status": "success",
"message": "Logged out successfully" "message": "Logged out successfully"
} }
@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}
+133 -1
View File
@@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") 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}") @router.put("/edit_message/{message_id}")
async def edit_message( async def edit_message(
message_id: int, message_id: int,
@@ -147,6 +223,7 @@ async def reply_message(
class MessaggingSocketManager: class MessaggingSocketManager:
def __init__(self) -> None: def __init__(self) -> None:
self.connections: list[WebSocket] = [] self.connections: list[WebSocket] = []
self.user_by_ws: dict[WebSocket, int] = {}
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException): 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}}) await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
@@ -169,12 +246,19 @@ class MessaggingSocketManager:
return None return None
if type == "ping": 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"}}) await websocket.send_json({"type": "ping", "data": {"status": "success"}})
elif type == "getMessages": elif type == "getMessages":
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
raise HTTPException(401) raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
except HTTPException as e: except HTTPException as e:
@@ -184,6 +268,7 @@ class MessaggingSocketManager:
current_user = get_current_user_inner() current_user = get_current_user_inner()
if not current_user: if not current_user:
raise HTTPException(401) raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
@@ -196,6 +281,46 @@ class MessaggingSocketManager:
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, 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": elif type == "editMessage":
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
@@ -264,11 +389,18 @@ class MessaggingSocketManager:
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
finally: finally:
self.connections.remove(websocket) self.connections.remove(websocket)
if websocket in self.user_by_ws:
del self.user_by_ws[websocket]
async def broadcast(self, message: dict): async def broadcast(self, message: dict):
for websocket in self.connections: for websocket in self.connections:
await websocket.send_json(message) 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() messagingManager = MessaggingSocketManager()
@router.websocket("/chat/ws") @router.websocket("/chat/ws")
+12
View File
@@ -147,6 +147,9 @@
<mdui-tab value="contacts"> <mdui-tab value="contacts">
Контакты Контакты
</mdui-tab> </mdui-tab>
<mdui-tab value="dms">
ЛС
</mdui-tab>
<mdui-tab-panel slot="panel" value="chats"> <mdui-tab-panel slot="panel" value="chats">
<mdui-list> <mdui-list>
@@ -160,6 +163,9 @@
</mdui-tab-panel> </mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel> <mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel> <mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<mdui-list id="dm-users"></mdui-list>
</mdui-tab-panel>
</mdui-tabs> </mdui-tabs>
</div> </div>
<mdui-bottom-app-bar> <mdui-bottom-app-bar>
@@ -411,6 +417,12 @@
<span class="stat-value last-seen"></span> <span class="stat-value last-seen"></span>
</div> </div>
</div> </div>
<div class="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
Send Message
</mdui-button>
</div>
</div> </div>
</div> </div>
</mdui-dialog> </mdui-dialog>
+17 -1
View File
@@ -1,7 +1,8 @@
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { showLogin } from "../navigation"; import { showLogin } from "../navigation";
import type { Headers, User } from "../core/types"; import type { Headers, User, WebSocketMessage } from "../core/types";
import { clearAlerts } from "./auth"; import { clearAlerts } from "./auth";
import { request } from "../websocket";
/** /**
* Current authenticated user information * Current authenticated user information
@@ -23,6 +24,21 @@ export let authToken: string | null = null;
export function setUser(token: string, user: User) { export function setUser(token: string, user: User) {
authToken = token authToken = token
currentUser = user currentUser = user
try {
const payload: WebSocketMessage = {
type: "ping",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: {}
}
request(payload).then(() => {
console.log("Ping succeeded")
})
} catch {}
} }
/** /**
+8 -2
View File
@@ -8,8 +8,9 @@
import { initializeProfile } from "../userPanel/profile/profile"; import { initializeProfile } from "../userPanel/profile/profile";
import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types"; import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { loadChat, showLogin, showRegister } from "../navigation"; import { showChat, showLogin, showRegister } from "../navigation";
import { setUser } from "./api"; import { setUser } from "./api";
import { ensureKeysOnLogin } from "./crypto";
import { id } from "../utils/utils"; import { id } from "../utils/utils";
/** /**
@@ -71,7 +72,12 @@ async function handleLogin(e: Event): Promise<void> {
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
// Store the JWT token // Store the JWT token
setUser(data.token, data.user) setUser(data.token, data.user)
loadChat(); try {
await ensureKeysOnLogin(password);
} catch (e) {
console.error("Key setup failed:", e);
}
showChat();
initializeProfile(); // Initialize profile after login initializeProfile(); // Initialize profile after login
} else { } else {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
+98
View File
@@ -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<Uint8Array | null> {
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<void> {
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<string | 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<void> {
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<UserKeyPairMemory> {
// 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 };
}
+11 -46
View File
@@ -6,13 +6,14 @@
*/ */
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { websocket } from "../websocket"; import { request } from "../websocket";
import type { Message, Messages, WebSocketMessage } from "../core/types"; import type { Message, Messages, WebSocketMessage } from "../core/types";
import { formatTime } from "../utils/utils"; import { formatTime } from "../utils/utils";
import { show as showContextMenu } from "./contextMenu"; import { show as showContextMenu } from "./contextMenu";
import { show as showUserProfileDialog } from "./profileDialog"; import { show as showUserProfileDialog } from "./profileDialog";
import defaultAvatar from "../resources/images/default-avatar.png"; import defaultAvatar from "../resources/images/default-avatar.png";
import { authToken, currentUser, getAuthHeaders } from "../auth/api"; import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import { ChatPanelController, PublicChatPanel } from "./panel";
/** /**
* Adds a new message to the chat interface * Adds a new message to the chat interface
@@ -122,43 +123,15 @@ export function addMessage(message: Message, isAuthor: boolean): void {
messagesContainer.scrollTop = messagesContainer.scrollHeight; 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 * Sends a message via WebSocket
*/ */
export function sendMessage(): void { export async function sendMessage(): Promise<void> {
const input = document.querySelector('.message-input') as HTMLInputElement; const input = document.querySelector('.message-input') as HTMLInputElement;
const message = input.value.trim(); const message = input.value.trim();
if (message) { if (message) {
const payload: WebSocketMessage = { const response = await request({
data: { data: {
content: message content: message
}, },
@@ -167,29 +140,15 @@ export function sendMessage(): void {
credentials: authToken! credentials: authToken!
}, },
type: "sendMessage" 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) console.log(response)
if (!response.error) { if (!response.error) {
input.value = ""; 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 * Updates an existing message in the chat interface
* @param {Message} message - Updated message object * @param {Message} message - Updated message object
@@ -230,6 +189,7 @@ export function removeMessage(messageId: number): void {
* @param {WebSocketMessage} response - WebSocket response * @param {WebSocketMessage} response - WebSocket response
*/ */
export function handleWebSocketMessage(response: WebSocketMessage): void { export function handleWebSocketMessage(response: WebSocketMessage): void {
if (ChatPanelController.active == publicChatPanel) {
switch (response.type) { switch (response.type) {
case 'messageEdited': case 'messageEdited':
if (response.data) { if (response.data) {
@@ -248,4 +208,9 @@ export function handleWebSocketMessage(response: WebSocketMessage): void {
} }
break; break;
} }
}
} }
export const publicChatPanel = new PublicChatPanel();
publicChatPanel.activate();
+19 -36
View File
@@ -5,8 +5,8 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { websocket } from "../websocket"; import { request } from "../websocket";
import type { Message, WebSocketMessage } from "../core/types"; import type { Message } from "../core/types";
import { showSuccess, showError } from "../utils/notification"; import { showSuccess, showError } from "../utils/notification";
import { delay, id } from "../utils/utils"; import { delay, id } from "../utils/utils";
import type { Dialog } from "mdui/components/dialog"; import type { Dialog } from "mdui/components/dialog";
@@ -85,8 +85,15 @@ export function show(message: Message, x: number, y: number): void {
const isAuthor = message.username === currentUser?.username; const isAuthor = message.username === currentUser?.username;
const isOwner = currentUser?.admin; 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'; 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 // Position the menu properly
menu.style.display = 'block'; menu.style.display = 'block';
@@ -184,7 +191,7 @@ function hideEditDialog(): void {
* Saves the edited message * Saves the edited message
* @private * @private
*/ */
function saveEdit(): void { async function saveEdit(): Promise<void> {
if (!currentMessage) return; if (!currentMessage) return;
const textField = editDialog.querySelector('#edit-message-input') as TextField; const textField = editDialog.querySelector('#edit-message-input') as TextField;
@@ -195,7 +202,7 @@ function saveEdit(): void {
return; return;
} }
const payload: WebSocketMessage = { const response = await request({
type: "editMessage", type: "editMessage",
data: { data: {
message_id: currentMessage.id, message_id: currentMessage.id,
@@ -205,12 +212,7 @@ function saveEdit(): void {
scheme: "Bearer", scheme: "Bearer",
credentials: authToken! 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) { if (response.error) {
showError(response.error.detail); showError(response.error.detail);
@@ -218,9 +220,6 @@ function saveEdit(): void {
showSuccess('Message edited successfully'); showSuccess('Message edited successfully');
hideEditDialog(); hideEditDialog();
} }
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
} }
/** /**
@@ -257,7 +256,7 @@ function hideReplyDialog(): void {
* Sends the reply message * Sends the reply message
* @private * @private
*/ */
function sendReply(): void { async function sendReply(): Promise<void> {
if (!currentMessage) return; if (!currentMessage) return;
const textField = replyDialog.querySelector('#reply-message-input') as TextField; const textField = replyDialog.querySelector('#reply-message-input') as TextField;
@@ -268,7 +267,7 @@ function sendReply(): void {
return; return;
} }
const payload: WebSocketMessage = { const response = await request({
type: "replyMessage", type: "replyMessage",
data: { data: {
content: content, content: content,
@@ -278,12 +277,7 @@ function sendReply(): void {
scheme: "Bearer", scheme: "Bearer",
credentials: authToken! 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) { if (response.error) {
showError(response.error.detail); showError(response.error.detail);
@@ -294,9 +288,6 @@ function sendReply(): void {
textField.value = ''; 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 * @param {Message} message - The message to delete
* @private * @private
*/ */
function deleteMessage(message: Message): void { async function deleteMessage(message: Message): Promise<void> {
if (!confirm('Are you sure you want to delete this message?')) { if (!confirm('Are you sure you want to delete this message?')) {
return; return;
} }
const payload: WebSocketMessage = { const response = await request({
type: "deleteMessage", type: "deleteMessage",
data: { data: {
message_id: message.id message_id: message.id
@@ -318,21 +309,13 @@ function deleteMessage(message: Message): void {
scheme: "Bearer", scheme: "Bearer",
credentials: authToken! 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) { if (response.error) {
showError(response.error.detail); showError(response.error.detail);
} else { } else {
showSuccess('Message deleted successfully'); showSuccess('Message deleted successfully');
} }
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
} }
init(); init();
+392
View File
@@ -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<void> {
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<DmEnvelope[]> {
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<string> {
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<number, HTMLElement> = new Map();
const dmSupportingTextByUserId: Map<number, HTMLElement> = 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<void> {
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 {}
});
+175
View File
@@ -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<void>;
protected abstract loadMessages(): void | Promise<void>;
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<void> {
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<void>;
private loader: () => Promise<void> | void;
private otherUsername: string | null = null;
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
super();
this.sender = sender;
this.loader = loader;
}
setOtherUser(username: string): void {
this.otherUsername = username;
}
protected async onSubmit(text: string): Promise<void> {
this.appendSimple(text, true);
await this.sender(text);
}
protected loadMessages(): void | Promise<void> {
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();
+37 -1
View File
@@ -9,8 +9,9 @@ import { getAuthHeaders, currentUser } from "../auth/api";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import type { UserProfile } from "../core/types"; import type { UserProfile } from "../core/types";
import { showError, showSuccess } from "../utils/notification"; 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 defaultAvatar from "../resources/images/default-avatar.png";
import type { Tabs } from "mdui/components/tabs";
import type { Dialog } from "mdui/components/dialog"; import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
@@ -36,6 +37,41 @@ function bindEvents(): void {
editBioBtn?.addEventListener('click', () => startEditBio()); editBioBtn?.addEventListener('click', () => startEditBio());
saveBioBtn?.addEventListener('click', () => saveBio()); saveBioBtn?.addEventListener('click', () => saveBio());
cancelBioBtn?.addEventListener('click', () => cancelEditBio()); 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<void> {
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;
}
}
}
} }
/** /**
+35
View File
@@ -5,6 +5,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
/** /**
* HTTP headers object type * HTTP headers object type
* @typedef {Object.<string, string>} Headers * @typedef {Object.<string, string>} Headers
@@ -83,6 +84,7 @@ export interface User {
username: string; username: string;
admin?: boolean; admin?: boolean;
bio?: string; bio?: string;
profile_picture: string;
} }
/** /**
@@ -136,6 +138,19 @@ export interface RegisterRequest {
confirm_password: string; confirm_password: string;
} }
export interface UploadPublicKeyRequest {
publicKey: string;
}
export interface SendDMRequest {
recipientId: number;
iv: string;
ciphertext: string;
salt: string;
iv2: string;
wrappedMk: string;
}
// Responses // Responses
/** /**
@@ -149,6 +164,26 @@ export interface LoginResponse {
token: string; 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 // WebSocket types
// --------------- // ---------------
+21
View File
@@ -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<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
}
+68
View File
@@ -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<EncryptedBackupBlob> {
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<PrivateKeyBundle> {
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) };
}
+31
View File
@@ -0,0 +1,31 @@
export async function importPassword(password: string): Promise<CryptoKey> {
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<CryptoKey> {
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<Uint8Array> {
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;
}
+31
View File
@@ -0,0 +1,31 @@
export interface AesGcmCiphertext {
iv: Uint8Array;
ciphertext: Uint8Array;
}
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise<AesGcmCiphertext> {
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<Uint8Array> {
// 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<CryptoKey> {
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey;
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
+2
View File
@@ -17,3 +17,5 @@ import "./userPanel/profile/profile";
import "./chat/contextMenu"; import "./chat/contextMenu";
import "./chat/profileDialog"; import "./chat/profileDialog";
import "./electron/electron"; import "./electron/electron";
import "./chat/panel";
import "./chat/dm";
+2 -9
View File
@@ -1,5 +1,5 @@
import { clearAlerts } from "./auth/auth"; import { clearAlerts } from "./auth/auth";
import { loadMessages } from "./chat/chat"; import { publicChatPanel } from "./chat/chat";
import { id } from "./utils/utils"; import { id } from "./utils/utils";
const loginForm = id("login-form"); const loginForm = id("login-form");
@@ -36,14 +36,7 @@ export function showChat(): void {
loginForm.style.display = 'none'; loginForm.style.display = 'none';
registerForm.style.display = 'none'; registerForm.style.display = 'none';
chatInterface.style.display = 'block'; chatInterface.style.display = 'block';
loadMessages();
titleBar.classList.remove("color-surface"); titleBar.classList.remove("color-surface");
}
/** publicChatPanel.activate();
* Loads the chat interface and initializes messaging.
*/
export function loadChat(): void {
showChat();
loadMessages();
} }
+3
View File
@@ -8,6 +8,7 @@
import { Dialog } from "mdui/components/dialog"; import { Dialog } from "mdui/components/dialog";
import { loadProfilePicture } from "./profile/upload"; import { loadProfilePicture } from "./profile/upload";
import { id } from "../utils/utils"; import { id } from "../utils/utils";
import { publicChatPanel } from "../chat/chat";
// сварачивание и разворачивание чата // сварачивание и разворачивание чата
const chatCollapseBtn = id('hide-chat')!; const chatCollapseBtn = id('hide-chat')!;
@@ -74,12 +75,14 @@ function setupChatSwitching(): void {
chat1.addEventListener('click', () => { chat1.addEventListener('click', () => {
animateChatSwitch(() => { animateChatSwitch(() => {
chatName.textContent = 'Общий чат'; chatName.textContent = 'Общий чат';
publicChatPanel.activate();
}); });
}); });
chat2.addEventListener('click', () => { chat2.addEventListener('click', () => {
animateChatSwitch(() => { animateChatSwitch(() => {
chatName.textContent = 'Общий чат 2'; chatName.textContent = 'Общий чат 2';
publicChatPanel.activate();
}); });
}); });
} }
+9
View File
@@ -32,6 +32,15 @@ export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); 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<T extends Element = HTMLElement>(id: string): T { export function id<T extends Element = HTMLElement>(id: string): T {
return document.getElementById(id) as unknown as T return document.getElementById(id) as unknown as T
} }
+15
View File
@@ -7,6 +7,7 @@
import { handleWebSocketMessage } from "./chat/chat"; import { handleWebSocketMessage } from "./chat/chat";
import { API_WS_BASE_URL } from "./core/config"; import { API_WS_BASE_URL } from "./core/config";
import type { WebSocketMessage } from "./core/types";
import { delay } from "./utils/utils"; import { delay } from "./utils/utils";
/** /**
@@ -29,6 +30,20 @@ function create(): WebSocket {
*/ */
export let websocket: WebSocket = create(); export let websocket: WebSocket = create();
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
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. * 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 * If it fails, tries again in an endless loop until the connection is established
+1
View File
@@ -14,6 +14,7 @@
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
+2 -1
View File
@@ -44,6 +44,7 @@
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"mdui": "^2.1.4" "mdui": "^2.1.4",
"tweetnacl": "^1.0.3"
} }
} }