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({