From a15d3a08bfc38e1cec2a7254b88cebca4cfda819 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:02:44 +0300 Subject: [PATCH] Refactor APIs --- .../api/{devicesApi.ts => account/devices.ts} | 9 +- .../core/api/{authApi.ts => account/index.ts} | 175 +++++++---- frontend/src/core/api/account/profile.ts | 275 ++++++++++++++++++ frontend/src/core/api/crypto.ts | 76 +++++ frontend/src/core/api/dm.ts | 178 ++++++++++++ frontend/src/core/api/dmApi.ts | 35 +-- frontend/src/core/api/files.ts | 39 +++ frontend/src/core/api/messaging.ts | 87 ++++++ frontend/src/core/api/moderation.ts | 54 ++++ frontend/src/core/api/profileApi.ts | 66 ++++- frontend/src/core/api/push.ts | 50 ++++ frontend/src/core/api/securityApi.ts | 36 --- frontend/src/core/api/users.ts | 28 ++ frontend/src/core/api/webrtc.ts | 15 + frontend/src/core/calls/encryption.ts | 2 +- frontend/src/core/calls/webrtc.ts | 21 +- frontend/src/core/components/StatusBadge.tsx | 2 +- frontend/src/core/components/VerifyButton.tsx | 2 +- .../push-notifications/push-notifications.ts | 14 +- frontend/src/pages/auth/LoginForm.tsx | 33 +-- frontend/src/pages/auth/RegisterForm.tsx | 26 +- frontend/src/pages/chat/hooks/useDM.ts | 2 +- frontend/src/pages/chat/hooks/useProfile.ts | 2 +- frontend/src/pages/chat/state.ts | 14 +- frontend/src/pages/chat/ui/ChatPage.tsx | 2 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 44 +-- .../pages/chat/ui/left/UnifiedChatsList.tsx | 19 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 2 +- .../chat/ui/left/settings/AccountPanel.tsx | 2 +- .../ui/left/settings/ChangePasswordDialog.tsx | 2 +- .../chat/ui/left/settings/DevicesPanel.tsx | 2 +- .../ui/left/settings/NotificationsPanel.tsx | 12 +- frontend/src/pages/chat/ui/right/Message.tsx | 5 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 4 +- .../chat/ui/right/panels/PublicChatPanel.ts | 53 +--- 35 files changed, 1075 insertions(+), 313 deletions(-) rename frontend/src/core/api/{devicesApi.ts => account/devices.ts} (84%) rename frontend/src/core/api/{authApi.ts => account/index.ts} (55%) create mode 100644 frontend/src/core/api/account/profile.ts create mode 100644 frontend/src/core/api/crypto.ts create mode 100644 frontend/src/core/api/dm.ts create mode 100644 frontend/src/core/api/files.ts create mode 100644 frontend/src/core/api/messaging.ts create mode 100644 frontend/src/core/api/moderation.ts create mode 100644 frontend/src/core/api/push.ts delete mode 100644 frontend/src/core/api/securityApi.ts create mode 100644 frontend/src/core/api/users.ts create mode 100644 frontend/src/core/api/webrtc.ts diff --git a/frontend/src/core/api/devicesApi.ts b/frontend/src/core/api/account/devices.ts similarity index 84% rename from frontend/src/core/api/devicesApi.ts rename to frontend/src/core/api/account/devices.ts index 12882cd..aaa139d 100644 --- a/frontend/src/core/api/devicesApi.ts +++ b/frontend/src/core/api/account/devices.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import { getAuthHeaders } from "./index"; export interface DeviceInfo { session_id: string; @@ -18,20 +18,19 @@ export interface DeviceInfo { } export async function listDevices(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) }); + 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 revokeDevice(token: string, sessionId: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) }); + 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 logoutAllOtherDevices(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) }); + 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/authApi.ts b/frontend/src/core/api/account/index.ts similarity index 55% rename from frontend/src/core/api/authApi.ts rename to frontend/src/core/api/account/index.ts index a95c08f..af2318b 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,12 +1,15 @@ -import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse } 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 { API_BASE_URL } from "@/core/config"; import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; +import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; +import type { Headers } from "@/core/types"; /** * 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 */ @@ -23,54 +26,15 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head return headers; } -let currentPublicKey: Uint8Array | null = null; -let currentPrivateKey: Uint8Array | null = null; - -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); +export interface CheckAuthResponse { + authenticated: boolean; + username: string; + admin: boolean; } -async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { - const payload: UploadPublicKeyRequest = { - publicKey: b64(publicKey) - } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/public-key`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); -} - -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; - } -} - -async function uploadBackupBlob(blobJson: string, token: string): Promise { - const payload: BackupBlob = { blob: blobJson } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/backup`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); +export interface LogoutResponse { + status: string; + message: string; } export interface UserKeyPairMemory { @@ -78,6 +42,9 @@ export interface UserKeyPairMemory { 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; @@ -94,6 +61,72 @@ function saveKeys( 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); @@ -147,13 +180,41 @@ export function getAuthToken(): string | null { } /** - * 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. + * Changes the user's password */ -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); -} \ No newline at end of file +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/account/profile.ts b/frontend/src/core/api/account/profile.ts new file mode 100644 index 0000000..ddb1fbc --- /dev/null +++ b/frontend/src/core/api/account/profile.ts @@ -0,0 +1,275 @@ +import { getAuthHeaders } from "."; +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 loadProfile(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 uploadProfilePicture(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 updateProfile(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 fetchUserProfile(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 fetchUserProfileById(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; + } +} + +/** + * Toggles verification status for a user (owner only) + */ +export async function verifyUser(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; + } +} + +/** + * 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 checkUserSimilarity(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; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(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 unsuspendUser(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/crypto.ts b/frontend/src/core/api/crypto.ts new file mode 100644 index 0000000..ddb668f --- /dev/null +++ b/frontend/src/core/api/crypto.ts @@ -0,0 +1,76 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { UploadPublicKeyRequest, BackupBlob } 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; +} + +/** + * 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/dm.ts b/frontend/src/core/api/dm.ts new file mode 100644 index 0000000..b0cc194 --- /dev/null +++ b/frontend/src/core/api/dm.ts @@ -0,0 +1,178 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { getCurrentKeys } from "./account"; +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"; +import { fetchUsers, searchUsers } from "./users"; + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + + // Decrypt + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data = await response.json(); + return data.messages || []; +} + +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + +export async function sendDMViaWebSocket(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 sendDmWithFiles(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 fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function editDmEnvelope(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 deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} + +export interface DMConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function fetchDMConversations(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 || []; +} + diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index 632a40f..b0cc194 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,12 +1,14 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "./authApi"; +import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "./crypto"; +import { fetchUsers, searchUsers } from "./users"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { const keys = getCurrentKeys(); @@ -23,20 +25,6 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string return new TextDecoder().decode(msg); } -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 || []; -} - -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; -} - export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { headers: getAuthHeaders(token, true) @@ -46,6 +34,9 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); @@ -185,13 +176,3 @@ export async function fetchDMConversations(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 || []; -} diff --git a/frontend/src/core/api/files.ts b/frontend/src/core/api/files.ts new file mode 100644 index 0000000..01fe6bd --- /dev/null +++ b/frontend/src/core/api/files.ts @@ -0,0 +1,39 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +/** + * Gets the URL for a normal (unencrypted) file + */ +export function getNormalFileUrl(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) + */ +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(); +} + +/** + * 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(); +} + diff --git a/frontend/src/core/api/messaging.ts b/frontend/src/core/api/messaging.ts new file mode 100644 index 0000000..6cc3261 --- /dev/null +++ b/frontend/src/core/api/messaging.ts @@ -0,0 +1,87 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +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 { + let url = `${API_BASE_URL}/get_messages?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data: Messages = await response.json(); + return data.messages || []; +} + +/** + * Sends a public chat message via WebSocket + */ +export async function sendMessage(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 sendMessageWithFiles( + 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 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 editMessage(messageId: number, newContent: string, authToken: string): Promise { + const res = await 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 fetch(`${API_BASE_URL}/delete_message/${messageId}`, { + method: "DELETE", + headers: getAuthHeaders(authToken, true) + }); + if (!res.ok) throw new Error("Failed to delete message"); +} + diff --git a/frontend/src/core/api/moderation.ts b/frontend/src/core/api/moderation.ts new file mode 100644 index 0000000..6786973 --- /dev/null +++ b/frontend/src/core/api/moderation.ts @@ -0,0 +1,54 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +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 getBlocklist(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 addToBlocklist(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 removeFromBlocklist(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/profileApi.ts b/frontend/src/core/api/profileApi.ts index 5a25935..b161756 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -1,4 +1,4 @@ -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; import { API_BASE_URL } from "@/core/config"; import type { UserProfile } from "@/core/types"; @@ -208,3 +208,67 @@ export async function checkUserSimilarity(userId: number, token: string): Promis return result; } } + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(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), + 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 unsuspendUser(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) + }); + + 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) + }); + + 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 new file mode 100644 index 0000000..253b5cb --- /dev/null +++ b/frontend/src/core/api/push.ts @@ -0,0 +1,50 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +export interface PushSubscriptionRequest { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface PushSubscriptionResponse { + status: string; + 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(); +} + +/** + * 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"); + } + return await res.json(); +} + diff --git a/frontend/src/core/api/securityApi.ts b/frontend/src/core/api/securityApi.ts deleted file mode 100644 index a8488b8..0000000 --- a/frontend/src/core/api/securityApi.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi"; - -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), - body: JSON.stringify({ - currentPasswordDerived: currentDerived, - newPasswordDerived: newDerived, - logoutAllExceptCurrent - }) - }); - if (!res.ok) throw new Error("Failed to change password"); -} - -export async function deleteAccount(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/account/delete`, { - method: "POST", - headers: getAuthHeaders(token) - }); - if (!res.ok) { - const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); - throw new Error(error.detail || "Failed to delete account"); - } -} - - diff --git a/frontend/src/core/api/users.ts b/frontend/src/core/api/users.ts new file mode 100644 index 0000000..31dcb2c --- /dev/null +++ b/frontend/src/core/api/users.ts @@ -0,0 +1,28 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +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 || []; +} + diff --git a/frontend/src/core/api/webrtc.ts b/frontend/src/core/api/webrtc.ts new file mode 100644 index 0000000..81f7ff7 --- /dev/null +++ b/frontend/src/core/api/webrtc.ts @@ -0,0 +1,15 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { IceServersResponse } from "@/core/types"; + +/** + * Fetches ICE server configuration for WebRTC + */ +export async function getIceServers(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/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 5c0c926..e08c213 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/authApi"; +import { getCurrentKeys } from "@/core/api/account"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 1680908..470e42c 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,8 +1,9 @@ -import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; -import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; +import { getAuthToken } from "@/core/api/account"; +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/dmApi"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -99,16 +100,10 @@ export class WebRTCCall { */ private async getIceServers(): Promise { try { - const response = await fetch("/api/webrtc/ice", { - headers: getAuthHeaders(getAuthToken()!) - }); - - if (response.ok) { - const data = await response.json() as IceServersResponse; - return data.iceServers || []; - } else { - console.warn("Failed to fetch ICE servers:", response.status, response.statusText); - } + const token = getAuthToken(); + if (!token) throw new Error("No auth token"); + const data = await fetchIceServers(token); + return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); } diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index bce9df8..b8755d2 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/profileApi"; +import { checkUserSimilarity } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialIcon } from "@/utils/material"; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 07ed3de..4ddfa54 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/profileApi"; +import { verifyUser } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index a0760dd..ac94d75 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "@/core/config"; +import { subscribeToPush } from "@/core/api/push"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; @@ -89,16 +89,8 @@ async function sendSubscriptionToServer(token: string): Promise { }; try { - const response = await fetch(`${API_BASE_URL}/push/subscribe`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: JSON.stringify(subscriptionData) - }); - - return response.ok; + await subscribeToPush(subscriptionData, token); + return true; } catch (error) { console.error("Failed to send subscription to server:", error); return false; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index be6b091..4d3a930 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -2,11 +2,10 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; -import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; @@ -86,16 +85,8 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { password: derived } - const response = await fetch(`${API_BASE_URL}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); + try { + const data = await login(request); setUser(data.token, data.user); try { @@ -126,20 +117,16 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } catch (e) { console.error("Notification setup failed:", e); } - } else { - const data: ErrorResponse = await response.json(); - - if (response.status === 403 && response.headers.get("suspension_reason")) { - const suspensionReason = response.headers.get("suspension_reason"); + } catch (error: any) { + if (error.message && error.message.includes("suspension")) { const setSuspended = useAppState.getState().setSuspended; - setSuspended(suspensionReason || "No reason provided"); + setSuspended(error.message || "No reason provided"); return; } - - showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + showAlert("danger", error.message || "Неверное имя пользователя или пароль"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index b3e5c02..9ef19b5 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -2,11 +2,10 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; -import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { RegisterRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; @@ -115,16 +114,8 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { confirm_password: derived } - const response = await fetch(`${API_BASE_URL}/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); + try { + const data = await register(request); setUser(data.token, data.user); try { @@ -134,12 +125,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { } navigate("/chat"); - } else { - const data: ErrorResponse = await response.json(); - showAlert("danger", data.message || "Ошибка при регистрации"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка при регистрации"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 28dfb5f..e652b18 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -7,7 +7,7 @@ import { sendDMViaWebSocket, fetchDMConversations, type DMConversationResponse -} from "@/core/api/dmApi"; +} from "@/core/api/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index 3e0eb65..aa3f57b 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { useAppState } from "@/pages/chat/state"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi"; +import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 52679f4..7aea4c1 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -4,9 +4,9 @@ import { request } from "@/core/websocket"; import { MessagePanel } from "./ui/right/panels/MessagePanel"; import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { restoreKeys } from "@/core/api/authApi"; +import { restoreKeys } from "@/core/api/account"; 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"; @@ -298,12 +298,12 @@ export const useAppState = create((set, get) => ({ const token = localStorage.getItem('authToken'); if (token) { - const response = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token) + // Fetch full user profile + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) }); - - if (response.ok) { - const user: User = await response.json(); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); restoreKeys(); // Check if user is suspended diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 0a67f6f..33f8ade 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -5,7 +5,7 @@ import { CallWindow } from "./right/calls/CallWindow"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index b576348..11546bb 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -4,7 +4,7 @@ import type { ProfileDialogData } from "@/pages/chat/state"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; -import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi"; +import { updateProfile, uploadProfilePicture, fetchUserProfileById, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -349,37 +349,20 @@ export function ProfileDialog() { }); if (reason) { - const response = await fetch(`/api/user/${currentData.userId}/suspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ reason }) - }); - - if (response.ok) { + const result = await suspendUser(currentData.userId, reason, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to suspend user:", error); + console.error("Failed to suspend user"); } } } else { // Unsuspend user - const response = await fetch(`/api/user/${currentData.userId}/unsuspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); - - if (response.ok) { + const result = await unsuspendUser(currentData.userId, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to unsuspend user:", error); + console.error("Failed to unsuspend user"); } } } catch (error) { @@ -398,19 +381,12 @@ export function ProfileDialog() { cancelText: "Cancel" }); - const response = await fetch(`/api/user/${currentData.userId}/delete`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); + const result = await deleteUser(currentData.userId, user.authToken!); - if (response.ok) { + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to delete user:", error); + console.error("Failed to delete user"); } } catch (error) { // User cancelled or error occurred diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index fb40dad..0994cdb 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useAppState } from "@/pages/chat/state"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { fetchMessages } from "@/core/api/messaging"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -51,16 +50,10 @@ export function UnifiedChatsList() { if (!user.authToken) return; try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(user.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages?.length > 0) { - const lastMessage = data.messages[data.messages.length - 1]; - setLastMessages({ general: lastMessage }); - } + const messages = await fetchMessages(user.authToken, 1); + if (messages?.length > 0) { + const lastMessage = messages[messages.length - 1]; + setLastMessages({ general: lastMessage }); } } catch (error) { console.error("Error loading last messages:", error); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 9ed8dae..b37d0e1 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useAppState } from "@/pages/chat/state"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 16e39a9..9061e34 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 { useAppState } from "@/pages/chat/state"; -import { deleteAccount } from "@/core/api/securityApi"; +import { deleteAccount } from "@/core/api/account"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 8b6f9f1..78a9da6 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 { useAppState } from "@/pages/chat/state"; -import { changePassword } from "@/core/api/securityApi"; +import { changePassword } from "@/core/api/account"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index ac9dbc7..b6ccc4f 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; import { useAppState } from "@/pages/chat/state"; -import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; +import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index d5f0931..ba5c8bf 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -3,8 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from import { useAppState } from "@/pages/chat/state"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import { unsubscribeFromPush } from "@/core/api/push"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { @@ -74,14 +73,7 @@ export function NotificationsPanel() { } // Then unsubscribe from server - const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, { - method: "DELETE", - headers: getAuthHeaders(authToken) - }); - - if (!response.ok) { - throw new Error("Failed to unsubscribe from push notifications"); - } + await unsubscribeFromPush(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 757cdd9..137318a 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 } from "@/core/api/authApi"; +import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { getAuthHeaders } from "@/core/api/authApi"; import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 6956d1c..dc841f4 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -6,8 +6,8 @@ import { sendDmWithFiles, editDmEnvelope, deleteDmEnvelope -} from "@/core/api/dmApi"; -import { fetchUserProfileById } from "@/core/api/profileApi"; +} from "@/core/api/dm"; +import { fetchUserProfileById } from "@/core/api/account/profile"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index d8c0e7f..cc89906 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,9 +1,8 @@ import { MessagePanel } from "./MessagePanel"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; import { request } from "@/core/websocket"; -import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types"; +import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -42,18 +41,12 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(this.currentUser.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages && data.messages.length > 0) { - this.clearMessages(); - data.messages.forEach((msg: Message) => { - this.addMessage(msg); - }); - } + const messages = await fetchMessages(this.currentUser.authToken); + if (messages && messages.length > 0) { + this.clearMessages(); + messages.forEach((msg: Message) => { + this.addMessage(msg); + }); } this.messagesLoaded = true; } catch (error) { @@ -68,35 +61,9 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { - const response = await request({ - data: { - content: content.trim(), - reply_to_id: replyToId ?? null - }, - credentials: { - scheme: "Bearer", - credentials: this.currentUser.authToken - }, - type: "sendMessage" - } satisfies SendMessageRequest); - if (response.error) { - console.error("Error sending message:", response.error); - } + await sendMessage(content, replyToId ?? null, this.currentUser.authToken); } else { - 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 fetch(`${API_BASE_URL}/send_message`, { - method: "POST", - headers: getAuthHeaders(this.currentUser.authToken, false), - body: form - }); - if (!res.ok) { - console.error("Error sending message with files", await res.text()); - } + await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } catch (error) { console.error("Error sending message:", error);