From 7d254b565840b638f949e90676dd3e90bca91143 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 23 Nov 2025 21:33:09 +0300 Subject: [PATCH 1/9] Change the structure --- frontend/src/core/api/calls.ts | 16 ++ frontend/src/core/api/chats/dm.ts | 194 +++++++++++++++ frontend/src/core/api/chats/general.ts | 99 ++++++++ frontend/src/core/api/crypto/backup.ts | 37 +++ frontend/src/core/api/crypto/identity.ts | 45 ++++ frontend/src/core/api/crypto/prekeys.ts | 14 ++ frontend/src/core/api/files.ts | 70 +++--- frontend/src/core/api/index.ts | 50 ++++ frontend/src/core/api/moderation/blocklist.ts | 55 +++++ frontend/src/core/api/moderation/users.ts | 89 +++++++ frontend/src/core/api/push.ts | 66 +++--- frontend/src/core/api/user/auth.ts | 221 ++++++++++++++++++ frontend/src/core/api/user/devices.ts | 37 +++ frontend/src/core/api/user/profile.ts | 191 +++++++++++++++ frontend/src/core/api/user/search.ts | 40 ++++ frontend/src/core/calls/encryption.ts | 8 +- frontend/src/core/calls/webrtc.ts | 14 +- frontend/src/core/components/StatusBadge.tsx | 4 +- frontend/src/core/components/VerifyButton.tsx | 4 +- .../push-notifications/push-notifications.ts | 4 +- frontend/src/pages/auth/LoginForm.tsx | 8 +- frontend/src/pages/auth/RegisterForm.tsx | 8 +- frontend/src/pages/chat/hooks/useDM.ts | 48 ++-- frontend/src/pages/chat/hooks/useProfile.ts | 9 +- frontend/src/pages/chat/ui/ChatPage.tsx | 6 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 14 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 7 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 6 +- .../chat/ui/left/settings/AccountPanel.tsx | 4 +- .../ui/left/settings/ChangePasswordDialog.tsx | 4 +- .../chat/ui/left/settings/DevicesPanel.tsx | 9 +- .../ui/left/settings/NotificationsPanel.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 19 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 26 +-- .../chat/ui/right/panels/PublicChatPanel.ts | 8 +- frontend/src/state/user.ts | 7 +- 36 files changed, 1261 insertions(+), 184 deletions(-) create mode 100644 frontend/src/core/api/calls.ts create mode 100644 frontend/src/core/api/chats/dm.ts create mode 100644 frontend/src/core/api/chats/general.ts create mode 100644 frontend/src/core/api/crypto/backup.ts create mode 100644 frontend/src/core/api/crypto/identity.ts create mode 100644 frontend/src/core/api/crypto/prekeys.ts create mode 100644 frontend/src/core/api/index.ts create mode 100644 frontend/src/core/api/moderation/blocklist.ts create mode 100644 frontend/src/core/api/moderation/users.ts create mode 100644 frontend/src/core/api/user/auth.ts create mode 100644 frontend/src/core/api/user/devices.ts create mode 100644 frontend/src/core/api/user/profile.ts create mode 100644 frontend/src/core/api/user/search.ts diff --git a/frontend/src/core/api/calls.ts b/frontend/src/core/api/calls.ts new file mode 100644 index 0000000..e1a726d --- /dev/null +++ b/frontend/src/core/api/calls.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./user/auth"; +import type { IceServersResponse } from "@/core/types"; + +/** + * Fetches ICE server configuration for WebRTC + */ +export async function iceServers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/webrtc/ice`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch ICE servers"); + return await res.json(); +} + + diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts new file mode 100644 index 0000000..98dc1c5 --- /dev/null +++ b/frontend/src/core/api/chats/dm.ts @@ -0,0 +1,194 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { getCurrentKeys } from "../user/auth"; +import { request } from "@/core/websocket"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "../crypto/identity"; +import { fetchUsers, searchUsers } from "../user/search"; + +export async function decrypt(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); +} + +export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { + let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await globalThis.fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return { messages: [], has_more: false }; + const data = await response.json(); + return { messages: data.messages || [], has_more: data.has_more ?? false }; +} + +export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Encryption key + 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); + + // Encrypt the message + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + const payload: SendDMRequest = { + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }; + if (replyToId) payload.replyToId = replyToId; + + await request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: payload + }); +} + +export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; // server uses provided name + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Merge files metadata into plaintext JSON and encrypt + let obj: DmEncryptedJSON; + try { + obj = JSON.parse(plaintextJson); + } catch { + obj = { type: "text", data: { content: String(plaintextJson) } }; + } + + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await globalThis.fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); + const wrap = await aesGcmEncrypt(wk, mk); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext), + salt: b64(wkSalt) + } + } as DMEditRequest); +} + +export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} + +export interface ConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function conversations(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.conversations || []; +} + +/** + * Marks a DM as read + */ +export async function markRead(id: number, authToken: string): Promise { + await request({ + type: "dmMarkRead", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id } + }); +} + +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + + diff --git a/frontend/src/core/api/chats/general.ts b/frontend/src/core/api/chats/general.ts new file mode 100644 index 0000000..7c31155 --- /dev/null +++ b/frontend/src/core/api/chats/general.ts @@ -0,0 +1,99 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { Message, Messages, SendMessageRequest } from "@/core/types"; +import { request } from "@/core/websocket"; + +/** + * Fetches public chat messages + */ +export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> { + let url = `${API_BASE_URL}/get_messages?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await globalThis.fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return { messages: [], has_more: false }; + const data: Messages & { has_more?: boolean } = await response.json(); + return { messages: data.messages || [], has_more: data.has_more ?? false }; +} + +/** + * Sends a public chat message via WebSocket + */ +export async function send(content: string, replyToId: number | null, authToken: string): Promise { + await request({ + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, + credentials: { + scheme: "Bearer", + credentials: authToken + }, + type: "sendMessage" + } satisfies SendMessageRequest); +} + +/** + * Sends a public chat message with files via HTTP + */ +export async function sendWithFiles( + content: string, + replyToId: number | null, + files: File[], + authToken: string +): Promise { + const form = new FormData(); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); + for (const f of files) form.append("files", f, f.name); + const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, { + method: "POST", + headers: getAuthHeaders(authToken, false), + body: form + }); + if (!res.ok) { + const error = await res.text(); + throw new Error(error || "Failed to send message with files"); + } +} + +/** + * Edits a public chat message + */ +export async function edit(messageId: number, newContent: string, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, { + method: "PUT", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ content: newContent }) + }); + if (!res.ok) throw new Error("Failed to edit message"); +} + +/** + * Deletes a public chat message + */ +export async function deleteMessage(messageId: number, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, { + method: "DELETE", + headers: getAuthHeaders(authToken, true) + }); + if (!res.ok) throw new Error("Failed to delete message"); +} + +/** + * Marks a message as read + */ +export async function markRead(messageId: number, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, { + method: "POST", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ message_id: messageId }) + }); + if (!res.ok) throw new Error("Failed to mark message as read"); +} + diff --git a/frontend/src/core/api/crypto/backup.ts b/frontend/src/core/api/crypto/backup.ts new file mode 100644 index 0000000..3354c5c --- /dev/null +++ b/frontend/src/core/api/crypto/backup.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { BackupBlob } from "@/core/types"; + +/** + * Fetches the current user's backup blob + */ +export async function fetchBackupBlob(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } +} + +/** + * Uploads the current user's backup blob + */ +export async function uploadBackupBlob(blobJson: string, token: string): Promise { + const payload: BackupBlob = { blob: blobJson } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload backup blob"); +} + + diff --git a/frontend/src/core/api/crypto/identity.ts b/frontend/src/core/api/crypto/identity.ts new file mode 100644 index 0000000..ef02c59 --- /dev/null +++ b/frontend/src/core/api/crypto/identity.ts @@ -0,0 +1,45 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { UploadPublicKeyRequest } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; + +/** + * Fetches the current user's public key + */ +export async function fetchPublicKey(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +/** + * Uploads the current user's public key + */ +export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload public key"); +} + +/** + * Fetches another user's public key by user ID + */ +export async function fetchUserPublicKey(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return null; + const data = await res.json(); + return data.publicKey; +} + + diff --git a/frontend/src/core/api/crypto/prekeys.ts b/frontend/src/core/api/crypto/prekeys.ts new file mode 100644 index 0000000..3a32ce6 --- /dev/null +++ b/frontend/src/core/api/crypto/prekeys.ts @@ -0,0 +1,14 @@ +// Placeholder for Signal Protocol pre-key management +// Will be implemented when Signal Protocol is added + +export async function upload(_bundle: unknown, _token: string): Promise { + // TODO: Implement Signal Protocol pre-key upload + throw new Error("Not implemented yet"); +} + +export async function fetch(_userId: number, _token: string): Promise { + // TODO: Implement Signal Protocol pre-key fetch + throw new Error("Not implemented yet"); +} + + diff --git a/frontend/src/core/api/files.ts b/frontend/src/core/api/files.ts index 01fe6bd..c9e8186 100644 --- a/frontend/src/core/api/files.ts +++ b/frontend/src/core/api/files.ts @@ -1,39 +1,43 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; +import { getAuthHeaders } from "./user/auth"; -/** - * Gets the URL for a normal (unencrypted) file - */ -export function getNormalFileUrl(filename: string): string { - return `${API_BASE_URL}/uploads/files/normal/${filename}`; -} +export const normal = { + /** + * Gets the URL for a normal (unencrypted) file + */ + url(filename: string): string { + return `${API_BASE_URL}/uploads/files/normal/${filename}`; + }, -/** - * Gets the URL for an encrypted file - */ -export function getEncryptedFileUrl(filename: string): string { - return `${API_BASE_URL}/uploads/files/encrypted/${filename}`; -} + /** + * Fetches a normal file (unencrypted) + */ + async fetch(filename: string, token: string): Promise { + const res = await fetch(this.url(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch file"); + return await res.blob(); + } +}; -/** - * Fetches a normal file (unencrypted) - */ -export async function fetchNormalFile(filename: string, token: string): Promise { - const res = await fetch(getNormalFileUrl(filename), { - headers: getAuthHeaders(token, false) - }); - if (!res.ok) throw new Error("Failed to fetch file"); - return await res.blob(); -} +export const encrypted = { + /** + * Gets the URL for an encrypted file + */ + url(filename: string): string { + return `${API_BASE_URL}/uploads/files/encrypted/${filename}`; + }, -/** - * Fetches an encrypted file - */ -export async function fetchEncryptedFile(filename: string, token: string): Promise { - const res = await fetch(getEncryptedFileUrl(filename), { - headers: getAuthHeaders(token, false) - }); - if (!res.ok) throw new Error("Failed to fetch encrypted file"); - return await res.blob(); -} + /** + * Fetches an encrypted file + */ + async fetch(filename: string, token: string): Promise { + const res = await fetch(this.url(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch encrypted file"); + return await res.blob(); + } +}; diff --git a/frontend/src/core/api/index.ts b/frontend/src/core/api/index.ts new file mode 100644 index 0000000..dbeb1be --- /dev/null +++ b/frontend/src/core/api/index.ts @@ -0,0 +1,50 @@ +import * as chatsGeneral from "./chats/general"; +import * as chatsDm from "./chats/dm"; +import * as userProfile from "./user/profile"; +import * as userAuth from "./user/auth"; +import * as userDevices from "./user/devices"; +import * as userSearch from "./user/search"; +import * as cryptoPrekeys from "./crypto/prekeys"; +import * as cryptoIdentity from "./crypto/identity"; +import * as cryptoBackup from "./crypto/backup"; +import * as moderationBlocklist from "./moderation/blocklist"; +import * as moderationUsers from "./moderation/users"; +import * as callsModule from "./calls"; +import * as filesModule from "./files"; +import * as pushModule from "./push"; + +const api = { + chats: { + general: chatsGeneral, + dm: chatsDm + }, + user: { + profile: userProfile, + auth: userAuth, + devices: userDevices, + search: userSearch + }, + crypto: { + prekeys: cryptoPrekeys, + identity: cryptoIdentity, + backup: cryptoBackup + }, + moderation: { + blocklist: moderationBlocklist, + users: moderationUsers + }, + calls: callsModule, + files: filesModule, + push: pushModule +}; + +export default api; + +export const chats = api.chats; +export const user = api.user; +export const crypto = api.crypto; +export const moderation = api.moderation; +export const calls = api.calls; +export const files = api.files; +export const push = api.push; + diff --git a/frontend/src/core/api/moderation/blocklist.ts b/frontend/src/core/api/moderation/blocklist.ts new file mode 100644 index 0000000..9421d5c --- /dev/null +++ b/frontend/src/core/api/moderation/blocklist.ts @@ -0,0 +1,55 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; + +export interface BlocklistResponse { + words: string[]; +} + +export interface BlocklistUpdateRequest { + words: string[]; +} + +export interface BlocklistUpdateResponse { + added?: string[]; + removed?: string[]; + words: string[]; +} + +/** + * Fetches the current blocklist (admin only) + */ +export async function get(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch blocklist"); + return await res.json(); +} + +/** + * Adds words to the blocklist (admin only) + */ +export async function add(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to add to blocklist"); + return await res.json(); +} + +/** + * Removes words from the blocklist (admin only) + */ +export async function remove(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "DELETE", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to remove from blocklist"); + return await res.json(); +} + + diff --git a/frontend/src/core/api/moderation/users.ts b/frontend/src/core/api/moderation/users.ts new file mode 100644 index 0000000..d78f9c1 --- /dev/null +++ b/frontend/src/core/api/moderation/users.ts @@ -0,0 +1,89 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; + +/** + * Toggles verification status for a user (owner only) + */ +export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error verifying user:', error); + return null; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); + return null; + } +} + + diff --git a/frontend/src/core/api/push.ts b/frontend/src/core/api/push.ts index 253b5cb..31ae7bb 100644 --- a/frontend/src/core/api/push.ts +++ b/frontend/src/core/api/push.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; +import { getAuthHeaders } from "./user/auth"; export interface PushSubscriptionRequest { endpoint: string; @@ -14,37 +14,39 @@ export interface PushSubscriptionResponse { message: string; } -/** - * Subscribes the current user to push notifications - */ -export async function subscribeToPush( - subscription: PushSubscriptionRequest, - token: string -): Promise { - const res = await fetch(`${API_BASE_URL}/push/subscribe`, { - method: "POST", - headers: getAuthHeaders(token, true), - body: JSON.stringify(subscription) - }); - if (!res.ok) { - const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" })); - throw new Error(error.detail || "Failed to subscribe to push notifications"); - } - return await res.json(); -} +export const subscription = { + /** + * Subscribes the current user to push notifications + */ + async subscribe( + subscription: PushSubscriptionRequest, + token: string + ): Promise { + const res = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify(subscription) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" })); + throw new Error(error.detail || "Failed to subscribe to push notifications"); + } + return await res.json(); + }, -/** - * Unsubscribes the current user from push notifications - */ -export async function unsubscribeFromPush(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, { - method: "DELETE", - headers: getAuthHeaders(token, true) - }); - if (!res.ok) { - const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" })); - throw new Error(error.detail || "Failed to unsubscribe from push notifications"); + /** + * Unsubscribes the current user from push notifications + */ + async unsubscribe(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, { + method: "DELETE", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" })); + throw new Error(error.detail || "Failed to unsubscribe from push notifications"); + } + return await res.json(); } - return await res.json(); -} +}; diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts new file mode 100644 index 0000000..80b131a --- /dev/null +++ b/frontend/src/core/api/user/auth.ts @@ -0,0 +1,221 @@ +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; +import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; +import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; +import { b64, ub64 } from "@/utils/utils"; +import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; +import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; +import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; + +/** + * Generates authentication headers for API requests + * @param {string | null} token - Authentication token + * @param {boolean} json - Whether to include JSON content type header + * @returns {Headers} Headers object with authentication and content type + */ +export function getAuthHeaders(token: string | null, json: boolean = true): Headers { + const headers: Headers = {}; + + if (json) { + headers["Content-Type"] = "application/json"; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +export interface CheckAuthResponse { + authenticated: boolean; + username: string; + admin: boolean; +} + +export interface LogoutResponse { + status: string; + message: string; +} + +export interface UserKeyPairMemory { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + +export function getCurrentKeys(): UserKeyPairMemory | null { + if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; + return null; +} + +function saveKeys( + publicKey: Uint8Array, + privateKey: Uint8Array +) { + const encodedPublicKey = b64(publicKey); + const encodedPrivateKey = b64(privateKey); + + localStorage.setItem("publicKey", encodedPublicKey); + localStorage.setItem("privateKey", encodedPrivateKey); +} + +/** + * Checks if the current user is authenticated + */ +export async function checkAuth(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/check_auth`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to check auth"); + return await res.json(); +} + +/** + * Logs in a user with username and password + */ +export async function login(request: LoginRequest): Promise { + const res = await fetch(`${API_BASE_URL}/login`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Login failed" })); + throw new Error(error.detail || "Login failed"); + } + return await res.json(); +} + +/** + * Registers a new user + */ +export async function register(request: RegisterRequest): Promise { + const res = await fetch(`${API_BASE_URL}/register`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Registration failed" })); + throw new Error(error.detail || "Registration failed"); + } + return await res.json(); +} + +/** + * Logs out the current user + */ +export async function logout(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/logout`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to logout"); + return await res.json(); +} + +/** + * Derive a client-side authentication secret so the raw password never leaves the client. + * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + */ +export async function deriveAuthSecret(username: string, password: string): Promise { + // Use per-user salt derived from username; in future we can fetch a server-provided salt + const salt = new TextEncoder().encode(`fromchat.user:${username}`); + // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword + const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); + return b64(derived); +} + +export async function ensureKeysOnLogin(password: string, token: string): Promise { + // Try to restore from backup + const blobJson = await fetchBackupBlob(token); + 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(token); + 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, token); + const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(newBlob), token); + } + + saveKeys(currentPublicKey!, currentPrivateKey!); + + return { + publicKey: currentPublicKey!, + privateKey: currentPrivateKey! + }; + } + + // First-time setup: generate keys and upload + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey, token); + const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(encBlob), token); + + saveKeys(pair.publicKey, pair.privateKey); + + return pair; +} + +export function restoreKeys() { + currentPublicKey = ub64(localStorage.getItem("publicKey")!); + currentPrivateKey = ub64(localStorage.getItem("privateKey")!); +} + +export function getAuthToken(): string | null { + return localStorage.getItem("authToken"); +} + +/** + * Changes the user's password + */ +export async function changePassword( + token: string, + username: string, + currentPassword: string, + newPassword: string, + logoutAllExceptCurrent: boolean +): Promise { + const currentDerived = await deriveAuthSecret(username, currentPassword); + const newDerived = await deriveAuthSecret(username, newPassword); + const res = await fetch(`${API_BASE_URL}/change-password`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ + currentPasswordDerived: currentDerived, + newPasswordDerived: newDerived, + logoutAllExceptCurrent + }) + }); + if (!res.ok) throw new Error("Failed to change password"); +} + +/** + * Deletes the current user's account + */ +export async function deleteAccount(token: string): Promise<{ status: string; message: string }> { + const res = await fetch(`${API_BASE_URL}/account/delete`, { + method: "POST", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); + throw new Error(error.detail || "Failed to delete account"); + } + return await res.json(); +} + + diff --git a/frontend/src/core/api/user/devices.ts b/frontend/src/core/api/user/devices.ts new file mode 100644 index 0000000..b9844e5 --- /dev/null +++ b/frontend/src/core/api/user/devices.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./auth"; + +export interface DeviceInfo { + session_id: string; + device_name?: string; + device_type?: string; + os_name?: string; + os_version?: string; + browser_name?: string; + browser_version?: string; + brand?: string; + model?: string; + created_at?: string; + last_seen?: string; + revoked?: boolean; + current?: boolean; +} + +export async function list(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to fetch devices"); + const data = await res.json(); + return data.devices as DeviceInfo[]; +} + +export async function revoke(token: string, sessionId: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to revoke device"); +} + +export async function revokeAll(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to logout all devices"); +} + + diff --git a/frontend/src/core/api/user/profile.ts b/frontend/src/core/api/user/profile.ts new file mode 100644 index 0000000..ce3be53 --- /dev/null +++ b/frontend/src/core/api/user/profile.ts @@ -0,0 +1,191 @@ +import { getAuthHeaders } from "./auth"; +import { API_BASE_URL } from "@/core/config"; +import type { UserProfile } from "@/core/types"; + +export interface ProfileData { + profile_picture?: string; + username?: string; + display_name?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} + +/** + * Loads user profile data from the server + */ +export async function get(token: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + const data = await response.json(); + // Map backend fields to frontend fields + return { + profile_picture: data.profile_picture, + username: data.username, + display_name: data.display_name, + description: data.bio + }; + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +/** + * Uploads a profile picture to the server + */ +export async function uploadPicture(token: string, file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, { + method: 'POST', + body: formData, + headers: getAuthHeaders(token, false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +/** + * Updates user profile information + */ +export async function update(token: string, data: Partial): Promise { + try { + // Map frontend fields to backend fields + const backendData = { + username: data.username, + display_name: data.display_name, + description: data.description + }; + + const response = await fetch(`${API_BASE_URL}/user/profile`, { + method: 'PUT', + headers: { + ...getAuthHeaders(token, true), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(backendData) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} + +/** + * Updates user bio + */ +export async function updateBio(token: string, bio: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} + +/** + * Fetches user profile data by username + */ +export async function fetchByUsername(token: string, username: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile:', error); + return null; + } +} + +/** + * Fetches user profile data by user ID + */ +export async function fetchById(token: string, userId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile by ID:', error); + return null; + } +} + +/** + * In-memory cache for user similarity results + * Key: userId, Value: similarity result + */ +const similarityCache = new Map(); + +/** + * Checks if a user is similar to any verified user + * Results are cached in memory to avoid redundant API calls + */ +export async function checkSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + // Check cache first + if (similarityCache.has(userId)) { + return similarityCache.get(userId) ?? null; + } + + try { + const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + let result: {isSimilar: boolean, similarTo?: string} | null = null; + if (response.ok) { + result = await response.json(); + } + + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; + } catch (error) { + console.error('Error checking user similarity:', error); + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; + } +} + + diff --git a/frontend/src/core/api/user/search.ts b/frontend/src/core/api/user/search.ts new file mode 100644 index 0000000..6285506 --- /dev/null +++ b/frontend/src/core/api/user/search.ts @@ -0,0 +1,40 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./auth"; +import type { User } from "@/core/types"; + +/** + * Fetches a list of all users (excluding current user) + */ +export async function fetchUsers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Searches for users by username query + */ +export async function searchUsers(query: string, token: string): Promise { + if (query.length < 2) return []; + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Fetches a user by ID + */ +export async function get(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users/${userId}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return null; + return await res.json(); +} + + diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index e08c213..9ebc700 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy import { randomBytes } from "@/utils/crypto/kdf"; import { b64, ub64 } from "@/utils/utils"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { getCurrentKeys } from "@/core/api/account"; +import api from "@/core/api"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { @@ -186,7 +186,7 @@ const CALL_INFO = new Uint8Array([2]); * @returns Promise that resolves to the wrapped session key payload */ export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); const salt = randomBytes(16); @@ -209,7 +209,7 @@ export async function createSharedSecretAndDeriveSessionKey( sessionKeyHash: string, isInitiator: boolean ): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); // Create shared secret using ECDH @@ -226,7 +226,7 @@ export async function createSharedSecretAndDeriveSessionKey( * @returns Promise that resolves to the unwrapped session key */ export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); const salt = ub64(payload.salt); diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 470e42c..dbacf14 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,9 +1,7 @@ -import { getAuthToken } from "@/core/api/account"; +import api from "@/core/api"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; -import { getIceServers as fetchIceServers } from "@/core/api/webrtc"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { fetchUserPublicKey } from "@/core/api/dm"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -100,9 +98,9 @@ export class WebRTCCall { */ private async getIceServers(): Promise { try { - const token = getAuthToken(); + const token = api.user.auth.getAuthToken(); if (!token) throw new Error("No auth token"); - const data = await fetchIceServers(token); + const data = await api.calls.iceServers(token); return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); @@ -774,7 +772,7 @@ async function sendSignalingMessage(message: CallSignalingMessage) { type: "call_signaling", credentials: { scheme: "Bearer", - credentials: getAuthToken()! + credentials: api.user.auth.getAuthToken()! }, data: message }); @@ -857,7 +855,7 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string) export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { try { - const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!); + const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!); if (!recipientPublicKey) { console.warn("No recipient public key for", userId); return; @@ -892,7 +890,7 @@ export async function receiveWrappedSessionKey( sessionKeyHash?: string ): Promise { try { - const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!); + const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!); if (!senderPublicKey) { console.error("Failed to get sender public key"); return; diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index 6268a45..9ec282d 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { checkUserSimilarity } from "@/core/api/account/profile"; +import api from "@/core/api"; import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; @@ -18,7 +18,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro // Check similarity for unverified users useEffect(() => { if (!verified && userId && user.authToken) { - checkUserSimilarity(userId, user.authToken) + api.user.profile.checkSimilarity(userId, user.authToken) .then(result => { setIsSimilarToVerified(result?.isSimilar || false); }) diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 111a6ac..391b8e1 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { verifyUser } from "@/core/api/account/profile"; +import api from "@/core/api"; import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; @@ -23,7 +23,7 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB setIsVerifying(true); try { - const result = await verifyUser(userId, user.authToken); + const result = await api.moderation.users.verify(userId, user.authToken); if (result) { onVerificationChange?.(result.verified); } diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index ac94d75..7ca24cb 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,4 +1,4 @@ -import { subscribeToPush } from "@/core/api/push"; +import api from "@/core/api"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; @@ -89,7 +89,7 @@ async function sendSubscriptionToServer(token: string): Promise { }; try { - await subscribeToPush(subscriptionData, token); + await api.push.subscription.subscribe(subscriptionData, token); return true; } catch (error) { console.error("Failed to send subscription to server:", error); diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 2f74010..8e1410d 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -5,7 +5,7 @@ import { useImmer } from "use-immer"; import type { LoginRequest } from "@/core/types"; import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; +import api from "@/core/api"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; @@ -79,18 +79,18 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { setIsLoading(true); try { - const derived = await deriveAuthSecret(username, password); + const derived = await api.user.auth.deriveAuthSecret(username, password); const request: LoginRequest = { username: username, password: derived } try { - const data = await login(request); + const data = await api.user.auth.login(request); setUser(data.token, data.user); try { - await ensureKeysOnLogin(password, data.token); + await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 29961ed..81fdfa4 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -5,7 +5,7 @@ import { useImmer } from "use-immer"; import type { RegisterRequest } from "@/core/types"; import { useUserStore } from "@/state/user"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; +import api from "@/core/api"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; @@ -106,7 +106,7 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { setIsLoading(true); try { - const derived = await deriveAuthSecret(username, password); + const derived = await api.user.auth.deriveAuthSecret(username, password); const request: RegisterRequest = { display_name: displayName, username: username, @@ -115,11 +115,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { } try { - const data = await register(request); + const data = await api.user.auth.register(request); setUser(data.token, data.user); try { - await ensureKeysOnLogin(password, data.token); + await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 92fb749..579760c 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,14 +1,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; -import { - fetchUserPublicKey, - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - fetchDMConversations, - type DMConversationResponse -} from "@/core/api/dm"; +import api from "@/core/api"; +import type { ConversationResponse } from "@/core/api/chats/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -58,11 +52,11 @@ export function useDM() { try { // Get public key - const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken); if (!publicKey) return; // Get message history - const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(dmUser.id, user.authToken, 50); if (messages.length === 0) return; // Find last message @@ -70,7 +64,7 @@ export function useDM() { let lastPlaintext: string | null = null; try { - lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; + lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content; } catch (error) { console.error("Failed to decrypt last message:", error); } @@ -107,11 +101,11 @@ export function useDM() { usersLoadedRef.current = true; setIsLoadingUsers(true); try { - const conversations = await fetchDMConversations(user.authToken); + const conversations = await api.chats.dm.conversations(user.authToken); // Process conversations and decrypt last messages const dmUsersWithState: DMUser[] = await Promise.all( - conversations.map(async (conv: DMConversationResponse) => { + conversations.map(async (conv: ConversationResponse) => { let lastMessageContent: string | undefined = undefined; if (conv.lastMessage) { @@ -121,10 +115,10 @@ export function useDM() { ? conv.lastMessage.recipientId : conv.lastMessage.senderId; - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await decryptDm(conv.lastMessage, publicKey!); + const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); } @@ -143,7 +137,7 @@ export function useDM() { ); setDmUsersState(dmUsersWithState); - setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user)); + setDmUsers(conversations.map((conv: ConversationResponse) => conv.user)); } catch (error) { console.error("Failed to load DM conversations:", error); @@ -163,13 +157,13 @@ export function useDM() { setIsLoadingHistory(true); try { - const messages = await fetchDMHistory(userId, user.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50); const decryptedMessages: Message[] = []; let maxIncomingId = 0; for (const env of messages) { try { - const text = await decryptDm(env, publicKey); + const text = await api.chats.dm.decrypt(env, publicKey); const isAuthor = env.senderId !== userId; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; @@ -214,7 +208,7 @@ export function useDM() { if (!user.authToken) return; try { - await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken); + await api.chats.dm.send(recipientId, publicKey, content, user.authToken); } catch (error) { console.error("Failed to send DM:", error); } @@ -228,7 +222,7 @@ export function useDM() { // Get public key if not already loaded let publicKey = dmUser.publicKey; if (!publicKey) { - publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken); if (!publicKey) return; } @@ -257,7 +251,7 @@ export function useDM() { if (!user.authToken) return; try { - const conversations = await fetchDMConversations(user.authToken); + const conversations = await api.chats.dm.conversations(user.authToken); const userConversation = conversations.find(conv => conv.user.id === userId); if (userConversation) { @@ -270,10 +264,10 @@ export function useDM() { ? userConversation.lastMessage.recipientId : userConversation.lastMessage.senderId; - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await decryptDm(userConversation.lastMessage, publicKey!); + const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); } @@ -322,9 +316,9 @@ export function useDM() { // Update unread count and last message preview try { - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); @@ -352,9 +346,9 @@ export function useDM() { } const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; try { - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index 9a8abff..9c7d1af 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { useUserStore } from "@/state/user"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; +import api from "@/core/api"; +import type { ProfileData } from "@/core/api/user/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { @@ -15,7 +16,7 @@ export default function useProfile() { setIsLoading(true); try { - const data = await loadProfile(user.authToken); + const data = await api.user.profile.get(user.authToken); if (data) { setProfileData(data); } @@ -33,7 +34,7 @@ export default function useProfile() { setIsUpdating(true); try { - const success = await updateProfile(user.authToken, data); + const success = await api.user.profile.update(user.authToken, data); if (success) { // Reload profile data to get updated information await loadProfileData(); @@ -58,7 +59,7 @@ export default function useProfile() { setIsUpdating(true); try { - const result = await uploadProfilePicture(user.authToken, file); + const result = await api.user.profile.uploadPicture(user.authToken, file); if (result) { // Update profile data with new picture URL setProfileData(prev => prev ? { diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 3c489a7..89a9795 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; +import api from "@/core/api"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { @@ -43,10 +43,10 @@ export default function ChatPage() { if (profileInfo.userId) { // Fetch by user ID - userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId); + userProfile = await api.user.profile.fetchById(user.authToken, profileInfo.userId); } else if (profileInfo.username) { // Fetch by username - userProfile = await fetchUserProfile(user.authToken, profileInfo.username); + userProfile = await api.user.profile.fetchByUsername(user.authToken, profileInfo.username); } if (userProfile) { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 34234d0..0a52dab 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -5,7 +5,7 @@ import type { ProfileDialogData } from "@/state/types"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; -import { updateProfile, uploadProfilePicture, fetchUserProfileById, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile"; +import api from "@/core/api"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -98,7 +98,7 @@ export function ProfileDialog() { // If it's not the public chat and has a user ID, fetch fresh data if (profileData.userId && profileData.username !== "Общий чат") { - const userProfile = await fetchUserProfileById(user.authToken, profileData.userId); + const userProfile = await api.user.profile.fetchById(user.authToken, profileData.userId); if (userProfile) { freshData = { ...userProfile, @@ -285,7 +285,7 @@ export function ProfileDialog() { } if (Object.keys(updateData).length > 0) { - await updateProfile(user.authToken, updateData); + await api.user.profile.update(user.authToken, updateData); } // Update profile picture if changed @@ -294,7 +294,7 @@ export function ProfileDialog() { if (currentData.profilePicture.startsWith("data:")) { const response = await fetch(currentData.profilePicture); const blob = await response.blob(); - await uploadProfilePicture(user.authToken, blob); + await api.user.profile.uploadPicture(user.authToken, blob); } } @@ -351,7 +351,7 @@ export function ProfileDialog() { }); if (reason) { - const result = await suspendUser(currentData.userId, reason, user.authToken!); + const result = await api.moderation.users.suspend(currentData.userId, reason, user.authToken!); if (result) { closeProfileDialog(); } else { @@ -360,7 +360,7 @@ export function ProfileDialog() { } } else { // Unsuspend user - const result = await unsuspendUser(currentData.userId, user.authToken!); + const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!); if (result) { closeProfileDialog(); } else { @@ -383,7 +383,7 @@ export function ProfileDialog() { cancelText: "Cancel" }); - const result = await deleteUser(currentData.userId, user.authToken!); + const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!); if (result) { closeProfileDialog(); diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index a58c17e..485db7a 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -2,8 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; -import { fetchMessages } from "@/core/api/messaging"; -import { fetchUserPublicKey } from "@/core/api/dm"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -52,7 +51,7 @@ export function UnifiedChatsList() { if (!user.authToken) return; try { - const messages = await fetchMessages(user.authToken, 1); + const { messages } = await api.chats.general.fetchMessages(user.authToken, 1); if (messages?.length > 0) { const lastMessage = messages[messages.length - 1]; setLastMessages({ general: lastMessage }); @@ -160,7 +159,7 @@ export function UnifiedChatsList() { const authToken = useUserStore.getState().user.authToken; if (!authToken) return; - const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); + const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken); if (!publicKey) { console.error("Failed to get public key for user:", dmConversation.id); return; diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index fc7d60b..7184e46 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -45,7 +45,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use const newTimeout = setTimeout(async () => { if (user.authToken) { try { - const users = await searchUsers(searchQuery, user.authToken); + const users = await api.user.search.searchUsers(searchQuery, user.authToken); setSearchResults(users); } catch (error) { console.error("Search failed:", error); @@ -118,7 +118,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use let publicKey = searchUser.publicKey; if (!publicKey) { - const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken); + const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken); publicKey = fetchedPublicKey; } diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index db428f3..855d0fc 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,6 +1,6 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; import { useUserStore } from "@/state/user"; -import { deleteAccount } from "@/core/api/account"; +import api from "@/core/api"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -23,7 +23,7 @@ export function AccountPanel({ onClose }: AccountPanelProps) { cancelText: "Cancel" }); - await deleteAccount(authToken); + await api.user.auth.deleteAccount(authToken); logout(); onClose(); } catch (error) { diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index cc3c52c..c56058f 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; import { useUserStore } from "@/state/user"; -import { changePassword } from "@/core/api/account"; +import api from "@/core/api"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; @@ -29,7 +29,7 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro if (!current || !next || next !== confirm) return; setBusy(true); try { - await changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll); + await api.user.auth.changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll); setCurrent(""); setNext(""); setConfirm(""); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index 3a5e252..810611c 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; import { useUserStore } from "@/state/user"; -import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; +import api from "@/core/api"; +import type { DeviceInfo } from "@/core/api/user/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -24,7 +25,7 @@ export function DevicesPanel() { setDevicesLoading(true); try { - const deviceList = await listDevices(authToken); + const deviceList = await api.user.devices.list(authToken); updateDevices(deviceList); } catch (error) { console.error("Failed to load devices:", error); @@ -48,7 +49,7 @@ export function DevicesPanel() { draft.add(sessionId); }); - await revokeDevice(authToken, sessionId); + await api.user.devices.revoke(authToken, sessionId); await loadDevices(); } catch (error) { if (error !== "cancelled") { @@ -72,7 +73,7 @@ export function DevicesPanel() { cancelText: "Cancel" }); - await logoutAllOtherDevices(authToken); + await api.user.devices.revokeAll(authToken); await loadDevices(); } catch (error) { if (error !== "cancelled") { diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index 3b60c2d..ea84bf7 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -3,7 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from import { useUserStore } from "@/state/user"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -import { unsubscribeFromPush } from "@/core/api/push"; +import api from "@/core/api"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { @@ -73,7 +73,7 @@ export function NotificationsPanel() { } // Then unsubscribe from server - await unsubscribeFromPush(authToken); + await api.push.subscription.unsubscribe(authToken); // After unsubscribing, permission is still granted but we're not subscribed // So we keep the state as disabled (false) diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index a87837e..79fdd3d 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote"; import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; -import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; +import api from "@/core/api"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; @@ -223,14 +222,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { - headers: getAuthHeaders(user.authToken!) + headers: api.user.auth.getAuthHeaders(user.authToken!) }); if (!response.ok) throw new Error("Failed to fetch file"); const encryptedData = await response.arrayBuffer(); // Get current user's keys - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); // Derive shared secret with the recipient's public key @@ -343,7 +342,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // Fetch with credentials/headers when not a blob URL const response = await fetch(src, { - headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download image"); @@ -381,7 +380,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // If not decrypted or public file, fetch with credentials/headers const response = await fetch(file.path, { - headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download file"); @@ -405,7 +404,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD if (!user.authToken || !message.user_id) return; try { - const userProfile = await fetchUserProfileById(user.authToken, message.user_id); + const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id); if (userProfile) { setProfileDialog({ ...userProfile, @@ -434,9 +433,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD let userProfile; if (profileLink.userId) { - userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); - } else if (profileLink.username) { - userProfile = await fetchUserProfile(user.authToken, profileLink.username); + userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId); + } else if (profileLink.username) { + userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username); } if (userProfile) { diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 1591e5b..b300709 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -1,13 +1,5 @@ import { MessagePanel } from "./MessagePanel"; -import { - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - sendDmWithFiles, - editDmEnvelope, - deleteDmEnvelope -} from "@/core/api/dm"; -import { fetchUserProfileById } from "@/core/api/account/profile"; +import api from "@/core/api"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; @@ -63,7 +55,7 @@ export class DMPanel extends MessagePanel { } private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { - const plaintext = await decryptDm(env, this.dmData!.publicKey); + const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey); const username = formatDMUsername( env.senderId, env.recipientId, @@ -111,7 +103,7 @@ export class DMPanel extends MessagePanel { this.setLoading(true); try { - const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, 50); const decryptedMessages: Message[] = []; let maxIncomingId = 0; @@ -157,14 +149,14 @@ export class DMPanel extends MessagePanel { const json = JSON.stringify(payload); if (files.length === 0) { - await sendDMViaWebSocket( + await api.chats.dm.send( this.dmData.userId, this.dmData.publicKey, json, this.currentUser.authToken ); } else { - await sendDmWithFiles( + await api.chats.dm.sendWithFiles( this.dmData.userId, this.dmData.publicKey, json, @@ -228,7 +220,7 @@ export class DMPanel extends MessagePanel { const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; try { // Decrypt new content in-place - const plaintext = await decryptDm( + const plaintext = await api.chats.dm.decrypt( { id, senderId: 0, @@ -330,7 +322,7 @@ export class DMPanel extends MessagePanel { this.deleteMessageImmediately(messageId); // Fire and forget server deletion; UI already updated - await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken); + await api.chats.dm.deleteMessage(messageId, this.dmData.userId, this.currentUser.authToken); } async handleEditMessage(messageId: number, content: string): Promise { @@ -345,7 +337,7 @@ export class DMPanel extends MessagePanel { reply_to_id: msg?.reply_to?.id ?? undefined } }; - editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { + api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { console.error("Failed to edit DM:", e); }); } @@ -354,7 +346,7 @@ export class DMPanel extends MessagePanel { if (!this.dmData || !this.currentUser.authToken) return null; try { - const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId); + const userProfile = await api.user.profile.fetchById(this.currentUser.authToken, this.dmData.userId); if (!userProfile) return null; return { diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index 3e9e216..3457758 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel"; import { request } from "@/core/websocket"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/state/types"; -import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; +import api from "@/core/api"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -41,7 +41,7 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const messages = await fetchMessages(this.currentUser.authToken); + const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken); if (messages && messages.length > 0) { this.clearMessages(); messages.forEach((msg: Message) => { @@ -61,9 +61,9 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { - await sendMessage(content, replyToId ?? null, this.currentUser.authToken); + await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken); } else { - await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); + await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } catch (error) { console.error("Error sending message:", error); diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index bffd7ca..3d2675d 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -1,9 +1,8 @@ import { create } from "zustand"; import type { User } from "@/core/types"; import { request } from "@/core/websocket"; -import { restoreKeys } from "@/core/api/account"; +import api from "@/core/api"; import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/account"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -84,11 +83,11 @@ export const useUserStore = create((set) => ({ if (token) { const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (fullResponse.ok) { const user: User = await fullResponse.json(); - restoreKeys(); + api.user.auth.restoreKeys(); if (user.suspended) { set({ From 857365361d5400f838ee17141190714db6feeb54 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 16:29:37 +0300 Subject: [PATCH 2/9] Implement robust reconnection system, updates, optimize typing --- backend/models.py | 15 + backend/routes/messaging.py | 583 ++++++++++++++---- frontend/src/core/updateManager.ts | 126 ++++ frontend/src/core/websocket.ts | 130 +++- .../chat/ui/right/MessagePanelRenderer.tsx | 108 +++- .../src/pages/chat/ui/right/panels/DMPanel.ts | 48 +- .../chat/ui/right/panels/MessagePanel.ts | 27 +- .../chat/ui/right/panels/PublicChatPanel.ts | 33 +- frontend/src/state/user.ts | 25 +- package.json | 1 + 10 files changed, 892 insertions(+), 204 deletions(-) create mode 100644 frontend/src/core/updateManager.ts diff --git a/backend/models.py b/backend/models.py index 8d581ee..5f4c71e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -283,5 +283,20 @@ class DMReactionResponse(BaseModel): from_attributes = True +class UpdateLog(Base): + """Stores update sequence numbers and updates for gap detection""" + __tablename__ = "update_log" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + sequence = Column(Integer, nullable=False, index=True) + updates = Column(Text, nullable=False) # JSON array of updates + timestamp = Column(DateTime, default=datetime.now, index=True) + + __table_args__ = ( + UniqueConstraint("user_id", "sequence", name="uq_user_sequence"), + ) + + # Tables are now created through Alembic migrations # Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index bd75081..b9ff6f8 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -19,7 +19,7 @@ from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from .account import convert_user from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog from push_service import push_service from PIL import Image import io @@ -360,7 +360,7 @@ async def _send_message_internal( await messagingManager.broadcast({ "type": "newMessage", "data": convert_message(new_message) - }) + }, db) except Exception: pass @@ -798,7 +798,7 @@ async def add_reaction( "username": current_user.username, "reactions": message_data["reactions"] } - }) + }, db) except Exception: pass @@ -871,7 +871,7 @@ async def add_dm_reaction( "username": current_user.username, "reactions": envelope_data["reactions"] } - }) + }, db) except Exception: pass @@ -894,12 +894,193 @@ class MessaggingSocketManager: self.online_users: set[int] = set() self.typing_users: dict[int, float] = {} # user_id -> timestamp self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp} + self.typing_state: dict[int, bool] = {} # user_id -> is_typing (for public chat) + self.dm_typing_state: dict[int, dict[int, bool]] = {} # user_id -> {recipient_id -> is_typing} self.ws_subscriptions: dict[WebSocket, set[int]] = {} # websocket -> set of subscribed user_ids self._cleanup_task = None + # Update system: sequence numbers and batching + self.sequence_numbers: dict[int, int] = {} # user_id -> current sequence number + self.pending_updates: dict[WebSocket, list[dict]] = {} # websocket -> list of pending updates + self.update_batch_tasks: dict[WebSocket, asyncio.Task] = {} # websocket -> batch task + self.last_seq_by_ws: dict[WebSocket, int] = {} # websocket -> last received sequence number + self.stored_sequences: dict[tuple[int, int], bool] = {} # (user_id, sequence) -> stored flag + self.recent_updates: dict[WebSocket, set[str]] = {} # websocket -> set of recent update signatures + self._sequence_lock: dict[int, asyncio.Lock] = {} # user_id -> lock for sequence generation 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}}) + async def _get_next_sequence(self, user_id: int) -> int: + """Get the next sequence number for a user (shared across all their connections) - thread-safe""" + if user_id not in self._sequence_lock: + self._sequence_lock[user_id] = asyncio.Lock() + + async with self._sequence_lock[user_id]: + if user_id not in self.sequence_numbers: + self.sequence_numbers[user_id] = 0 + self.sequence_numbers[user_id] += 1 + return self.sequence_numbers[user_id] + + def _get_update_signature(self, update: dict) -> str: + """Generate a unique signature for an update to detect duplicates""" + import hashlib + import json + + update_type = update.get("type", "") + data = update.get("data", {}) + + # Create signature based on update type and key identifying fields + if update_type == "newMessage": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "messageEdited": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "messageDeleted": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id") or data.get("message_id")} + elif update_type == "dmNew": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "dmEdited": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "dmDeleted": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "reactionUpdate": + # Deduplicate by message ID + emoji + user ID + sig_data = {"type": update_type, "messageId": data.get("message_id"), "emoji": data.get("emoji"), "userId": data.get("userId")} + elif update_type == "dmReactionUpdate": + # Deduplicate by envelope ID + emoji + user ID + sig_data = {"type": update_type, "dmEnvelopeId": data.get("dm_envelope_id"), "emoji": data.get("emoji"), "userId": data.get("userId")} + elif update_type == "typing" or update_type == "stopTyping": + # Deduplicate by user ID (state tracking already handles this, but extra protection) + sig_data = {"type": update_type, "userId": data.get("userId")} + elif update_type == "dmTyping" or update_type == "stopDmTyping": + # Deduplicate by user ID (recipient ID is implicit - this update is sent TO the recipient) + sig_data = {"type": update_type, "userId": data.get("userId")} + elif update_type == "statusUpdate": + # Deduplicate by user ID + sig_data = {"type": update_type, "userId": data.get("userId")} + else: + # For unknown types, use full data (less efficient but safe) + sig_data = {"type": update_type, "data": data} + + # Create hash of signature data + sig_json = json.dumps(sig_data, sort_keys=True) + return hashlib.md5(sig_json.encode()).hexdigest() + + def _add_update(self, websocket: WebSocket, update: dict): + """Add an update to the pending batch for a WebSocket (with deduplication)""" + if websocket not in self.pending_updates: + self.pending_updates[websocket] = [] + + # Check for duplicates + signature = self._get_update_signature(update) + if websocket not in self.recent_updates: + self.recent_updates[websocket] = set() + + # Skip if this exact update was recently added + if signature in self.recent_updates[websocket]: + return + + # Add to pending updates and track signature + self.pending_updates[websocket].append(update) + self.recent_updates[websocket].add(signature) + + # Limit recent updates cache size (keep last 100 signatures per websocket) + if len(self.recent_updates[websocket]) > 100: + # Remove oldest entries (simple FIFO by converting to list and keeping last 100) + # Actually, we'll just clear and rebuild on next flush - simpler approach + pass + + async def _flush_updates(self, websocket: WebSocket, db: Session | None = None): + """Flush pending updates for a WebSocket connection""" + if websocket not in self.pending_updates or not self.pending_updates[websocket]: + return + + updates = self.pending_updates[websocket] + self.pending_updates[websocket] = [] + + # Clear recent updates cache after flushing (updates are now sent, can be re-added if needed) + if websocket in self.recent_updates: + # Keep only the last 50 signatures to allow some deduplication across batches + recent_list = list(self.recent_updates[websocket]) + if len(recent_list) > 50: + self.recent_updates[websocket] = set(recent_list[-50:]) + else: + # Keep all if under limit + pass + + if updates: + user_id = self.user_by_ws.get(websocket) + if not user_id: + # No user associated - this shouldn't happen for authenticated connections + # Skip sending to avoid seq: 0 issues + logger.warning(f"Attempted to flush updates for unauthenticated websocket, skipping") + return + + seq = await self._get_next_sequence(user_id) + + # Store updates in database for gap detection (only once per user per sequence) + if db: + sequence_key = (user_id, seq) + # Double-check pattern: check again after getting sequence (in case another connection got the same sequence) + if sequence_key not in self.stored_sequences: + try: + import json + # Store the entire batch as a single record + update_log = UpdateLog( + user_id=user_id, + sequence=seq, + updates=json.dumps(updates) + ) + db.add(update_log) + db.commit() + self.stored_sequences[sequence_key] = True + except Exception as e: + # Always rollback on error to reset session state + try: + db.rollback() + except Exception: + pass # Ignore rollback errors + + # If we get a UNIQUE constraint error, it means another connection already stored this sequence + if "UNIQUE constraint" in str(e) or "IntegrityError" in str(e.__class__.__name__): + # Mark as stored to prevent future attempts + self.stored_sequences[sequence_key] = True + logger.debug(f"Update sequence {seq} for user {user_id} already stored by another connection") + else: + logger.error(f"Failed to store updates in database: {e}") + else: + # Already stored, skip + logger.debug(f"Update sequence {seq} for user {user_id} already marked as stored") + + await websocket.send_json({ + "type": "updates", + "seq": seq, + "updates": updates + }) + + async def _schedule_batch_flush(self, websocket: WebSocket, db: Session | None = None): + """Schedule a batch flush after a delay (50-100ms)""" + if websocket in self.update_batch_tasks: + self.update_batch_tasks[websocket].cancel() + + async def flush_after_delay(): + await asyncio.sleep(0.075) # 75ms delay for batching + await self._flush_updates(websocket, db) + if websocket in self.update_batch_tasks: + del self.update_batch_tasks[websocket] + + self.update_batch_tasks[websocket] = asyncio.create_task(flush_after_delay()) + + async def _send_update(self, websocket: WebSocket, update_type: str, update_data: dict, db: Session | None = None): + """Send an update (will be batched)""" + self._add_update(websocket, {"type": update_type, "data": update_data}) + await self._schedule_batch_flush(websocket, db) + async def handle_connection(self, websocket: WebSocket, db: Session): # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() @@ -926,11 +1107,43 @@ class MessaggingSocketManager: ) while True: - data = await websocket.receive_json() + try: + data = await websocket.receive_json() + except Exception as e: + logger.error(f"Error receiving WebSocket message: {e}") + break + type = data["type"] def get_current_user_inner() -> User | None: - if data["credentials"]: + try: + # Ensure session is in a usable state before querying + try: + db.rollback() + except Exception: + pass + + if data.get("credentials"): + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() + return get_current_user( + dummy_request, + HTTPAuthorizationCredentials( + scheme=data["credentials"]["scheme"], + credentials=data["credentials"]["credentials"] + ), + db + ) + else: + return None + except Exception as e: + logger.error(f"Error getting current user: {e}") + try: + db.rollback() + except Exception: + pass + return None + if data.get("credentials"): dummy_request = SimpleNamespace() dummy_request.state = SimpleNamespace() return get_current_user( @@ -944,7 +1157,63 @@ class MessaggingSocketManager: else: return None - if type == "ping": + if type == "getUpdates": + # Handle gap detection - client requests updates from a specific sequence number + current_user: User | None = None + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + last_seq = data.get("data", {}).get("lastSeq", 0) + self.last_seq_by_ws[websocket] = last_seq + current_seq = self.sequence_numbers.get(current_user.id, 0) + + # Query database for missed updates + missed_updates = [] + if last_seq > 0 and last_seq < current_seq: + try: + import json + # Get all updates between last_seq and current_seq + update_logs = db.query(UpdateLog).filter( + UpdateLog.user_id == current_user.id, + UpdateLog.sequence > last_seq, + UpdateLog.sequence <= current_seq + ).order_by(UpdateLog.sequence.asc()).all() + + # Each log entry contains a batch of updates with the same sequence number + for log in update_logs: + updates = json.loads(log.updates) + missed_updates.append({ + "seq": log.sequence, + "updates": updates + }) + except Exception as e: + logger.error(f"Failed to retrieve missed updates: {e}") + + # Send missed updates + for batch in missed_updates: + await websocket.send_json({ + "type": "updates", + "seq": batch["seq"], + "updates": batch["updates"] + }) + + await websocket.send_json({ + "type": "getUpdates", + "data": { + "status": "ok", + "lastSeq": current_seq, + "missedCount": len(missed_updates) + } + }) + # Update the websocket's last sequence tracking + self.last_seq_by_ws[websocket] = current_seq + _log_ws("getUpdates", current_user, last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) + except HTTPException as e: + _log_ws("getUpdates_error", current_user, detail=str(getattr(e, "detail", e))) + await self.send_error(websocket, type, e) + elif type == "ping": current_user: User | None = None try: current_user = get_current_user_inner() @@ -957,7 +1226,7 @@ class MessaggingSocketManager: # Add to online users self.online_users.add(current_user.id) # Broadcast status change - await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat()) + await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat(), db) else: await websocket.send_json({ "type": "ping", @@ -1012,7 +1281,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "newMessage", "data": response["message"] - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) @@ -1067,9 +1336,9 @@ class MessaggingSocketManager: except Exception as e: logger.error(f"Failed to send push notification for DM {env.id}: {e}") - await self.send_to_user(env.recipient_id, payload); + await self.send_update_to_user(env.recipient_id, "dmNew", payload["data"], db); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); - await self.send_to_user(env.sender_id, payload); + await self.send_update_to_user(env.sender_id, "dmNew", payload["data"], db); _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) log_dm( @@ -1097,7 +1366,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "messageEdited", "data": response["message"] - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("editMessage", current_user, message_id=message_id) @@ -1142,8 +1411,8 @@ class MessaggingSocketManager: "timestamp": env.timestamp.isoformat(), } } - await self.send_to_user(env.recipient_id, payload_ws) - await self.send_to_user(env.sender_id, payload_ws) + await self.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) + await self.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) _log_ws("dmEdit", current_user, dm_envelope_id=env.id) @@ -1182,9 +1451,9 @@ class MessaggingSocketManager: "recipientId": payload.get("recipientId") } } - await self.send_to_user(env.recipient_id, payload_ws) + await self.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) - await self.send_to_user(env.sender_id, payload_ws) + await self.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) _log_ws("dmDelete", current_user, dm_envelope_id=env_id) log_dm( @@ -1209,7 +1478,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "messageDeleted", "data": {"message_id": message_id} - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("deleteMessage", current_user, message_id=message_id) @@ -1242,7 +1511,7 @@ class MessaggingSocketManager: "username": current_user.username, "reactions": response["reactions"] } - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) @@ -1275,7 +1544,7 @@ class MessaggingSocketManager: "username": current_user.username, "reactions": response["reactions"] } - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) @@ -1328,15 +1597,12 @@ class MessaggingSocketManager: # Ensure sender is set by the server payload["fromUserId"] = current_user.id - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": { - "type": "call_video_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - } - }) + await self.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_video_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) except HTTPException as e: @@ -1361,15 +1627,12 @@ class MessaggingSocketManager: # Ensure sender is set by the server payload["fromUserId"] = current_user.id - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": { - "type": "call_screen_share_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - } - }) + await self.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_screen_share_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) except HTTPException as e: @@ -1431,18 +1694,23 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) + was_typing = self.typing_state.get(current_user.id, False) self.typing_users[current_user.id] = time.time() + is_now_typing = True - # Broadcast to all connected users - await self.broadcast({ - "type": "typing", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) + # Only send update if state changed (started typing) + if not was_typing: + self.typing_state[current_user.id] = True + # Broadcast to all connected users + await self.broadcast({ + "type": "typing", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }, db) - await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) @@ -1455,19 +1723,23 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) + was_typing = self.typing_state.get(current_user.id, False) if current_user.id in self.typing_users: del self.typing_users[current_user.id] - # Broadcast to all connected users - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) + # Only send update if state changed (stopped typing) + if was_typing: + self.typing_state[current_user.id] = False + # Broadcast to all connected users + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }, db) - await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) @@ -1483,18 +1755,22 @@ class MessaggingSocketManager: if current_user.id not in self.dm_typing_users: self.dm_typing_users[current_user.id] = {} + if current_user.id not in self.dm_typing_state: + self.dm_typing_state[current_user.id] = {} + + was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) self.dm_typing_users[current_user.id][recipient_id] = time.time() - # Send only to recipient - await self.send_to_user(recipient_id, { - "type": "dmTyping", - "data": { + # Only send update if state changed (started typing) + if not was_typing: + self.dm_typing_state[current_user.id][recipient_id] = True + # Send only to recipient + await self.send_update_to_user(recipient_id, "dmTyping", { "userId": current_user.id, "username": current_user.username - } - }) + }, db) - await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: await self.send_error(websocket, type, e) elif type == "stopDmTyping": @@ -1505,21 +1781,26 @@ class MessaggingSocketManager: recipient_id = int(data["data"]["recipientId"]) + was_typing = False + if current_user.id in self.dm_typing_state: + was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) + if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]: del self.dm_typing_users[current_user.id][recipient_id] if not self.dm_typing_users[current_user.id]: del self.dm_typing_users[current_user.id] - # Send only to recipient - await self.send_to_user(recipient_id, { - "type": "stopDmTyping", - "data": { + # Only send update if state changed (stopped typing) + if was_typing: + if current_user.id in self.dm_typing_state: + self.dm_typing_state[current_user.id][recipient_id] = False + # Send only to recipient + await self.send_update_to_user(recipient_id, "stopDmTyping", { "userId": current_user.id, "username": current_user.username - } - }) + }, db) - await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: await self.send_error(websocket, type, e) else: @@ -1540,6 +1821,9 @@ class MessaggingSocketManager: ip=client_ip, ) self.connections.append(websocket) + # Initialize update system for this connection + self.pending_updates[websocket] = [] + self.last_seq_by_ws[websocket] = 0 try: await self.handle_connection(websocket, db) except WebSocketDisconnect as e: @@ -1553,69 +1837,96 @@ class MessaggingSocketManager: reason=e.reason, ) finally: + # Flush any pending updates before disconnecting + if websocket in self.pending_updates: + await self._flush_updates(websocket, db) + # Cancel any pending batch tasks + if websocket in self.update_batch_tasks: + self.update_batch_tasks[websocket].cancel() + del self.update_batch_tasks[websocket] # Cleanup connection self.connections.remove(websocket) if websocket in self.user_by_ws: user_id = self.user_by_ws[websocket] # Set user offline in DB - user = db.query(User).filter(User.id == user_id).first() - if user: - user.online = False - user.last_seen = datetime.now() - db.commit() - # Remove from online users - self.online_users.discard(user_id) - # Broadcast status change - await self.broadcast_status_change(user_id, False, user.last_seen.isoformat()) - del self.user_by_ws[websocket] + try: + # Ensure session is in a usable state + try: + db.rollback() + except Exception: + pass + + user = db.query(User).filter(User.id == user_id).first() + if user: + user.online = False + user.last_seen = datetime.now() + db.commit() + # Remove from online users + self.online_users.discard(user_id) + # Broadcast status change + await self.broadcast_status_change(user_id, False, user.last_seen.isoformat(), db) + except Exception as e: + logger.error(f"Failed to set user offline during cleanup: {e}") + try: + db.rollback() + except Exception: + pass + finally: + del self.user_by_ws[websocket] # Cleanup subscriptions if websocket in self.ws_subscriptions: del self.ws_subscriptions[websocket] + # Cleanup update system + if websocket in self.pending_updates: + del self.pending_updates[websocket] + if websocket in self.last_seq_by_ws: + del self.last_seq_by_ws[websocket] + if websocket in self.recent_updates: + del self.recent_updates[websocket] - async def broadcast(self, message: dict): + async def broadcast(self, message: dict, db: Session | None = None): + """Broadcast a message to all authenticated connections as an update (batched)""" + message_type = message.get("type", "") + update_data = message.get("data", {}) for websocket in self.connections: - await websocket.send_json(message) + # Only send to authenticated websockets (those with user_id set) + if websocket in self.user_by_ws: + await self._send_update(websocket, message_type, update_data, db) + + async def send_update_to_user(self, user_id: int, update_type: str, update_data: dict, db: Session | None = None): + """Send an update to a specific user (batched)""" + for websocket in self.connections: + if self.user_by_ws.get(websocket) == user_id: + await self._send_update(websocket, update_type, update_data, db) async def send_to_user(self, user_id: int, message: dict): + """Send a direct WebSocket message to a specific user (not batched)""" for websocket in self.connections: if self.user_by_ws.get(websocket) == user_id: await websocket.send_json(message) async def send_suspension_to_user(self, user_id: int, reason: str): - """Send suspension message to user's WebSocket connections""" - message = { - "type": "suspended", - "data": { - "reason": reason - } - } - await self.send_to_user(user_id, message) + """Send suspension message to user's WebSocket connections (as batched update)""" + await self.send_update_to_user(user_id, "suspended", { + "reason": reason + }) async def send_deletion_to_user(self, user_id: int): - """Send account deletion message to user's WebSocket connections""" - message = { - "type": "account_deleted", - "data": {} - } - await self.send_to_user(user_id, message) + """Send account deletion message to user's WebSocket connections (as batched update)""" + await self.send_update_to_user(user_id, "account_deleted", {}) - async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str): + async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str, db: Session | None = None): """Broadcast status change to all connections that are subscribed to this user""" - message = { - "type": "statusUpdate", - "data": { - "userId": user_id, - "online": online, - "lastSeen": last_seen - } - } - # Send to all connections that have this user in their subscriptions for websocket in self.connections: if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]: - await websocket.send_json(message) + await self._send_update(websocket, "statusUpdate", { + "userId": user_id, + "online": online, + "lastSeen": last_seen + }, db) - async def cleanup_stale_typing_indicators(self): + async def cleanup_stale_typing_indicators(self, db: Session): """Periodically cleanup typing indicators that haven't been updated in 3+ seconds""" while True: try: @@ -1629,15 +1940,23 @@ class MessaggingSocketManager: ] for user_id in stale_public_typing: + was_typing = self.typing_state.get(user_id, False) del self.typing_users[user_id] - # Broadcast stop typing - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": user_id, - "username": "Unknown" # We don't have username here, frontend will handle - } - }) + + # Only send update if state changed (stopped typing) + if was_typing: + self.typing_state[user_id] = False + # Get username from database + user = db.query(User).filter(User.id == user_id).first() + username = user.username if user else "Unknown" + # Broadcast stop typing + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": user_id, + "username": username + } + }, db) # Cleanup DM typing indicators stale_dm_typing = [] @@ -1647,18 +1966,27 @@ class MessaggingSocketManager: stale_dm_typing.append((user_id, recipient_id)) for user_id, recipient_id in stale_dm_typing: + was_typing = False + if user_id in self.dm_typing_state: + was_typing = self.dm_typing_state[user_id].get(recipient_id, False) + if user_id in self.dm_typing_users and recipient_id in self.dm_typing_users[user_id]: del self.dm_typing_users[user_id][recipient_id] if not self.dm_typing_users[user_id]: del self.dm_typing_users[user_id] + + # Only send update if state changed (stopped typing) + if was_typing: + if user_id in self.dm_typing_state: + self.dm_typing_state[user_id][recipient_id] = False + # Get username from database + user = db.query(User).filter(User.id == user_id).first() + username = user.username if user else "Unknown" # Send stop typing to recipient - await self.send_to_user(recipient_id, { - "type": "stopDmTyping", - "data": { - "userId": user_id, - "username": "Unknown" # We don't have username here, frontend will handle - } - }) + await self.send_update_to_user(recipient_id, "stopDmTyping", { + "userId": user_id, + "username": username + }, db) # Wait 1 second before next cleanup await asyncio.sleep(1.0) @@ -1669,7 +1997,16 @@ class MessaggingSocketManager: def start_cleanup_task(self): """Start the cleanup task if not already running""" if self._cleanup_task is None or self._cleanup_task.done(): - self._cleanup_task = asyncio.create_task(self.cleanup_stale_typing_indicators()) + from db import SessionLocal + async def cleanup_with_db(): + while True: + try: + with SessionLocal() as db: + await self.cleanup_stale_typing_indicators(db) + except Exception as e: + logger.error(f"Error in cleanup task wrapper: {e}") + await asyncio.sleep(1.0) + self._cleanup_task = asyncio.create_task(cleanup_with_db()) messagingManager = MessaggingSocketManager() diff --git a/frontend/src/core/updateManager.ts b/frontend/src/core/updateManager.ts new file mode 100644 index 0000000..ae89c32 --- /dev/null +++ b/frontend/src/core/updateManager.ts @@ -0,0 +1,126 @@ +/** + * @fileoverview Update Manager for Telegram-like update system + * @description Handles update sequence numbers, batching, and gap detection + * @author Cursor + * @version 1.0.0 + */ + +import { openDB, type IDBPDatabase } from "idb"; +import type { WebSocketCredentials, WebSocketMessage } from "./types"; + +interface UpdateMessage { + type: string; + data: T; +} + +interface BatchedUpdatesMessage { + type: "updates"; + seq: number; + updates: UpdateMessage[]; +} + +const DB_NAME = "fromchat-updates"; +const DB_VERSION = 1; +const STORE_NAME = "lastSequence"; + +let db: IDBPDatabase | null = null; + +/** + * Initialize IndexedDB for storing last sequence number + */ +async function initDB(): Promise { + if (db) return db; + + db = await openDB(DB_NAME, DB_VERSION, { + upgrade(database) { + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME); + } + } + }); + + return db; +} + +/** + * Get the last received sequence number from IndexedDB + */ +export async function getLastSequence(): Promise { + try { + return (await initDB()) + .transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .get("lastSeq") || 0; + } catch (error) { + console.error("Failed to get last sequence:", error); + return 0; + } +} + +/** + * Store the last received sequence number in IndexedDB + */ +export async function setLastSequence(seq: number): Promise { + try { + (await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq"); + } catch (error) { + console.error("Failed to set last sequence:", error); + } +} + +/** + * Process a batched updates message + * @param message - The batched updates message from the server + * @param handler - Function to handle individual updates + * @param requestMissedFn - Optional function to request missed updates (for gap detection) + */ +export async function processBatchedUpdates( + message: BatchedUpdatesMessage, + handler: (update: UpdateMessage) => void, + requestMissedFn?: (lastSeq: number) => Promise +): Promise { + const { seq, updates } = message; + const lastSeq = await getLastSequence(); + + // Check for gap + if (seq !== lastSeq + 1 && lastSeq > 0) { + console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`); + + // Request missing updates if function provided + if (requestMissedFn) { + try { + await requestMissedFn(lastSeq); + } catch (error) { + console.error("Failed to request missed updates for gap:", error); + } + } + } + + // Process all updates in the batch + for (const update of updates) { + handler(update); + } + + // Update last sequence number + await setLastSequence(seq); +} + +/** + * Request missed updates from the server + * @param lastSeq - The last sequence number we received + * @param requestFn - Function to send the request to the server + * @param credentials - Optional WebSocket credentials for authentication + */ +export async function requestMissedUpdates( + lastSeq: number, + requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise, + credentials?: WebSocketCredentials +): Promise { + if (lastSeq > 0) { + await requestFn({ + type: "getUpdates", + data: { lastSeq }, + credentials + }); + } +} \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 6eea282..0915d19 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -12,6 +12,8 @@ import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; import { useUserStore } from "@/state/user"; +import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; +import { getAuthToken } from "@/core/api/user/auth"; /** * Creates a new WebSocket connection to the chat server @@ -148,55 +150,119 @@ async function reconnect(): Promise { */ function setupEventHandlers(): void { // Message handler - messageHandler = (e: MessageEvent) => { + messageHandler = async (e: MessageEvent) => { try { const response: WebSocketMessage = JSON.parse(e.data); + // Handle batched updates + if (response.type === "updates" && "seq" in response && "updates" in response) { + // Create function to request missed updates with credentials + const token = getAuthToken(); + const requestMissedFn = token ? async (lastSeq: number) => { + await requestMissedUpdates(lastSeq, async (req) => { + await request(req); + }, { + scheme: "Bearer", + credentials: token + }); + } : undefined; + + await processBatchedUpdates(response as any, (update) => { + // Route individual updates to appropriate handlers + handleUpdate(update); + }, requestMissedFn); + return; + } + // Handle call signaling messages if (callSignalingHandler && response.type === "call_signaling" && response.data) { callSignalingHandler.handleWebSocketMessage(response.data); } - // Handle status and typing messages - if (response.type === "statusUpdate") { - onlineStatusManager.handleStatusUpdate(response as any); - } else if (response.type === "typing") { - typingManager.handleTyping(response as any); - } else if (response.type === "stopTyping") { - typingManager.handleStopTyping(response as any); - } else if (response.type === "dmTyping") { - typingManager.handleDmTyping(response as any); - } else if (response.type === "stopDmTyping") { - typingManager.handleStopDmTyping(response as any); - } else if (response.type === "suspended") { - // Handle account suspension - const { setSuspended } = useUserStore.getState(); - const reason = response.data?.reason || "No reason provided"; - setSuspended(reason); - // Close WebSocket connection - websocket.close(); - } else if (response.type === "account_deleted") { - // Handle account deletion - silent logout - const { logout } = useUserStore.getState(); - logout(); - // Close WebSocket connection - websocket.close(); - } - - // Route message to global handler if set - if (globalMessageHandler) { - globalMessageHandler(response); - } + // Handle status and typing messages (these may come as immediate messages or in batches) + handleUpdate(response); } catch (error) { console.error("Error parsing WebSocket message:", error); } }; + + // Helper function to handle individual updates + function handleUpdate(response: WebSocketMessage): void { + if (response.type === "statusUpdate") { + onlineStatusManager.handleStatusUpdate(response as any); + } else if (response.type === "typing") { + typingManager.handleTyping(response as any); + } else if (response.type === "stopTyping") { + typingManager.handleStopTyping(response as any); + } else if (response.type === "dmTyping") { + typingManager.handleDmTyping(response as any); + } else if (response.type === "stopDmTyping") { + typingManager.handleStopDmTyping(response as any); + } else if (response.type === "suspended") { + // Handle account suspension + const { setSuspended } = useUserStore.getState(); + const reason = response.data?.reason || "No reason provided"; + setSuspended(reason); + // Close WebSocket connection + websocket.close(); + } else if (response.type === "account_deleted") { + // Handle account deletion - silent logout + const { logout } = useUserStore.getState(); + logout(); + // Close WebSocket connection + websocket.close(); + } + + // Route message to global handler if set + if (globalMessageHandler) { + globalMessageHandler(response); + } + } websocket.addEventListener("message", messageHandler); // Open handler - openHandler = () => { + openHandler = async () => { reconnectAttempts = 0; // Reset on successful connection isReconnecting = false; + + // Authenticate by sending ping with credentials and request missed updates + try { + const token = getAuthToken(); + if (token) { + const credentials = { + scheme: "Bearer", + credentials: token + }; + + // Send ping to authenticate and set user_by_ws on the server + try { + await request({ + type: "ping", + credentials, + data: {} + }); + } catch (error) { + console.error("Failed to send ping on reconnect:", error); + } + + // Send last sequence number and request missed updates on reconnect + // Wait a bit for ping to complete authentication + await delay(100); + + try { + const lastSeq = await getLastSequence(); + if (lastSeq > 0) { + await requestMissedUpdates(lastSeq, async (req) => { + await request(req); + }, credentials); + } + } catch (error) { + console.error("Failed to request missed updates:", error); + } + } + } catch (error) { + console.error("Failed to authenticate on reconnect:", error); + } }; websocket.addEventListener("open", openHandler); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index aeb2abc..edb356b 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -58,6 +58,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { const [panelState, setPanelState] = useState(null); const messagesEndRef = useRef(null); const previousMessageCountRef = useRef(0); + const messagesContainerRef = useRef(null); + const isLoadingMoreRef = useRef(false); const [replyTo, setReplyTo] = useState(null); const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); const [editMessage, setEditMessage] = useState(null); @@ -92,6 +94,50 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } }, [editMessage]); + // Handle scroll detection for infinite loading + useEffect(() => { + if (!panel || !panelState) return; + + const messagesContainer = document.getElementById("chat-messages"); + if (!messagesContainer) return; + + messagesContainerRef.current = messagesContainer; + + const handleScroll = async () => { + if (!panel || !panelState || isLoadingMoreRef.current) return; + + const container = messagesContainerRef.current; + if (!container) return; + + // Check if scrolled to top (within 100px threshold) + if (container.scrollTop <= 100 && panelState.hasMoreMessages && !panelState.isLoadingMore) { + isLoadingMoreRef.current = true; + const previousScrollHeight = container.scrollHeight; + + try { + await panel.loadMoreMessages(); + + // Preserve scroll position after loading + requestAnimationFrame(() => { + if (container) { + const newScrollHeight = container.scrollHeight; + container.scrollTop = newScrollHeight - previousScrollHeight; + } + isLoadingMoreRef.current = false; + }); + } catch (error) { + console.error("Error loading more messages:", error); + isLoadingMoreRef.current = false; + } + } + }; + + messagesContainer.addEventListener("scroll", handleScroll); + return () => { + messagesContainer.removeEventListener("scroll", handleScroll); + }; + }, [panel, panelState]); + // Handle panel state changes useEffect(() => { if (panel) { @@ -280,31 +326,43 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { ) : panelState && panel ? ( - { - if (editMessage || editVisible) { - setPendingAction({ type: "reply", message: message }); - setEditVisible(false); // onCloseEdit will apply pending - } else { - setReplyTo(message); - } - }} - onEditSelect={(message) => { - if (replyTo || replyToVisible) { - setPendingAction({ type: "edit", message: message }); - setReplyToVisible(false); // onCloseReply will apply pending - } else { - setEditMessage(message); - } - }} - onDelete={(id) => panel.handleDeleteMessage(id)} - onRetryMessage={(id) => panel.retryMessage(id)} - > -
- + <> + {panelState.isLoadingMore && ( +
+ Загрузка... +
+ )} + { + if (editMessage || editVisible) { + setPendingAction({ type: "reply", message: message }); + setEditVisible(false); // onCloseEdit will apply pending + } else { + setReplyTo(message); + } + }} + onEditSelect={(message) => { + if (replyTo || replyToVisible) { + setPendingAction({ type: "edit", message: message }); + setReplyToVisible(false); // onCloseReply will apply pending + } else { + setEditMessage(message); + } + }} + onDelete={(id) => panel.handleDeleteMessage(id)} + onRetryMessage={(id) => panel.retryMessage(id)} + > +
+ + ) : (
this.addMessage(msg)); + this.setHasMoreMessages(has_more); // Update last read ID if (maxIncomingId > 0) { @@ -135,6 +137,50 @@ export class DMPanel extends MessagePanel { } } + async loadMoreMessages(): Promise { + if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return; + + const messages = this.getMessages(); + if (messages.length === 0) return; + + const oldestMessage = messages[0]; + const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope; + if (!oldestEnvelope) return; + + this.setLoadingMore(true); + try { + const limit = this.calculateMessageLimit(); + const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages( + this.dmData.userId, + this.currentUser.authToken, + limit, + oldestEnvelope.id + ); + + if (newEnvelopes && newEnvelopes.length > 0) { + const decryptedMessages: Message[] = []; + for (const env of newEnvelopes) { + try { + const dmMsg = await this.parseTextPayload(env, decryptedMessages); + decryptedMessages.push(dmMsg); + } catch (error) { + console.error("Error decrypting message:", error); + } + } + + // Prepend older messages (they come in reverse chronological order) + this.updateState({ + messages: [...decryptedMessages.reverse(), ...messages] + }); + } + this.setHasMoreMessages(has_more); + } catch (error) { + console.error("Failed to load more DM messages:", error); + } finally { + this.setLoadingMore(false); + } + } + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 4b38f0d..322a469 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -9,6 +9,8 @@ export interface MessagePanelState { messages: Message[]; isLoading: boolean; isTyping: boolean; + hasMoreMessages: boolean; + isLoadingMore: boolean; } export interface MessagePanelCallbacks { @@ -35,7 +37,9 @@ export abstract class MessagePanel { online: false, messages: [], isLoading: false, - isTyping: false + isTyping: false, + hasMoreMessages: false, + isLoadingMore: false }; this.currentUser = currentUser; } @@ -107,6 +111,27 @@ export abstract class MessagePanel { this.updateState({ isTyping: typing }); } + protected setLoadingMore(loading: boolean): void { + this.updateState({ isLoadingMore: loading }); + } + + protected setHasMoreMessages(hasMore: boolean): void { + this.updateState({ hasMoreMessages: hasMore }); + } + + /** + * Calculate message limit based on viewport height (5x screen height) + */ + protected calculateMessageLimit(): number { + const viewportHeight = window.innerHeight; + return Math.ceil((viewportHeight * 5) / 100); + } + + /** + * Load more messages (to be implemented by subclasses) + */ + abstract loadMoreMessages(): Promise; + // Getters getState(): MessagePanelState { return { ...this.state }; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index 3457758..72c2e4e 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -41,13 +41,15 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken); + const limit = this.calculateMessageLimit(); + const { messages, has_more } = await api.chats.general.fetchMessages(this.currentUser.authToken, limit); if (messages && messages.length > 0) { this.clearMessages(); messages.forEach((msg: Message) => { this.addMessage(msg); }); } + this.setHasMoreMessages(has_more); this.messagesLoaded = true; } catch (error) { console.error("Error loading public chat messages:", error); @@ -56,6 +58,35 @@ export class PublicChatPanel extends MessagePanel { } } + async loadMoreMessages(): Promise { + if (!this.currentUser.authToken || !this.state.hasMoreMessages || this.state.isLoadingMore) return; + + const messages = this.getMessages(); + if (messages.length === 0) return; + + const oldestMessage = messages[0]; + this.setLoadingMore(true); + try { + const limit = this.calculateMessageLimit(); + const { messages: newMessages, has_more } = await api.chats.general.fetchMessages( + this.currentUser.authToken, + limit, + oldestMessage.id + ); + if (newMessages && newMessages.length > 0) { + // Prepend older messages (they come in reverse chronological order) + this.updateState({ + messages: [...newMessages.reverse(), ...messages] + }); + } + this.setHasMoreMessages(has_more); + } catch (error) { + console.error("Error loading more public chat messages:", error); + } finally { + this.setLoadingMore(false); + } + } + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !content.trim()) return; diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index 3d2675d..8c8e036 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -1,6 +1,5 @@ import { create } from "zustand"; import type { User } from "@/core/types"; -import { request } from "@/core/websocket"; import api from "@/core/api"; import { API_BASE_URL } from "@/core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; @@ -44,16 +43,8 @@ export const useUserStore = create((set) => ({ console.error('Failed to store credentials in localStorage:', error); } - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings }, logout: () => { try { @@ -113,16 +104,8 @@ export const useUserStore = create((set) => ({ onlineStatusManager.setAuthToken(token); typingManager.setAuthToken(token); - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings try { if (isSupported()) { diff --git a/package.json b/package.json index db5d7ce..5a46993 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", "he": "^1.2.0", + "idb": "^8.0.3", "marked": "^16.3.0", "mdui": "^2.1.4", "motion": "^12.23.24", From c68cb2818c20bb9d3e333ccddda9fc25b1bd53b0 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 19:43:42 +0300 Subject: [PATCH 3/9] Refactor the websocket message handler --- backend/routes/messaging.py | 736 ++-------------------------------- backend/websocket/__init__.py | 7 + backend/websocket/handlers.py | 576 ++++++++++++++++++++++++++ backend/websocket/registry.py | 33 ++ backend/websocket/utils.py | 92 +++++ 5 files changed, 735 insertions(+), 709 deletions(-) create mode 100644 backend/websocket/__init__.py create mode 100644 backend/websocket/handlers.py create mode 100644 backend/websocket/registry.py create mode 100644 backend/websocket/utils.py diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index b9ff6f8..7f2a2cd 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -28,6 +28,7 @@ from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security from security.profanity import censor_text from security.rate_limit import rate_limit_per_ip +from websocket.utils import authenticate_user router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -1084,27 +1085,9 @@ class MessaggingSocketManager: async def handle_connection(self, websocket: WebSocket, db: Session): # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() - - ws_path = getattr(getattr(websocket, "url", None), "path", None) - if not ws_path and isinstance(getattr(websocket, "scope", None), dict): - ws_path = websocket.scope.get("path") - ws_path = ws_path or "unknown" - headers = {} - if isinstance(getattr(websocket, "scope", None), dict): - headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])} - xff = headers.get("x-forwarded-for") - client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None) - - def _log_ws(event: str, user: User | None, **extra: Any) -> None: - log_access( - "ws_event", - path=ws_path, - event=event, - user=user.username if user else None, - user_id=user.id if user else None, - ip=client_ip, - **extra, - ) + + # Import here to avoid circular import + from websocket.handlers import handler_registry while True: try: @@ -1113,698 +1096,33 @@ class MessaggingSocketManager: logger.error(f"Error receiving WebSocket message: {e}") break - type = data["type"] - - def get_current_user_inner() -> User | None: + message_type = data["type"] + handler_info = handler_registry.get_handler(message_type) + + if handler_info: + handler, authRequired = handler_info try: - # Ensure session is in a usable state before querying - try: - db.rollback() - except Exception: - pass + # Authenticate user before calling handler + user = authenticate_user(data, db, authRequired) + # Set user association for authenticated connections + if user: + self.user_by_ws[websocket] = user.id - if data.get("credentials"): - dummy_request = SimpleNamespace() - dummy_request.state = SimpleNamespace() - return get_current_user( - dummy_request, - HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], - credentials=data["credentials"]["credentials"] - ), - db - ) - else: - return None + # Extract inner data to pass to handler + handler_data = data.get("data", {}) + result = await handler(self, websocket, db, user, handler_data) + # If handler returns a value, send it as a WebSocket message + if result is not None: + await websocket.send_json({"type": message_type, "data": result}) + except HTTPException as e: + await self.send_error(websocket, message_type, e) + except WebSocketDisconnect: + raise # Re-raise to close connection except Exception as e: - logger.error(f"Error getting current user: {e}") - try: - db.rollback() - except Exception: - pass - return None - if data.get("credentials"): - dummy_request = SimpleNamespace() - dummy_request.state = SimpleNamespace() - return get_current_user( - dummy_request, - HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], - credentials=data["credentials"]["credentials"] - ), - db - ) - else: - return None - - if type == "getUpdates": - # Handle gap detection - client requests updates from a specific sequence number - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - last_seq = data.get("data", {}).get("lastSeq", 0) - self.last_seq_by_ws[websocket] = last_seq - current_seq = self.sequence_numbers.get(current_user.id, 0) - - # Query database for missed updates - missed_updates = [] - if last_seq > 0 and last_seq < current_seq: - try: - import json - # Get all updates between last_seq and current_seq - update_logs = db.query(UpdateLog).filter( - UpdateLog.user_id == current_user.id, - UpdateLog.sequence > last_seq, - UpdateLog.sequence <= current_seq - ).order_by(UpdateLog.sequence.asc()).all() - - # Each log entry contains a batch of updates with the same sequence number - for log in update_logs: - updates = json.loads(log.updates) - missed_updates.append({ - "seq": log.sequence, - "updates": updates - }) - except Exception as e: - logger.error(f"Failed to retrieve missed updates: {e}") - - # Send missed updates - for batch in missed_updates: - await websocket.send_json({ - "type": "updates", - "seq": batch["seq"], - "updates": batch["updates"] - }) - - await websocket.send_json({ - "type": "getUpdates", - "data": { - "status": "ok", - "lastSeq": current_seq, - "missedCount": len(missed_updates) - } - }) - # Update the websocket's last sequence tracking - self.last_seq_by_ws[websocket] = current_seq - _log_ws("getUpdates", current_user, last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) - except HTTPException as e: - _log_ws("getUpdates_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "ping": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if current_user: - self.user_by_ws[websocket] = current_user.id - # Set user online in DB - current_user.online = True - current_user.last_seen = datetime.now() - db.commit() - # Add to online users - self.online_users.add(current_user.id) - # Broadcast status change - await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat(), db) - else: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - _log_ws("ping_error", current_user) - except HTTPException: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - _log_ws("ping_error", current_user) - await websocket.send_json({"type": "ping", "data": {"status": "success"}}) - _log_ws("ping", current_user) - elif type == "getMessages": - current_user: User | None = None - 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)}) - _log_ws("getMessages", current_user) - except HTTPException as e: - _log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "sendMessage": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - message_request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - - # Call internal function directly (rate limiting is handled at infrastructure level via Caddy) - response = await _send_message_internal(message_request, current_user, db, []) - await self.broadcast({ - "type": "newMessage", - "data": response["message"] - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) - except HTTPException as e: - _log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmSend": - current_user: User | None = None - 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"], - reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, - ) - db.add(env) - db.commit() - db.refresh(env) - - payload = { - "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(), - "replyToId": env.reply_to_id, - } - } - - # Send push notification for DM - try: - await push_service.send_dm_notification(db, env, current_user) - except Exception as e: - logger.error(f"Failed to send push notification for DM {env.id}: {e}") - - await self.send_update_to_user(env.recipient_id, "dmNew", payload["data"], db); - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); - await self.send_update_to_user(env.sender_id, "dmNew", payload["data"], db); - - _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) - log_dm( - "message_sent_ws", - dm_envelope_id=env.id, - sender_id=current_user.id, - sender_username=current_user.username, - recipient_id=env.recipient_id, - reply_to=env.reply_to_id, - ) - except HTTPException as e: - _log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "editMessage": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - message_id = data["data"]["message_id"] - request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) - - response = await edit_message(message_id, request, current_user, db) - await self.broadcast({ - "type": "messageEdited", - "data": response["message"] - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("editMessage", current_user, message_id=message_id) - except HTTPException as e: - _log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmEdit": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - payload = data["data"] - env_id = int(payload["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only edit your own messages") - - # Replace ciphertext and iv - env.iv_b64 = payload["iv"] - env.ciphertext_b64 = payload["ciphertext"] - env.iv2_b64 = payload["iv2"] - env.wrapped_mk_b64 = payload["wrappedMk"] - env.salt_b64 = payload["salt"] - db.commit() - db.refresh(env) - - payload_ws = { - "type": "dmEdited", - "data": { - "id": env.id, - "senderId": env.sender_id, - "recipientId": env.recipient_id, - "iv": env.iv_b64, - "ciphertext": env.ciphertext_b64, - "iv2": env.iv2_b64, - "wrappedMk": env.wrapped_mk_b64, - "salt": env.salt_b64, - "timestamp": env.timestamp.isoformat(), - } - } - await self.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) - await self.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) - - _log_ws("dmEdit", current_user, dm_envelope_id=env.id) - log_dm( - "message_edited", - dm_envelope_id=env.id, - user_id=current_user.id, - username=current_user.username, - ) - except HTTPException as e: - _log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmDelete": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - payload = data["data"] - env_id = int(payload["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only delete your own messages") - - db.delete(env) - db.commit() - - payload_ws = { - "type": "dmDeleted", - "data": { - "id": env_id, - "senderId": current_user.id, - "recipientId": payload.get("recipientId") - } - } - await self.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) - await self.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) - - _log_ws("dmDelete", current_user, dm_envelope_id=env_id) - log_dm( - "message_deleted", - dm_envelope_id=env_id, - user_id=current_user.id, - username=current_user.username, - recipient_id=env.recipient_id, - ) - except HTTPException as e: - _log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "deleteMessage": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - message_id = data["data"]["message_id"] - response = await delete_message(message_id, current_user, db) - await self.broadcast({ - "type": "messageDeleted", - "data": {"message_id": message_id} - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("deleteMessage", current_user, message_id=message_id) - except HTTPException as e: - _log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "addReaction": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - request_data = data["data"] - reaction_request = ReactionRequest( - message_id=request_data["message_id"], - emoji=request_data["emoji"] - ) - - response = await add_reaction(reaction_request, current_user, db) - - # Broadcast reaction update - await self.broadcast({ - "type": "reactionUpdate", - "data": { - "message_id": request_data["message_id"], - "emoji": request_data["emoji"], - "action": response["action"], - "user_id": current_user.id, - "username": current_user.username, - "reactions": response["reactions"] - } - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) - except HTTPException as e: - _log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "addDmReaction": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - request_data = data["data"] - reaction_request = DMReactionRequest( - dm_envelope_id=request_data["dm_envelope_id"], - emoji=request_data["emoji"] - ) - - response = await add_dm_reaction(reaction_request, current_user, db) - - # Broadcast reaction update - await self.broadcast({ - "type": "dmReactionUpdate", - "data": { - "dm_envelope_id": request_data["dm_envelope_id"], - "emoji": request_data["emoji"], - "action": response["action"], - "user_id": current_user.id, - "username": current_user.username, - "reactions": response["reactions"] - } - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) - except HTTPException as e: - _log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "call_signaling": - # Forward WebRTC signaling between peers - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - payload["fromUsername"] = current_user.username - - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": payload - }) - - # Optional ack - await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) - _log_ws("call_signaling", current_user, to_user_id=to_user_id) - except HTTPException as e: - _log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "call_video_toggle": - # Forward video toggle state between peers - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - - await self.send_update_to_user(to_user_id, "call_signaling", { - "type": "call_video_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - }, db) - - await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - _log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) - elif type == "call_screen_share_toggle": - # Forward screen share toggle state between peers - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - - await self.send_update_to_user(to_user_id, "call_signaling", { - "type": "call_screen_share_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - }, db) - - await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - _log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) - elif type == "subscribeStatus": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - user_id_to_subscribe = int(data["data"]["userId"]) - self.ws_subscriptions[websocket].add(user_id_to_subscribe) - - # Get current status of the user - target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() - if target_user: - await websocket.send_json({ - "type": "statusUpdate", - "data": { - "userId": user_id_to_subscribe, - "online": target_user.online, - "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None - } - }) - else: - await websocket.send_json({ - "type": "subscribeStatus", - "data": {"status": "error", "error": "User not found"} - }) - except HTTPException as e: - _log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe) - elif type == "unsubscribeStatus": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - user_id_to_unsubscribe = int(data["data"]["userId"]) - self.ws_subscriptions[websocket].discard(user_id_to_unsubscribe) - - await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) - except HTTPException as e: - _log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe) - elif type == "typing": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - was_typing = self.typing_state.get(current_user.id, False) - self.typing_users[current_user.id] = time.time() - is_now_typing = True - - # Only send update if state changed (started typing) - if not was_typing: - self.typing_state[current_user.id] = True - # Broadcast to all connected users - await self.broadcast({ - "type": "typing", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("typing", current_user) - elif type == "stopTyping": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - was_typing = self.typing_state.get(current_user.id, False) - if current_user.id in self.typing_users: - del self.typing_users[current_user.id] - - # Only send update if state changed (stopped typing) - if was_typing: - self.typing_state[current_user.id] = False - # Broadcast to all connected users - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("stopTyping", current_user) - elif type == "dmTyping": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - recipient_id = int(data["data"]["recipientId"]) - - if current_user.id not in self.dm_typing_users: - self.dm_typing_users[current_user.id] = {} - if current_user.id not in self.dm_typing_state: - self.dm_typing_state[current_user.id] = {} - - was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) - self.dm_typing_users[current_user.id][recipient_id] = time.time() - - # Only send update if state changed (started typing) - if not was_typing: - self.dm_typing_state[current_user.id][recipient_id] = True - # Send only to recipient - await self.send_update_to_user(recipient_id, "dmTyping", { - "userId": current_user.id, - "username": current_user.username - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "stopDmTyping": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - recipient_id = int(data["data"]["recipientId"]) - - was_typing = False - if current_user.id in self.dm_typing_state: - was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) - - if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]: - del self.dm_typing_users[current_user.id][recipient_id] - if not self.dm_typing_users[current_user.id]: - del self.dm_typing_users[current_user.id] - - # Only send update if state changed (stopped typing) - if was_typing: - if current_user.id in self.dm_typing_state: - self.dm_typing_state[current_user.id][recipient_id] = False - # Send only to recipient - await self.send_update_to_user(recipient_id, "stopDmTyping", { - "userId": current_user.id, - "username": current_user.username - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - await self.send_error(websocket, type, e) + logger.error(f"Error in handler for {message_type}: {e}") + await self.send_error(websocket, message_type, HTTPException(500, "Internal server error")) else: - await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}}) + await websocket.send_json({"type": message_type, "error": {"code": 400, "detail": "Invalid type"}}) async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None): try: diff --git a/backend/websocket/__init__.py b/backend/websocket/__init__.py new file mode 100644 index 0000000..efe848a --- /dev/null +++ b/backend/websocket/__init__.py @@ -0,0 +1,7 @@ +from websocket.registry import WebSocketHandlerRegistry + +# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency +# Import them directly from websocket.handlers when needed + +__all__ = ["WebSocketHandlerRegistry"] + diff --git a/backend/websocket/handlers.py b/backend/websocket/handlers.py new file mode 100644 index 0000000..6b949e6 --- /dev/null +++ b/backend/websocket/handlers.py @@ -0,0 +1,576 @@ +from datetime import datetime +import json +import logging +import time +from typing import Any +from fastapi import HTTPException, WebSocket +from sqlalchemy.orm import Session + +from websocket.registry import WebSocketHandlerRegistry +from websocket.utils import authenticate_user +from routes.messaging import ( + MessaggingSocketManager, + convert_message, + convert_dm_envelope, + _send_message_internal, + get_messages, + edit_message, + delete_message, + add_reaction, + add_dm_reaction, +) +from models import ( + User, + SendMessageRequest, + EditMessageRequest, + DMEnvelope, + ReactionRequest, + DMReactionRequest, + UpdateLog, +) +from security.audit import log_access, log_dm, log_public_chat +from routes.account import convert_user + +logger = logging.getLogger("uvicorn.error") + +# Create global registry instance +handler_registry = WebSocketHandlerRegistry() + +# Create decorator alias +websocket_handler = handler_registry.register + + +def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None: + """Log WebSocket event.""" + ws_path = getattr(getattr(websocket, "url", None), "path", None) + if not ws_path and isinstance(getattr(websocket, "scope", None), dict): + ws_path = websocket.scope.get("path") + ws_path = ws_path or "unknown" + headers = {} + if isinstance(getattr(websocket, "scope", None), dict): + headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])} + xff = headers.get("x-forwarded-for") + client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None) + + log_access( + "ws_event", + path=ws_path, + event=event, + user=user.username if user else None, + user_id=user.id if user else None, + ip=client_ip, + **extra, + ) + + +@websocket_handler("getUpdates", authRequired=True) +async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Handle gap detection - client requests updates from a specific sequence number.""" + last_seq = data.get("lastSeq", 0) + manager.last_seq_by_ws[websocket] = last_seq + current_seq = manager.sequence_numbers.get(user.id, 0) + + # Query database for missed updates + missed_updates = [] + if last_seq > 0 and last_seq < current_seq: + try: + # Get all updates between last_seq and current_seq + update_logs = db.query(UpdateLog).filter( + UpdateLog.user_id == user.id, + UpdateLog.sequence > last_seq, + UpdateLog.sequence <= current_seq + ).order_by(UpdateLog.sequence.asc()).all() + + # Each log entry contains a batch of updates with the same sequence number + for log_entry in update_logs: + updates = json.loads(log_entry.updates) + missed_updates.append({ + "seq": log_entry.sequence, + "updates": updates + }) + except Exception as e: + logger.error(f"Failed to retrieve missed updates: {e}") + + # Send missed updates directly (not through return value) + for batch in missed_updates: + await websocket.send_json({ + "type": "updates", + "seq": batch["seq"], + "updates": batch["updates"] + }) + + # Update the websocket's last sequence tracking + manager.last_seq_by_ws[websocket] = current_seq + log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) + + return { + "status": "ok", + "lastSeq": current_seq, + "missedCount": len(missed_updates) + } + + +@websocket_handler("ping", authRequired=True) +async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Handle ping - authenticate and set user online.""" + # Set user online in DB + user.online = True + user.last_seen = datetime.now() + db.commit() + # Add to online users + manager.online_users.add(user.id) + # Broadcast status change + await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db) + + log(manager, websocket, user, "ping") + return {"status": "success"} + + +@websocket_handler("getMessages", authRequired=True) +async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Get all public chat messages.""" + result = await get_messages(user, db) + log(manager, websocket, user, "getMessages") + return result + + +@websocket_handler("sendMessage", authRequired=True) +async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Send a public chat message.""" + message_request: SendMessageRequest = SendMessageRequest.model_validate(data) + + # Call internal function directly (rate limiting is handled at infrastructure level via Caddy) + response = await _send_message_internal(message_request, user, db, []) + await manager.broadcast({ + "type": "newMessage", + "data": response["message"] + }, db) + + log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"]) + return response + + +@websocket_handler("dmSend", authRequired=True) +async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Send a direct message.""" + payload = 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=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"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, + ) + db.add(env) + db.commit() + db.refresh(env) + + payload_ws = { + "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(), + "replyToId": env.reply_to_id, + } + } + + # Send push notification for DM + try: + from push_service import push_service + await push_service.send_dm_notification(db, env, user) + except Exception as e: + logger.error(f"Failed to send push notification for DM {env.id}: {e}") + + await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db) + + log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id) + log_dm( + "message_sent_ws", + dm_envelope_id=env.id, + sender_id=user.id, + sender_username=user.username, + recipient_id=env.recipient_id, + reply_to=env.reply_to_id, + ) + + return {"status": "ok", "id": env.id} + + +@websocket_handler("editMessage", authRequired=True) +async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Edit a public chat message.""" + from types import SimpleNamespace + + message_id = data["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data) + + # Create a dummy request object for the HTTP endpoint function + dummy_request = SimpleNamespace() + response = await edit_message(dummy_request, message_id, request, user, db) + await manager.broadcast({ + "type": "messageEdited", + "data": response["message"] + }, db) + + log(manager, websocket, user, "editMessage", message_id=message_id) + return response + + +@websocket_handler("dmEdit", authRequired=True) +async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Edit a direct message.""" + payload = data + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + # Replace ciphertext and iv + env.iv_b64 = payload["iv"] + env.ciphertext_b64 = payload["ciphertext"] + env.iv2_b64 = payload["iv2"] + env.wrapped_mk_b64 = payload["wrappedMk"] + env.salt_b64 = payload["salt"] + db.commit() + db.refresh(env) + + payload_ws = { + "type": "dmEdited", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "salt": env.salt_b64, + "timestamp": env.timestamp.isoformat(), + } + } + await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) + + log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id) + log_dm( + "message_edited", + dm_envelope_id=env.id, + user_id=user.id, + username=user.username, + ) + + return {"status": "ok", "id": env.id} + + +@websocket_handler("dmDelete", authRequired=True) +async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Delete a direct message.""" + payload = data + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(env) + db.commit() + + payload_ws = { + "type": "dmDeleted", + "data": { + "id": env_id, + "senderId": user.id, + "recipientId": payload.get("recipientId") + } + } + await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) + + log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id) + log_dm( + "message_deleted", + dm_envelope_id=env_id, + user_id=user.id, + username=user.username, + recipient_id=env.recipient_id, + ) + + return {"status": "ok", "id": env_id} + + +@websocket_handler("deleteMessage", authRequired=True) +async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Delete a public chat message.""" + message_id = data["message_id"] + response = await delete_message(message_id, user, db) + await manager.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }, db) + + log(manager, websocket, user, "deleteMessage", message_id=message_id) + return response + + +@websocket_handler("addReaction", authRequired=True) +async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Add or remove a reaction to a public chat message.""" + reaction_request = ReactionRequest( + message_id=data["message_id"], + emoji=data["emoji"] + ) + + response = await add_reaction(reaction_request, user, db) + + # Broadcast reaction update + await manager.broadcast({ + "type": "reactionUpdate", + "data": { + "message_id": data["message_id"], + "emoji": data["emoji"], + "action": response["action"], + "user_id": user.id, + "username": user.username, + "reactions": response["reactions"] + } + }, db) + + log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"]) + return response + + +@websocket_handler("addDmReaction", authRequired=True) +async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Add or remove a reaction to a direct message.""" + reaction_request = DMReactionRequest( + dm_envelope_id=data["dm_envelope_id"], + emoji=data["emoji"] + ) + + response = await add_dm_reaction(reaction_request, user, db) + + # Broadcast reaction update + await manager.broadcast({ + "type": "dmReactionUpdate", + "data": { + "dm_envelope_id": data["dm_envelope_id"], + "emoji": data["emoji"], + "action": response["action"], + "user_id": user.id, + "username": user.username, + "reactions": response["reactions"] + } + }, db) + + log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"]) + return response + + +@websocket_handler("call_signaling", authRequired=True) +async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward WebRTC signaling between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + # Ensure sender is set by the server + payload["fromUserId"] = user.id + payload["fromUsername"] = user.username + + await manager.send_to_user(to_user_id, { + "type": "call_signaling", + "data": payload + }) + + log(manager, websocket, user, "call_signaling", to_user_id=to_user_id) + return {"status": "ok"} + + +@websocket_handler("call_video_toggle", authRequired=True) +async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward video toggle state between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + await manager.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_video_toggle", + "fromUserId": user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) + + log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False)) + return {"status": "ok"} + + +@websocket_handler("call_screen_share_toggle", authRequired=True) +async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward screen share toggle state between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + await manager.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_screen_share_toggle", + "fromUserId": user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) + + log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False)) + return {"status": "ok"} + + +@websocket_handler("subscribeStatus", authRequired=True) +async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Subscribe to status updates for a user.""" + user_id_to_subscribe = int(data["userId"]) + manager.ws_subscriptions[websocket].add(user_id_to_subscribe) + + # Get current status of the user + target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() + if target_user: + # Send current status directly (not through return value) + await websocket.send_json({ + "type": "statusUpdate", + "data": { + "userId": user_id_to_subscribe, + "online": target_user.online, + "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None + } + }) + log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe) + return {"status": "ok"} + else: + log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found") + raise HTTPException(status_code=404, detail="User not found") + + +@websocket_handler("unsubscribeStatus", authRequired=True) +async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Unsubscribe from status updates for a user.""" + user_id_to_unsubscribe = int(data["userId"]) + manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe) + + log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe) + return {"status": "ok"} + + +@websocket_handler("typing", authRequired=True) +async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator start for public chat.""" + was_typing = manager.typing_state.get(user.id, False) + manager.typing_users[user.id] = time.time() + + # Only send update if state changed (started typing) + if not was_typing: + manager.typing_state[user.id] = True + # Broadcast to all connected users + await manager.broadcast({ + "type": "typing", + "data": { + "userId": user.id, + "username": user.username + } + }, db) + + # No confirmation response - privacy protection + + +@websocket_handler("stopTyping", authRequired=True) +async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator stop for public chat.""" + was_typing = manager.typing_state.get(user.id, False) + if user.id in manager.typing_users: + del manager.typing_users[user.id] + + # Only send update if state changed (stopped typing) + if was_typing: + manager.typing_state[user.id] = False + # Broadcast to all connected users + await manager.broadcast({ + "type": "stopTyping", + "data": { + "userId": user.id, + "username": user.username + } + }, db) + + # No confirmation response - privacy protection + log(manager, websocket, user, "stopTyping") + + +@websocket_handler("dmTyping", authRequired=True) +async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator start for DM.""" + recipient_id = int(data["recipientId"]) + + if user.id not in manager.dm_typing_users: + manager.dm_typing_users[user.id] = {} + if user.id not in manager.dm_typing_state: + manager.dm_typing_state[user.id] = {} + + was_typing = manager.dm_typing_state[user.id].get(recipient_id, False) + manager.dm_typing_users[user.id][recipient_id] = time.time() + + # Only send update if state changed (started typing) + if not was_typing: + manager.dm_typing_state[user.id][recipient_id] = True + # Send only to recipient + await manager.send_update_to_user(recipient_id, "dmTyping", { + "userId": user.id, + "username": user.username + }, db) + + # No confirmation response - privacy protection + + +@websocket_handler("stopDmTyping", authRequired=True) +async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator stop for DM.""" + recipient_id = int(data["recipientId"]) + + was_typing = False + if user.id in manager.dm_typing_state: + was_typing = manager.dm_typing_state[user.id].get(recipient_id, False) + + if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]: + del manager.dm_typing_users[user.id][recipient_id] + if not manager.dm_typing_users[user.id]: + del manager.dm_typing_users[user.id] + + # Only send update if state changed (stopped typing) + if was_typing: + if user.id in manager.dm_typing_state: + manager.dm_typing_state[user.id][recipient_id] = False + # Send only to recipient + await manager.send_update_to_user(recipient_id, "stopDmTyping", { + "userId": user.id, + "username": user.username + }, db) + + # No confirmation response - privacy protection + diff --git a/backend/websocket/registry.py b/backend/websocket/registry.py new file mode 100644 index 0000000..d9a6271 --- /dev/null +++ b/backend/websocket/registry.py @@ -0,0 +1,33 @@ +from typing import Callable + + +class WebSocketHandlerRegistry: + """Registry for WebSocket message handlers with authentication support.""" + + def __init__(self): + self._handlers: dict[str, tuple[Callable, bool]] = {} + + def register(self, message_type: str, authRequired: bool = True): + """Register a handler for a message type. + + Args: + message_type: The WebSocket message type to handle + authRequired: If True, handler will receive authenticated User (not None) or raise 401 + """ + def decorator(func: Callable): + self._handlers[message_type] = (func, authRequired) + return func + return decorator + + def get_handler(self, message_type: str) -> tuple[Callable, bool] | None: + """Get handler and authRequired flag for a message type. + + Returns: + Tuple of (handler function, authRequired flag) or None if not found + """ + return self._handlers.get(message_type) + + def get_all_types(self) -> list[str]: + """Get all registered message types for debugging/logging.""" + return list(self._handlers.keys()) + diff --git a/backend/websocket/utils.py b/backend/websocket/utils.py new file mode 100644 index 0000000..1688706 --- /dev/null +++ b/backend/websocket/utils.py @@ -0,0 +1,92 @@ +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from types import SimpleNamespace +from dependencies import get_current_user +from models import User + + +def extract_token_from_data(data: dict) -> str | None: + """Extract authentication token from WebSocket message data. + + Args: + data: WebSocket message data dictionary + + Returns: + Token string or None if not present + """ + credentials = data.get("credentials") + if credentials and isinstance(credentials, dict): + return credentials.get("credentials") + return None + + +def get_current_user_from_token(token: str, db: Session) -> User | None: + """Get user from authentication token. + + Args: + token: JWT token string + db: Database session + + Returns: + User object or None if token is invalid + """ + try: + # Ensure session is in a usable state before querying + try: + db.rollback() + except Exception: + pass + + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() + + try: + from fastapi.security import HTTPBearer + security = HTTPBearer() + # We need to create credentials manually + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token + ) + return get_current_user(dummy_request, credentials, db) + except HTTPException: + return None + except Exception: + try: + db.rollback() + except Exception: + pass + return None + + +def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None: + """Authenticate user from WebSocket message data. + + Args: + data: WebSocket message data dictionary + db: Database session + authRequired: If True, raises 401 on missing/invalid token + + Returns: + User object (guaranteed not None if authRequired=True) or None + + Raises: + HTTPException: 401 if authRequired=True and token is missing/invalid + """ + token = extract_token_from_data(data) + + if authRequired: + if not token: + raise HTTPException(status_code=401, detail="Missing credentials") + + user = get_current_user_from_token(token, db) + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + + return user + else: + if token: + return get_current_user_from_token(token, db) + return None + From 32832f81e6e8d735af2c1f47d25eb70282d62066 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 22:42:19 +0300 Subject: [PATCH 4/9] Clean up --- backend/websocket/handlers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/websocket/handlers.py b/backend/websocket/handlers.py index 6b949e6..beee083 100644 --- a/backend/websocket/handlers.py +++ b/backend/websocket/handlers.py @@ -7,11 +7,8 @@ from fastapi import HTTPException, WebSocket from sqlalchemy.orm import Session from websocket.registry import WebSocketHandlerRegistry -from websocket.utils import authenticate_user from routes.messaging import ( MessaggingSocketManager, - convert_message, - convert_dm_envelope, _send_message_internal, get_messages, edit_message, @@ -28,8 +25,7 @@ from models import ( DMReactionRequest, UpdateLog, ) -from security.audit import log_access, log_dm, log_public_chat -from routes.account import convert_user +from security.audit import log_access, log_dm logger = logging.getLogger("uvicorn.error") From a7f88e0d2bf78fb0cf7019e61d3e4cc8ea948318 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 15:33:33 +0300 Subject: [PATCH 5/9] Improve and strenghten the profanity filter --- backend/security/profanity.py | 576 +++++++++++++++++++++++++++++++--- 1 file changed, 526 insertions(+), 50 deletions(-) diff --git a/backend/security/profanity.py b/backend/security/profanity.py index 8c49d30..d7adffb 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +import unicodedata from pathlib import Path from threading import RLock from typing import Iterable, List, Set, Tuple @@ -13,8 +14,8 @@ BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) _CUSTOM_RU_TERMS: Set[str] = { "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", - "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", + "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", + "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", "пидоры", "пидорас", "пидорасы", "пидорасов", @@ -28,6 +29,12 @@ _ADULT_TERMS: Set[str] = { _STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) +# Words that should never be censored (whitelist) +_WHITELIST: Set[str] = { + "говно", # Allow this word +} + +# Phrase patterns - these will be applied to normalized text (without special chars) _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), @@ -39,42 +46,142 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), ) +# Map for normalizing homoglyphs (similar-looking characters) +# Maps English/Latin characters to their Cyrillic equivalents and vice versa +# Also includes Greek, full-width, and other Unicode variants _LEET_MAP = { + # Numbers to letters "0": "о", - "o": "о", - "о": "о", - "a": "а", - "@": "а", - "4": "а", - "а": "а", - "e": "е", - "ё": "е", - "3": "е", - "c": "с", - "s": "с", - "с": "с", - "x": "х", - "х": "х", - "t": "т", - "т": "т", - "p": "п", - "п": "п", - "n": "н", - "н": "н", - "m": "м", - "м": "м", - "y": "у", - "u": "у", - "у": "у", - "g": "г", - "г": "г", - "v": "в", - "в": "в", - "f": "ф", - "ф": "ф", - "i": "и", "1": "и", + "3": "е", + "4": "а", + # Latin to Cyrillic (lowercase) + "a": "а", + "c": "с", + "e": "е", + "f": "ф", + "g": "г", + "i": "и", + "m": "м", + "n": "н", + "o": "о", + "p": "п", + "s": "с", + "t": "т", + "u": "у", + "v": "в", + "x": "х", + "y": "у", + "z": "з", # English 'z' to Cyrillic 'з' + # Latin to Cyrillic (uppercase) + "A": "а", + "C": "с", + "E": "е", + "F": "ф", + "G": "г", + "I": "и", + "M": "м", + "N": "н", + "O": "о", + "P": "п", + "S": "с", + "T": "т", + "U": "у", + "V": "в", + "X": "х", + "Y": "у", + "Z": "з", # English 'Z' to Cyrillic 'з' + # Greek letters that look like Cyrillic/Latin + "α": "а", # Greek alpha + "Α": "а", + "ο": "о", # Greek omicron + "Ο": "о", + "ρ": "р", # Greek rho (looks like Cyrillic р) + "Ρ": "р", + "υ": "у", # Greek upsilon + "Υ": "у", + "χ": "х", # Greek chi + "Χ": "х", + "ε": "е", # Greek epsilon + "Ε": "е", + "ι": "и", # Greek iota + "Ι": "и", + "ν": "н", # Greek nu + "Ν": "н", + "μ": "м", # Greek mu + "Μ": "м", + "π": "п", # Greek pi + "Π": "п", + "τ": "т", # Greek tau + "Τ": "т", + "γ": "г", # Greek gamma + "Γ": "г", + "σ": "с", # Greek sigma + "Σ": "с", + "φ": "ф", # Greek phi + "Φ": "ф", + # Full-width Latin characters + "a": "а", + "A": "а", + "c": "с", + "C": "с", + "e": "е", + "E": "е", + "f": "ф", + "F": "ф", + "g": "г", + "G": "г", + "i": "и", + "I": "и", + "m": "м", + "M": "м", + "n": "н", + "N": "н", + "o": "о", + "O": "о", + "p": "п", + "P": "п", + "s": "с", + "S": "с", + "t": "т", + "T": "т", + "u": "у", + "U": "у", + "v": "в", + "V": "в", + "x": "х", + "X": "х", + "y": "у", + "Y": "у", + "z": "з", # Full-width 'z' to Cyrillic 'з' + "Z": "з", + # Cyrillic to canonical Cyrillic (identity mappings) + "а": "а", + "с": "с", + "е": "е", + "ё": "е", + "ф": "ф", + "г": "г", "и": "и", + "м": "м", + "н": "н", + "о": "о", + "п": "п", + "т": "т", + "у": "у", + "ү": "у", # Cyrillic capital U (U+04AE) + "Ү": "у", # Cyrillic capital U (U+04AE) + "в": "в", + "х": "х", + "р": "р", + "з": "з", # Cyrillic 'з' + "д": "д", # Cyrillic 'д' + "б": "б", # Cyrillic 'б' + "л": "л", # Cyrillic 'л' + "я": "я", # Cyrillic 'я' + "н": "н", # Already mapped, but explicit + # Special characters + "@": "а", } _RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( @@ -87,14 +194,240 @@ _PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} def _normalize_char(ch: str) -> str: + """Normalize a single character, mapping homoglyphs to canonical form.""" + # First try direct mapping (preserves case for non-mapped chars) + if ch in _LEET_MAP: + return _LEET_MAP[ch] + # Then try lowercase mapping lower = ch.lower() - return _LEET_MAP.get(lower, lower) + if lower in _LEET_MAP: + return _LEET_MAP[lower] + # If no mapping and character is ASCII letter, return lowercase + # This preserves English words like "fromchat" as-is + if ch.isascii() and ch.isalpha(): + return lower + # For other characters, return lowercase for consistency + return lower def _normalize_token(token: str) -> str: + """Normalize a token by mapping all homoglyphs.""" return "".join(_normalize_char(ch) for ch in token) +def _normalize_text_for_profanity(text: str) -> str: + """ + Normalize entire text by mapping homoglyphs to canonical forms. + This prevents bypasses like using English 'u' instead of Russian 'у'. + """ + return "".join(_normalize_char(ch) for ch in text) + + +def _strip_zero_width_chars(text: str) -> str: + """ + Remove zero-width characters that could be used to bypass filters. + """ + # Zero-width space, zero-width non-joiner, zero-width joiner, etc. + zero_width_chars = [ + '\u200B', # Zero-width space + '\u200C', # Zero-width non-joiner + '\u200D', # Zero-width joiner + '\uFEFF', # Zero-width no-break space + '\u2060', # Word joiner + '\u2061', # Function application + '\u2062', # Invisible times + '\u2063', # Invisible separator + '\u2064', # Invisible plus + ] + result = text + for zw_char in zero_width_chars: + result = result.replace(zw_char, '') + return result + + +def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]: + """ + Extract only alphanumeric characters from text and create a mapping + from normalized positions to original positions. + + Args: + preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching) + + Returns: + (normalized_text, position_map) where position_map[i] is the original + position of the i-th character in normalized_text + """ + # First normalize Unicode (composed vs decomposed) + normalized_unicode = unicodedata.normalize('NFKC', text) + + # For phrase matching, convert zero-width chars to spaces instead of stripping + if preserve_spaces: + zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064'] + for zw_char in zero_width_chars: + normalized_unicode = normalized_unicode.replace(zw_char, ' ') + else: + # Strip zero-width characters + normalized_unicode = _strip_zero_width_chars(normalized_unicode) + + normalized = [] + position_map = [] + + for i, ch in enumerate(normalized_unicode): + # Check if character is alphanumeric (including Cyrillic) + if ch.isalnum(): + # For phrase matching, preserve ASCII letters as-is (just lowercase) + # to allow English words in patterns to match + if preserve_spaces and ch.isascii() and ch.isalpha(): + normalized.append(ch.lower()) + else: + # Normalize this character (homoglyphs, Cyrillic, etc.) + normalized.append(_normalize_char(ch)) + position_map.append(i) + elif preserve_spaces: + # For phrase matching, treat any whitespace or non-alphanumeric as word separator + if ch.isspace() or not ch.isalnum(): + # Normalize to single space to allow patterns to match + if normalized and normalized[-1] != ' ': # Don't add consecutive spaces + normalized.append(' ') + position_map.append(i) + + return "".join(normalized), position_map + + +def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]: + """ + Check for profane words as substrings or subsequences in normalized text. + This catches cases like "хуй" in "хууй" (with extra characters). + Returns list of (start, end) positions where profanity is found. + """ + spans = [] + normalized_lower = normalized_text.lower() + + for word in profane_words: + word_lower = word.lower() + + # First try exact substring match + start = 0 + while True: + pos = normalized_lower.find(word_lower, start) + if pos == -1: + break + spans.append((pos, pos + len(word_lower))) + start = pos + 1 + + # Also check if profane word appears as a subsequence (allowing extra chars) + # This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй" + word_chars = list(word_lower) + text_chars = list(normalized_lower) + + # Try to find the word as a subsequence + i = 0 # position in text + j = 0 # position in word + seq_start = None + + while i < len(text_chars) and j < len(word_chars): + if text_chars[i] == word_chars[j]: + if seq_start is None: + seq_start = i + j += 1 + if j == len(word_chars): + # Found the word as subsequence + seq_end = i + 1 + # Only add if it's not already covered by exact match + if (seq_start, seq_end) not in spans: + spans.append((seq_start, seq_end)) + # Reset to find next occurrence + seq_start = None + j = 0 + # Continue from after the start position + i = seq_start + 1 if seq_start is not None else i + 1 + continue + i += 1 + + return spans + + +def _find_profanity_spans_in_original( + normalized_text: str, + position_map: list[int], + original_length: int, + original_text: str +) -> list[tuple[int, int]]: + """ + Find profanity in normalized text and map the spans back to original text positions. + Uses both better_profanity library and substring matching for better detection. + + Returns list of (start, end) tuples in original text coordinates. + """ + spans = [] + + if not normalized_text or not position_map: + return spans + + # Check normalized text for profanity using better_profanity + censored = _profanity.censor(normalized_text, censor_char="\\*") + + # Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня") + profane_words = _STATIC_TERMS + substring_spans = _check_profanity_substrings(normalized_text, profane_words) + + # Combine spans from both methods + all_spans = set() + + # From better_profanity censoring + i = 0 + while i < len(censored): + if censored[i] == "*": + span_start = i + while i < len(censored) and censored[i] == "*": + i += 1 + span_end = i + all_spans.add((span_start, span_end)) + else: + i += 1 + + # From substring matching + for start, end in substring_spans: + all_spans.add((start, end)) + + # Map all spans to original positions + for span_start, span_end in all_spans: + if span_start < len(position_map): + orig_start = position_map[span_start] + # Find the end position - use the last mapped position in the span + if span_end > 0 and span_end <= len(position_map): + orig_end = position_map[span_end - 1] + 1 + elif span_end > len(position_map): + orig_end = original_length + else: + orig_end = orig_start + 1 + + # Extend span to include any non-alphanumeric characters between + # the mapped positions in the original text + # Limit extension to prevent over-censoring (max 50 chars each direction) + max_extension = 50 + extension_count = 0 + + # Extend backwards to include any preceding non-alphanumeric + while (orig_start > 0 and + not original_text[orig_start - 1].isalnum() and + extension_count < max_extension): + orig_start -= 1 + extension_count += 1 + + extension_count = 0 + # Extend forwards to include any following non-alphanumeric + while (orig_end < original_length and + not original_text[orig_end].isalnum() and + extension_count < max_extension): + orig_end += 1 + extension_count += 1 + + spans.append((orig_start, min(orig_end, original_length))) + + return spans + + def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: tokens: List[Tuple[int, int, str]] = [] start: int | None = None @@ -241,8 +574,17 @@ def _rebuild_dictionary(force: bool = False) -> None: profanity = Profanity() profanity.load_censor_words() + # Remove whitelisted words from the default word list + try: + for word in _WHITELIST: + profanity.remove_censor_words([word]) + except AttributeError: + # If remove_censor_words doesn't exist, we'll handle it in post-processing + pass combined = set(_STATIC_TERMS) combined.update(blocklist_list) + # Remove whitelisted words from our custom terms + combined -= _WHITELIST if combined: profanity.add_censor_words(list(combined)) @@ -251,18 +593,59 @@ def _rebuild_dictionary(force: bool = False) -> None: def _apply_phrase_filters(text: str) -> str: - result = text + """ + Apply phrase patterns to text. Patterns are applied to normalized text + (without special characters) and then mapped back to original positions. + """ + # Normalize text for phrase matching (remove special chars but preserve spaces) + normalized_text, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) + normalized_lower = normalized_text.lower() + + result = list(text) + censored_positions = set() + + # Apply phrase patterns to normalized text for pattern in _PHRASE_PATTERNS: - while True: - match = pattern.search(result) - if not match: - break - result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():] - - for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True): - result = result[:start] + ("*" * (end - start)) + result[end:] - - return result + for match in pattern.finditer(normalized_lower): + # Map back to original positions + norm_start = match.start() + norm_end = match.end() + + if norm_start < len(position_map) and norm_end <= len(position_map): + orig_start = position_map[norm_start] + orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1 + + # Extend to include special characters + while orig_start > 0 and not text[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(text) and not text[orig_end].isalnum(): + orig_end += 1 + + # Mark positions for censoring + for pos in range(orig_start, min(orig_end, len(result))): + censored_positions.add(pos) + + # Apply fuzzy phrase spans + for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True): + if start < len(position_map) and end <= len(position_map): + orig_start = position_map[start] + orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1 + + # Extend to include special characters + while orig_start > 0 and not text[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(text) and not text[orig_end].isalnum(): + orig_end += 1 + + for pos in range(orig_start, min(orig_end, len(result))): + censored_positions.add(pos) + + # Apply censoring + for pos in censored_positions: + if pos < len(result): + result[pos] = "*" + + return "".join(result) def censor_text(text: str) -> str: @@ -271,7 +654,62 @@ def censor_text(text: str) -> str: _rebuild_dictionary() preprocessed = _apply_phrase_filters(text) - return _profanity.censor(preprocessed, censor_char="\\*") + + # Normalize text for whitelist matching (to handle special characters) + normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed) + normalized_for_whitelist_lower = normalized_for_whitelist.lower() + + # Identify and protect whitelisted words (using normalized text) + whitelist_spans = [] + for whitelist_word in _WHITELIST: + # Normalize whitelist word too + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + + # Find in normalized text + pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE) + for match in pattern.finditer(normalized_for_whitelist_lower): + # Map back to original positions + if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map): + orig_start = whitelist_position_map[match.start()] + orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1 + # Extend to include any special characters + while orig_start > 0 and not preprocessed[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum(): + orig_end += 1 + whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end])) + + # Extract only alphanumeric characters and normalize homoglyphs + # This removes special characters, emojis, etc. that could be used to bypass the filter + normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed) + normalized_lower = normalized_text.lower() + + # Check profanity on normalized text (without special characters) + profanity_spans = _find_profanity_spans_in_original( + normalized_lower, + position_map, + len(preprocessed), + preprocessed + ) + + # Apply censoring to original text + result = list(preprocessed) + for start, end in profanity_spans: + # Check if this span overlaps with a whitelisted word + is_whitelisted = False + for wl_start, wl_end, _ in whitelist_spans: + # Check if spans overlap + if not (end <= wl_start or start >= wl_end): + is_whitelisted = True + break + + if not is_whitelisted: + # Censor the entire span (including any special characters within it) + for pos in range(start, min(end, len(result))): + result[pos] = "*" + + return "".join(result) def contains_profanity(text: str) -> bool: @@ -279,12 +717,50 @@ def contains_profanity(text: str) -> bool: return False _rebuild_dictionary() + + # Extract only alphanumeric characters and normalize homoglyphs + # This removes special characters, emojis, etc. that could be used to bypass the filter + normalized_text, _ = _extract_alphanumeric_with_mapping(text) + normalized_lower = normalized_text.lower() + + # Check phrase patterns on normalized text (to handle special characters) for pattern in _PHRASE_PATTERNS: - if pattern.search(text): + if pattern.search(normalized_lower): return True - if _find_fuzzy_phrase_spans(text, "generic"): + if _find_fuzzy_phrase_spans(normalized_lower, "generic"): return True - return _profanity.contains_profanity(text) + + # Check for profane words as substrings/subsequences (to catch cases like "хуй" in "хууй" or "хуйня") + profane_words = _STATIC_TERMS + substring_spans = _check_profanity_substrings(normalized_text, profane_words) + + if substring_spans: + # Check if any found profanity is not part of a whitelisted word + for span_start, span_end in substring_spans: + is_whitelisted = False + for whitelist_word in _WHITELIST: + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + wl_pos = normalized_lower.find(normalized_whitelist_lower) + if wl_pos != -1: + # Check if profane span is within whitelisted word + if wl_pos <= span_start < wl_pos + len(normalized_whitelist_lower): + is_whitelisted = True + break + if not is_whitelisted: + return True + + # Remove whitelisted words from text before checking profanity + # This allows standalone whitelisted words but still blocks them in phrases + for whitelist_word in _WHITELIST: + # Normalize whitelist word too + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + # Use word boundaries to match whole words only + pattern = re.compile(r"\b" + re.escape(normalized_whitelist_lower) + r"\b", re.IGNORECASE) + normalized_lower = pattern.sub("", normalized_lower) + + return _profanity.contains_profanity(normalized_lower) def contains_sensitive_phrase(text: str) -> bool: From 6d0edd19c3cfb3f934eb4ab5e4ca0854d3a99fad Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 15:46:58 +0300 Subject: [PATCH 6/9] Log both raw input and censored version --- backend/routes/messaging.py | 65 ++++++++++++++++++++++++----------- backend/security/audit.py | 22 ++++++++++-- backend/security/profanity.py | 62 ++++++++++++--------------------- 3 files changed, 86 insertions(+), 63 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7f2a2cd..adc6348 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -26,7 +26,7 @@ import io import json from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security -from security.profanity import censor_text +from security.profanity import censor_text, contains_profanity from security.rate_limit import rate_limit_per_ip from websocket.utils import authenticate_user @@ -277,6 +277,9 @@ async def _send_message_internal( # Apply profanity filter before storing filtered_content = censor_text(raw_content) escaped_content = html.escape(filtered_content, quote=False) + + # Check if content was censored (use contains_profanity to detect actual profanity) + was_censored = contains_profanity(raw_content) if len(escaped_content) > 4096: raise HTTPException( @@ -368,17 +371,25 @@ async def _send_message_internal( _monitor_public_message_activity(current_user, filtered_content, db) message_payload = convert_message(new_message) - log_public_chat( - "message_created", - message_id=new_message.id, - user_id=current_user.id, - username=current_user.username, - reply_to=new_message.reply_to_id, - attachments=len(new_message.files or []), - length=len(new_message.content), - suspended=current_user.suspended, - content=new_message.content, - ) + + # Prepare log fields + log_fields = { + "message_id": new_message.id, + "user_id": current_user.id, + "username": current_user.username, + "reply_to": new_message.reply_to_id, + "attachments": len(new_message.files or []), + "length": len(new_message.content), + "suspended": current_user.suspended, + "content": new_message.content, + } + + # If content was censored, log both raw and censored versions + if was_censored: + log_fields["raw_content"] = raw_content + log_fields["censored_content"] = filtered_content + + log_public_chat("message_created", **log_fields) return {"status": "success", "message": message_payload} @@ -692,6 +703,10 @@ async def edit_message( original_content = message.content sanitized_content = censor_text(raw_content) escaped_content = html.escape(sanitized_content, quote=False) + + # Check if content was censored (use contains_profanity to detect actual profanity) + was_censored = contains_profanity(raw_content) + if len(escaped_content) > 4096: raise HTTPException(status_code=400, detail="Message too long") @@ -702,15 +717,23 @@ async def edit_message( db.refresh(message) payload = convert_message(message) - log_public_chat( - "message_edited", - message_id=message.id, - user_id=current_user.id, - username=current_user.username, - reply_to=message.reply_to_id, - content=message.content, - previous_content=original_content, - ) + + # Prepare log fields + log_fields = { + "message_id": message.id, + "user_id": current_user.id, + "username": current_user.username, + "reply_to": message.reply_to_id, + "content": message.content, + "previous_content": original_content, + } + + # If content was censored, log both raw and censored versions + if was_censored: + log_fields["raw_content"] = raw_content + log_fields["censored_content"] = sanitized_content + + log_public_chat("message_edited", **log_fields) return {"status": "success", "message": payload} diff --git a/backend/security/audit.py b/backend/security/audit.py index 52bad60..f9e7e64 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -204,7 +204,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: attachments = fields.get("attachments") if attachments: lines.append(f"Attachments: {_plural('file', attachments)}") - if fields.get("content"): + + # If content was censored, log both raw and censored versions + if fields.get("raw_content") is not None: + lines.append("Raw content (before censoring):") + for line in unescape(fields["raw_content"]).splitlines(): + lines.append(f"| {line}") + lines.append("Censored content (stored):") + for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): + lines.append(f"| {line}") + elif fields.get("content"): lines.append("Content:") for line in unescape(fields["content"]).splitlines(): lines.append(f"| {line}") @@ -217,7 +226,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: lines.append("Previous content:") for line in unescape(fields["previous_content"] or "").splitlines() or [""]: lines.append(f"| {line}") - if fields.get("content"): + + # If content was censored, log both raw and censored versions + if fields.get("raw_content") is not None: + lines.append("Raw content (before censoring):") + for line in unescape(fields["raw_content"]).splitlines(): + lines.append(f"| {line}") + lines.append("Censored content (stored):") + for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): + lines.append(f"| {line}") + elif fields.get("content"): lines.append("New content:") for line in unescape(fields["content"] or "").splitlines() or [""]: lines.append(f"| {line}") diff --git a/backend/security/profanity.py b/backend/security/profanity.py index d7adffb..b2f3acb 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -713,54 +713,36 @@ def censor_text(text: str) -> str: def contains_profanity(text: str) -> bool: + """ + Check if text contains profanity that would be censored. + Returns True if censor_text would actually censor anything. + """ if not text: return False - _rebuild_dictionary() + # Use censor_text to check if anything would be censored + # This ensures consistency between contains_profanity and censor_text + censored = censor_text(text) - # Extract only alphanumeric characters and normalize homoglyphs - # This removes special characters, emojis, etc. that could be used to bypass the filter - normalized_text, _ = _extract_alphanumeric_with_mapping(text) - normalized_lower = normalized_text.lower() + # Check if any characters were actually censored (changed to asterisks) + # by comparing the original text with the censored version + # We need to account for the fact that the original might already contain asterisks + if censored == text: + return False # No changes, so no profanity - # Check phrase patterns on normalized text (to handle special characters) - for pattern in _PHRASE_PATTERNS: - if pattern.search(normalized_lower): - return True - if _find_fuzzy_phrase_spans(normalized_lower, "generic"): - return True + # If the text changed, check if any non-asterisk characters were replaced + # by comparing character-by-character (excluding positions that were already asterisks) + for i, (orig_char, censored_char) in enumerate(zip(text, censored)): + if orig_char != "*" and censored_char == "*": + return True # A non-asterisk character was censored - # Check for profane words as substrings/subsequences (to catch cases like "хуй" in "хууй" or "хуйня") - profane_words = _STATIC_TERMS - substring_spans = _check_profanity_substrings(normalized_text, profane_words) - - if substring_spans: - # Check if any found profanity is not part of a whitelisted word - for span_start, span_end in substring_spans: - is_whitelisted = False - for whitelist_word in _WHITELIST: - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - wl_pos = normalized_lower.find(normalized_whitelist_lower) - if wl_pos != -1: - # Check if profane span is within whitelisted word - if wl_pos <= span_start < wl_pos + len(normalized_whitelist_lower): - is_whitelisted = True - break - if not is_whitelisted: + # If censored is longer, check the extra characters + if len(censored) > len(text): + for i in range(len(text), len(censored)): + if censored[i] == "*": return True - # Remove whitelisted words from text before checking profanity - # This allows standalone whitelisted words but still blocks them in phrases - for whitelist_word in _WHITELIST: - # Normalize whitelist word too - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - # Use word boundaries to match whole words only - pattern = re.compile(r"\b" + re.escape(normalized_whitelist_lower) + r"\b", re.IGNORECASE) - normalized_lower = pattern.sub("", normalized_lower) - - return _profanity.contains_profanity(normalized_lower) + return False def contains_sensitive_phrase(text: str) -> bool: From abbd3e2db9073d60bea36b61a82a139362c5d0ef Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:28:18 +0300 Subject: [PATCH 7/9] Fix reply preview --- .../src/pages/chat/css/ChatInput.module.scss | 18 ---- .../src/pages/chat/css/Message.module.scss | 102 ++++++++---------- .../pages/chat/css/reply-preview.module.scss | 19 ++++ .../pages/chat/ui/right/ChatInputWrapper.tsx | 13 +-- frontend/src/pages/chat/ui/right/Message.tsx | 9 +- 5 files changed, 74 insertions(+), 87 deletions(-) create mode 100644 frontend/src/pages/chat/css/reply-preview.module.scss diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/frontend/src/pages/chat/css/ChatInput.module.scss index cdd1f52..5376b99 100644 --- a/frontend/src/pages/chat/css/ChatInput.module.scss +++ b/frontend/src/pages/chat/css/ChatInput.module.scss @@ -2,24 +2,6 @@ @use "../../../css/material" as *; @use "sass:color"; -// Reply preview styles (shared with Message component) -.quote.contextualContent > .quoteInner { - display: flex; - flex-direction: column; - gap: 4px; - - .replyUsername { - font-weight: 600; - color: $color-dark-on-surface; - font-size: 0.85rem; - } - - .replyText { - overflow: hidden; - text-overflow: ellipsis; - } -} - .chatInputWrapper { position: relative; margin: 0 10px 10px 10px; diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss index f1cfe50..c49fafd 100644 --- a/frontend/src/pages/chat/css/Message.module.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -2,27 +2,11 @@ @use "../../../css/material" as *; @use "sass:color"; -.quote.contextualContent > .quoteInner { - display: flex; - flex-direction: column; - gap: 4px; - - .replyUsername { - font-weight: 600; - color: $color-dark-on-surface; - font-size: 0.85rem; - } - - .replyText { - overflow: hidden; - text-overflow: ellipsis; - } -} .message { $status-indicator-size: 16px; - margin-bottom: 1rem; + margin-bottom: 10px; max-width: 70%; position: relative; width: fit-content; @@ -30,30 +14,8 @@ align-items: flex-start; gap: 8px; - &.received { - .messageProfilePic { - width: 40px; - height: 40px; - flex-shrink: 0; - cursor: pointer; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.05); - } - - img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; - border: 2px solid $color-dark-outline; - } - } - } - .messageInner { - border-radius: 12px; + border-radius: 20px 20px 8px 8px; // Top corners rounded, bottom corners sharper position: relative; word-wrap: break-word; overflow-wrap: anywhere; @@ -72,6 +34,7 @@ display: flex; align-items: center; gap: 4px; + width: fit-content; &:hover { transform: scale(1.05); @@ -92,9 +55,10 @@ } } - .quote.replyPreview { + :global(.quote).replyPreview { user-select: none; - margin: 10px; + margin: 5px; + border-radius: 16px; } .messageAttachments { @@ -194,10 +158,31 @@ } &.received { + .messageProfilePic { + width: 40px; + height: 40px; + flex-shrink: 0; + cursor: pointer; + transition: transform 0.2s ease; + align-self: flex-end; + + &:hover { + transform: scale(1.05); + } + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + .messageInner { background: $color-dark-surface-container; color: $color-dark-on-surface; - border-top-left-radius: 5px; + border-radius: 20px 20px 20px 8px; // Top-left: 5px, top-right: 20px, bottom: 8px box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba($color-dark-outline-variant, 0.4); position: relative; @@ -231,27 +216,27 @@ margin-left: auto; flex-direction: row-reverse; + :global(.quote).replyPreview { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08)); + border: 1px solid rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; + box-shadow: 0 2px 8px rgba(147, 51, 234, 0.5); + + :global(.quote-inner) { + position: relative; + z-index: 1; + } + } + .messageInner { - background: linear-gradient(135deg, #9333EA, #6366F1, #3B82F6); - color: $color-dark-on-primary; - border-top-right-radius: 5px; + background: linear-gradient(135deg, #9333EA, #6366F1, #2f68c5); + border-radius: 20px 20px 8px 20px; // Top-left: 20px, top-right: 5px, bottom: 8px box-shadow: 0 0 20px rgba($color-dark-primary, 0.4); border: 1px solid rgba($color-dark-primary, 0.5); position: relative; overflow: hidden; - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08)); - pointer-events: none; - z-index: 0; - } - > * { position: relative; z-index: 1; @@ -273,7 +258,6 @@ } .messageTime { - color: $color-dark-on-primary; font-weight: 500; } } diff --git a/frontend/src/pages/chat/css/reply-preview.module.scss b/frontend/src/pages/chat/css/reply-preview.module.scss new file mode 100644 index 0000000..e3164cb --- /dev/null +++ b/frontend/src/pages/chat/css/reply-preview.module.scss @@ -0,0 +1,19 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; + +:global(.quote).contextualContent > :global(.quote-inner) { + display: flex; + flex-direction: column; + gap: 4px; + + .replyUsername { + font-weight: 600; + color: $color-dark-on-surface; + font-size: 0.85rem; + } + + .replyText { + overflow: hidden; + text-overflow: ellipsis; + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index a41c7b5..35a1ef3 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -7,6 +7,7 @@ import { useImmer } from "use-immer"; import { EmojiMenu } from "./EmojiMenu"; import { MaterialIcon, MaterialIconButton } from "@/utils/material"; import styles from "@/pages/chat/css/ChatInput.module.scss"; +import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; import { alert } from "mdui/functions/alert"; interface ChatInputWrapperProps { @@ -161,9 +162,9 @@ export function ChatInputWrapper( >
- - {editingMessage!.username} - {editingMessage!.content} + + {editingMessage!.username} + {editingMessage!.content}
@@ -181,9 +182,9 @@ export function ChatInputWrapper( >
- - {replyTo!.username} - {replyTo!.content} + + {replyTo!.username} + {replyTo!.content}
diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 79fdd3d..012629c 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -17,6 +17,7 @@ import { createPortal } from "react-dom"; import { parseProfileLink } from "@/core/profileLinks"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import styles from "@/pages/chat/css/Message.module.scss"; +import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; interface MessageReactionsProps { reactions?: Reaction[]; @@ -483,7 +484,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD {!isAuthor && !isDm && (
{message.username} { e.target.src = defaultAvatar; @@ -507,9 +508,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD )} {message.reply_to && ( - - {message.reply_to.username} - {message.reply_to.content} + + {message.reply_to.username} + {message.reply_to.content} )} From 3549f9869100a78b663ff6f30ffeb585987e5f55 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:32:46 +0300 Subject: [PATCH 8/9] Ensure ping after auth --- frontend/src/core/websocket.ts | 51 +++++++++++++++++++++++++++ frontend/src/pages/auth/LoginForm.tsx | 8 +++++ 2 files changed, 59 insertions(+) diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 0915d19..388c493 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -342,4 +342,55 @@ export function request(payload: WebSocketMessage { + const token = getAuthToken(); + if (!token) { + return; + } + + // If WebSocket is not connected, wait for it to connect + if (websocket.readyState === WebSocket.CONNECTING) { + await new Promise((resolve) => { + const checkConnection = () => { + if (websocket.readyState === WebSocket.OPEN) { + resolve(); + } else if (websocket.readyState === WebSocket.CLOSED) { + // Connection failed, try to reconnect + reconnect().then(() => { + setTimeout(checkConnection, 100); + }); + } else { + setTimeout(checkConnection, 100); + } + }; + checkConnection(); + }); + } else if (websocket.readyState === WebSocket.CLOSED) { + // Reconnect if closed + await reconnect(); + } + + // If WebSocket is open, send ping to authenticate + if (websocket.readyState === WebSocket.OPEN) { + try { + const credentials = { + scheme: "Bearer", + credentials: token + }; + + await request({ + type: "ping", + credentials, + data: {} + }); + } catch (error) { + console.error("Failed to send ping after login:", error); + } + } +} + setupEventHandlers(); \ No newline at end of file diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 8e1410d..17f96c1 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -12,6 +12,7 @@ import { isElectron } from "@/core/electron/electron"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; import styles from "./auth.module.scss"; +import { ensureAuthenticated } from "@/core/websocket"; const loginFieldVariants: Variants = { initial: { @@ -95,6 +96,13 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { console.error("Key setup failed:", e); } + // Ensure WebSocket is connected and authenticated + try { + await ensureAuthenticated(); + } catch (e) { + console.error("WebSocket authentication failed:", e); + } + navigate("/chat"); try { From dcad5dbbc2f7eb37f1cc41eb68dd6e38416778b7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:41:38 +0300 Subject: [PATCH 9/9] Fix token expiration --- backend/constants.py | 6 ++++-- backend/dependencies.py | 17 +++++++++++++++-- backend/utils.py | 7 ++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/constants.py b/backend/constants.py index e1086c9..bfddb72 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,9 +1,11 @@ import os - DATABASE_URL = "sqlite:///./data/database.db" JWT_ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_HOURS = 24 +# Token inactivity expiration - token expires if not used for this duration +TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity +# Maximum token lifetime (safety net) - tokens expire after this regardless of usage +MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum OWNER_USERNAME = "denis0001-dev" JWT_SECRET_KEY = os.getenv("JWT_SECRET") diff --git a/backend/dependencies.py b/backend/dependencies.py index c19adee..6ebb55b 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -66,7 +66,20 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) - # Touch last_seen on valid session + # Check if session has been inactive for too long (sliding expiration) + from constants import TOKEN_INACTIVITY_EXPIRE_HOURS + inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS) + if device_session.last_seen < inactivity_threshold: + # Session expired due to inactivity - revoke it + device_session.revoked = True + db.commit() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session expired due to inactivity", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Touch last_seen on valid session (sliding expiration - extends token life) device_session.last_seen = datetime.now() db.commit() diff --git a/backend/utils.py b/backend/utils.py index 660b475..ac2cd5a 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -4,16 +4,17 @@ import jwt from typing import Optional, Any import bcrypt -from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM +from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM # JWT Helper Functions def create_token(user_id: int, username: str, session_id: str) -> str: - expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + # Set a long expiration as safety net (actual expiration based on inactivity) + expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS) payload = { "user_id": user_id, "username": username, "session_id": session_id, - "exp": expire + "exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int) } return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)