Change the structure

This commit is contained in:
2025-11-23 21:33:09 +03:00
Unverified
parent 0f4256edad
commit 7d254b5658
36 changed files with 1261 additions and 184 deletions
+16
View File
@@ -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<IceServersResponse> {
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();
}
+194
View File
@@ -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<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
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<void> {
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<void> {
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<void> {
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<void> {
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<ConversationResponse[]> {
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<void> {
await request({
type: "dmMarkRead",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id }
});
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
+99
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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");
}
+37
View File
@@ -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<string | null> {
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<void> {
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");
}
+45
View File
@@ -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<Uint8Array | null> {
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<void> {
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<string | null> {
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;
}
+14
View File
@@ -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<void> {
// TODO: Implement Signal Protocol pre-key upload
throw new Error("Not implemented yet");
}
export async function fetch(_userId: number, _token: string): Promise<unknown> {
// TODO: Implement Signal Protocol pre-key fetch
throw new Error("Not implemented yet");
}
+18 -14
View File
@@ -1,39 +1,43 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./user/auth";
export const normal = {
/** /**
* Gets the URL for a normal (unencrypted) file * Gets the URL for a normal (unencrypted) file
*/ */
export function getNormalFileUrl(filename: string): string { url(filename: string): string {
return `${API_BASE_URL}/uploads/files/normal/${filename}`; 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) * Fetches a normal file (unencrypted)
*/ */
export async function fetchNormalFile(filename: string, token: string): Promise<Blob> { async fetch(filename: string, token: string): Promise<Blob> {
const res = await fetch(getNormalFileUrl(filename), { const res = await fetch(this.url(filename), {
headers: getAuthHeaders(token, false) headers: getAuthHeaders(token, false)
}); });
if (!res.ok) throw new Error("Failed to fetch file"); if (!res.ok) throw new Error("Failed to fetch file");
return await res.blob(); 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 * Fetches an encrypted file
*/ */
export async function fetchEncryptedFile(filename: string, token: string): Promise<Blob> { async fetch(filename: string, token: string): Promise<Blob> {
const res = await fetch(getEncryptedFileUrl(filename), { const res = await fetch(this.url(filename), {
headers: getAuthHeaders(token, false) headers: getAuthHeaders(token, false)
}); });
if (!res.ok) throw new Error("Failed to fetch encrypted file"); if (!res.ok) throw new Error("Failed to fetch encrypted file");
return await res.blob(); return await res.blob();
} }
};
+50
View File
@@ -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;
@@ -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<BlocklistResponse> {
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<BlocklistUpdateResponse> {
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<BlocklistUpdateResponse> {
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();
}
+89
View File
@@ -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;
}
}
+6 -4
View File
@@ -1,5 +1,5 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./user/auth";
export interface PushSubscriptionRequest { export interface PushSubscriptionRequest {
endpoint: string; endpoint: string;
@@ -14,10 +14,11 @@ export interface PushSubscriptionResponse {
message: string; message: string;
} }
export const subscription = {
/** /**
* Subscribes the current user to push notifications * Subscribes the current user to push notifications
*/ */
export async function subscribeToPush( async subscribe(
subscription: PushSubscriptionRequest, subscription: PushSubscriptionRequest,
token: string token: string
): Promise<PushSubscriptionResponse> { ): Promise<PushSubscriptionResponse> {
@@ -31,12 +32,12 @@ export async function subscribeToPush(
throw new Error(error.detail || "Failed to subscribe to push notifications"); throw new Error(error.detail || "Failed to subscribe to push notifications");
} }
return await res.json(); return await res.json();
} },
/** /**
* Unsubscribes the current user from push notifications * Unsubscribes the current user from push notifications
*/ */
export async function unsubscribeFromPush(token: string): Promise<PushSubscriptionResponse> { async unsubscribe(token: string): Promise<PushSubscriptionResponse> {
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, { const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE", method: "DELETE",
headers: getAuthHeaders(token, true) headers: getAuthHeaders(token, true)
@@ -47,4 +48,5 @@ export async function unsubscribeFromPush(token: string): Promise<PushSubscripti
} }
return await res.json(); return await res.json();
} }
};
+221
View File
@@ -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<ArrayBufferLike>,
privateKey: Uint8Array<ArrayBufferLike>
) {
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<CheckAuthResponse> {
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<LoginResponse> {
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<LoginResponse> {
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<LogoutResponse> {
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<string> {
// 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<UserKeyPairMemory> {
// 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<void> {
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();
}
+37
View File
@@ -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<DeviceInfo[]> {
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<void> {
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<void> {
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");
}
+191
View File
@@ -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<ProfileData | null> {
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<UploadResponse | null> {
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<ProfileData>): Promise<boolean> {
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<boolean> {
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<UserProfile | null> {
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<UserProfile | null> {
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<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* 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;
}
}
+40
View File
@@ -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<User[]> {
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<User[]> {
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<User | null> {
const res = await fetch(`${API_BASE_URL}/users/${userId}`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return null;
return await res.json();
}
+4 -4
View File
@@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy
import { randomBytes } from "@/utils/crypto/kdf"; import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { getCurrentKeys } from "@/core/api/account"; import api from "@/core/api";
import type { WrappedSessionKeyPayload } from "@/core/types"; import type { WrappedSessionKeyPayload } from "@/core/types";
export interface CallSessionKey { export interface CallSessionKey {
@@ -186,7 +186,7 @@ const CALL_INFO = new Uint8Array([2]);
* @returns Promise that resolves to the wrapped session key payload * @returns Promise that resolves to the wrapped session key payload
*/ */
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> { export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
const keys = getCurrentKeys(); const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
const salt = randomBytes(16); const salt = randomBytes(16);
@@ -209,7 +209,7 @@ export async function createSharedSecretAndDeriveSessionKey(
sessionKeyHash: string, sessionKeyHash: string,
isInitiator: boolean isInitiator: boolean
): Promise<CallSessionKey> { ): Promise<CallSessionKey> {
const keys = getCurrentKeys(); const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
// Create shared secret using ECDH // Create shared secret using ECDH
@@ -226,7 +226,7 @@ export async function createSharedSecretAndDeriveSessionKey(
* @returns Promise that resolves to the unwrapped session key * @returns Promise that resolves to the unwrapped session key
*/ */
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> { export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
const keys = getCurrentKeys(); const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
const salt = ub64(payload.salt); const salt = ub64(payload.salt);
+6 -8
View File
@@ -1,9 +1,7 @@
import { getAuthToken } from "@/core/api/account"; import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import { getIceServers as fetchIceServers } from "@/core/api/webrtc";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { fetchUserPublicKey } from "@/core/api/dm";
import { importAesGcmKey } from "@/utils/crypto/symmetric"; import { importAesGcmKey } from "@/utils/crypto/symmetric";
import E2EEWorker from "./e2eeWorker?worker"; import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils"; import { delay } from "@/utils/utils";
@@ -100,9 +98,9 @@ export class WebRTCCall {
*/ */
private async getIceServers(): Promise<RTCIceServer[]> { private async getIceServers(): Promise<RTCIceServer[]> {
try { try {
const token = getAuthToken(); const token = api.user.auth.getAuthToken();
if (!token) throw new Error("No auth token"); if (!token) throw new Error("No auth token");
const data = await fetchIceServers(token); const data = await api.calls.iceServers(token);
return data.iceServers || []; return data.iceServers || [];
} catch (error) { } catch (error) {
console.warn("Failed to fetch ICE servers:", error); console.warn("Failed to fetch ICE servers:", error);
@@ -774,7 +772,7 @@ async function sendSignalingMessage(message: CallSignalingMessage) {
type: "call_signaling", type: "call_signaling",
credentials: { credentials: {
scheme: "Bearer", scheme: "Bearer",
credentials: getAuthToken()! credentials: api.user.auth.getAuthToken()!
}, },
data: message 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<void> { export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
try { try {
const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!); const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
if (!recipientPublicKey) { if (!recipientPublicKey) {
console.warn("No recipient public key for", userId); console.warn("No recipient public key for", userId);
return; return;
@@ -892,7 +890,7 @@ export async function receiveWrappedSessionKey(
sessionKeyHash?: string sessionKeyHash?: string
): Promise<void> { ): Promise<void> {
try { try {
const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!); const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
if (!senderPublicKey) { if (!senderPublicKey) {
console.error("Failed to get sender public key"); console.error("Failed to get sender public key");
return; return;
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/account/profile"; import api from "@/core/api";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
@@ -18,7 +18,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
// Check similarity for unverified users // Check similarity for unverified users
useEffect(() => { useEffect(() => {
if (!verified && userId && user.authToken) { if (!verified && userId && user.authToken) {
checkUserSimilarity(userId, user.authToken) api.user.profile.checkSimilarity(userId, user.authToken)
.then(result => { .then(result => {
setIsSimilarToVerified(result?.isSimilar || false); setIsSimilarToVerified(result?.isSimilar || false);
}) })
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { verifyUser } from "@/core/api/account/profile"; import api from "@/core/api";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material"; import { MaterialButton } from "@/utils/material";
@@ -23,7 +23,7 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB
setIsVerifying(true); setIsVerifying(true);
try { try {
const result = await verifyUser(userId, user.authToken); const result = await api.moderation.users.verify(userId, user.authToken);
if (result) { if (result) {
onVerificationChange?.(result.verified); onVerificationChange?.(result.verified);
} }
@@ -1,4 +1,4 @@
import { subscribeToPush } from "@/core/api/push"; import api from "@/core/api";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
@@ -89,7 +89,7 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
}; };
try { try {
await subscribeToPush(subscriptionData, token); await api.push.subscription.subscribe(subscriptionData, token);
return true; return true;
} catch (error) { } catch (error) {
console.error("Failed to send subscription to server:", error); console.error("Failed to send subscription to server:", error);
+4 -4
View File
@@ -5,7 +5,7 @@ import { useImmer } from "use-immer";
import type { LoginRequest } from "@/core/types"; import type { LoginRequest } from "@/core/types";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material"; 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 { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
@@ -79,18 +79,18 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
setIsLoading(true); setIsLoading(true);
try { try {
const derived = await deriveAuthSecret(username, password); const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = { const request: LoginRequest = {
username: username, username: username,
password: derived password: derived
} }
try { try {
const data = await login(request); const data = await api.user.auth.login(request);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
await ensureKeysOnLogin(password, data.token); await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
+4 -4
View File
@@ -5,7 +5,7 @@ import { useImmer } from "use-immer";
import type { RegisterRequest } from "@/core/types"; import type { RegisterRequest } from "@/core/types";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { MaterialButton, MaterialIconButton } from "@/utils/material"; 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 { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import type { Alert, AlertType } from "./Auth"; import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth";
@@ -106,7 +106,7 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
setIsLoading(true); setIsLoading(true);
try { try {
const derived = await deriveAuthSecret(username, password); const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: RegisterRequest = { const request: RegisterRequest = {
display_name: displayName, display_name: displayName,
username: username, username: username,
@@ -115,11 +115,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
} }
try { try {
const data = await register(request); const data = await api.user.auth.register(request);
setUser(data.token, data.user); setUser(data.token, data.user);
try { try {
await ensureKeysOnLogin(password, data.token); await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
+21 -27
View File
@@ -1,14 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat"; import { useChatStore } from "@/state/chat";
import { import api from "@/core/api";
fetchUserPublicKey, import type { ConversationResponse } from "@/core/api/chats/dm";
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
fetchDMConversations,
type DMConversationResponse
} from "@/core/api/dm";
import type { User, Message, DmEncryptedJSON } from "@/core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
@@ -58,11 +52,11 @@ export function useDM() {
try { try {
// Get public key // 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; if (!publicKey) return;
// Get message history // 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; if (messages.length === 0) return;
// Find last message // Find last message
@@ -70,7 +64,7 @@ export function useDM() {
let lastPlaintext: string | null = null; let lastPlaintext: string | null = null;
try { 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) { } catch (error) {
console.error("Failed to decrypt last message:", error); console.error("Failed to decrypt last message:", error);
} }
@@ -107,11 +101,11 @@ export function useDM() {
usersLoadedRef.current = true; usersLoadedRef.current = true;
setIsLoadingUsers(true); setIsLoadingUsers(true);
try { try {
const conversations = await fetchDMConversations(user.authToken); const conversations = await api.chats.dm.conversations(user.authToken);
// Process conversations and decrypt last messages // Process conversations and decrypt last messages
const dmUsersWithState: DMUser[] = await Promise.all( const dmUsersWithState: DMUser[] = await Promise.all(
conversations.map(async (conv: DMConversationResponse) => { conversations.map(async (conv: ConversationResponse) => {
let lastMessageContent: string | undefined = undefined; let lastMessageContent: string | undefined = undefined;
if (conv.lastMessage) { if (conv.lastMessage) {
@@ -121,10 +115,10 @@ export function useDM() {
? conv.lastMessage.recipientId ? conv.lastMessage.recipientId
: conv.lastMessage.senderId; : conv.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
// Decrypt the last message // 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; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
} }
@@ -143,7 +137,7 @@ export function useDM() {
); );
setDmUsersState(dmUsersWithState); setDmUsersState(dmUsersWithState);
setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user)); setDmUsers(conversations.map((conv: ConversationResponse) => conv.user));
} catch (error) { } catch (error) {
console.error("Failed to load DM conversations:", error); console.error("Failed to load DM conversations:", error);
@@ -163,13 +157,13 @@ export function useDM() {
setIsLoadingHistory(true); setIsLoadingHistory(true);
try { try {
const messages = await fetchDMHistory(userId, user.authToken, 50); const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
const decryptedMessages: Message[] = []; const decryptedMessages: Message[] = [];
let maxIncomingId = 0; let maxIncomingId = 0;
for (const env of messages) { for (const env of messages) {
try { try {
const text = await decryptDm(env, publicKey); const text = await api.chats.dm.decrypt(env, publicKey);
const isAuthor = env.senderId !== userId; const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
@@ -214,7 +208,7 @@ export function useDM() {
if (!user.authToken) return; if (!user.authToken) return;
try { try {
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken); await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
} catch (error) { } catch (error) {
console.error("Failed to send DM:", error); console.error("Failed to send DM:", error);
} }
@@ -228,7 +222,7 @@ export function useDM() {
// Get public key if not already loaded // Get public key if not already loaded
let publicKey = dmUser.publicKey; let publicKey = dmUser.publicKey;
if (!publicKey) { if (!publicKey) {
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return; if (!publicKey) return;
} }
@@ -257,7 +251,7 @@ export function useDM() {
if (!user.authToken) return; if (!user.authToken) return;
try { 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); const userConversation = conversations.find(conv => conv.user.id === userId);
if (userConversation) { if (userConversation) {
@@ -270,10 +264,10 @@ export function useDM() {
? userConversation.lastMessage.recipientId ? userConversation.lastMessage.recipientId
: userConversation.lastMessage.senderId; : userConversation.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { if (publicKey) {
// Decrypt the last message // 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; const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); 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 // Update unread count and last message preview
try { try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { 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 decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content; const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
@@ -352,9 +346,9 @@ export function useDM() {
} }
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
try { try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) { 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 decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content; const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
+5 -4
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { useUserStore } from "@/state/user"; 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"; import { showSuccess, showError } from "@/utils/notification";
export default function useProfile() { export default function useProfile() {
@@ -15,7 +16,7 @@ export default function useProfile() {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await loadProfile(user.authToken); const data = await api.user.profile.get(user.authToken);
if (data) { if (data) {
setProfileData(data); setProfileData(data);
} }
@@ -33,7 +34,7 @@ export default function useProfile() {
setIsUpdating(true); setIsUpdating(true);
try { try {
const success = await updateProfile(user.authToken, data); const success = await api.user.profile.update(user.authToken, data);
if (success) { if (success) {
// Reload profile data to get updated information // Reload profile data to get updated information
await loadProfileData(); await loadProfileData();
@@ -58,7 +59,7 @@ export default function useProfile() {
setIsUpdating(true); setIsUpdating(true);
try { try {
const result = await uploadProfilePicture(user.authToken, file); const result = await api.user.profile.uploadPicture(user.authToken, file);
if (result) { if (result) {
// Update profile data with new picture URL // Update profile data with new picture URL
setProfileData(prev => prev ? { setProfileData(prev => prev ? {
+3 -3
View File
@@ -6,7 +6,7 @@ import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; 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"; import styles from "@/pages/chat/css/layout.module.scss";
export default function ChatPage() { export default function ChatPage() {
@@ -43,10 +43,10 @@ export default function ChatPage() {
if (profileInfo.userId) { if (profileInfo.userId) {
// Fetch by user ID // 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) { } else if (profileInfo.username) {
// Fetch by username // Fetch by username
userProfile = await fetchUserProfile(user.authToken, profileInfo.username); userProfile = await api.user.profile.fetchByUsername(user.authToken, profileInfo.username);
} }
if (userProfile) { if (userProfile) {
+7 -7
View File
@@ -5,7 +5,7 @@ import type { ProfileDialogData } from "@/state/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import { prompt } from "mdui/functions/prompt"; 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 { RichTextArea } from "@/core/components/RichTextArea";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import { VerifyButton } from "@/core/components/VerifyButton"; 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 it's not the public chat and has a user ID, fetch fresh data
if (profileData.userId && profileData.username !== "Общий чат") { 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) { if (userProfile) {
freshData = { freshData = {
...userProfile, ...userProfile,
@@ -285,7 +285,7 @@ export function ProfileDialog() {
} }
if (Object.keys(updateData).length > 0) { if (Object.keys(updateData).length > 0) {
await updateProfile(user.authToken, updateData); await api.user.profile.update(user.authToken, updateData);
} }
// Update profile picture if changed // Update profile picture if changed
@@ -294,7 +294,7 @@ export function ProfileDialog() {
if (currentData.profilePicture.startsWith("data:")) { if (currentData.profilePicture.startsWith("data:")) {
const response = await fetch(currentData.profilePicture); const response = await fetch(currentData.profilePicture);
const blob = await response.blob(); 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) { 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) { if (result) {
closeProfileDialog(); closeProfileDialog();
} else { } else {
@@ -360,7 +360,7 @@ export function ProfileDialog() {
} }
} else { } else {
// Unsuspend user // Unsuspend user
const result = await unsuspendUser(currentData.userId, user.authToken!); const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!);
if (result) { if (result) {
closeProfileDialog(); closeProfileDialog();
} else { } else {
@@ -383,7 +383,7 @@ export function ProfileDialog() {
cancelText: "Cancel" cancelText: "Cancel"
}); });
const result = await deleteUser(currentData.userId, user.authToken!); const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!);
if (result) { if (result) {
closeProfileDialog(); closeProfileDialog();
@@ -2,8 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat"; import { useChatStore } from "@/state/chat";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { fetchMessages } from "@/core/api/messaging"; import api from "@/core/api";
import { fetchUserPublicKey } from "@/core/api/dm";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import type { Message } from "@/core/types"; import type { Message } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
@@ -52,7 +51,7 @@ export function UnifiedChatsList() {
if (!user.authToken) return; if (!user.authToken) return;
try { try {
const messages = await fetchMessages(user.authToken, 1); const { messages } = await api.chats.general.fetchMessages(user.authToken, 1);
if (messages?.length > 0) { if (messages?.length > 0) {
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
setLastMessages({ general: lastMessage }); setLastMessages({ general: lastMessage });
@@ -160,7 +159,7 @@ export function UnifiedChatsList() {
const authToken = useUserStore.getState().user.authToken; const authToken = useUserStore.getState().user.authToken;
if (!authToken) return; if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken);
if (!publicKey) { if (!publicKey) {
console.error("Failed to get public key for user:", dmConversation.id); console.error("Failed to get public key for user:", dmConversation.id);
return; return;
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat"; import { useChatStore } from "@/state/chat";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types"; import type { User } from "@/core/types";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
@@ -45,7 +45,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
const newTimeout = setTimeout(async () => { const newTimeout = setTimeout(async () => {
if (user.authToken) { if (user.authToken) {
try { try {
const users = await searchUsers(searchQuery, user.authToken); const users = await api.user.search.searchUsers(searchQuery, user.authToken);
setSearchResults(users); setSearchResults(users);
} catch (error) { } catch (error) {
console.error("Search failed:", error); console.error("Search failed:", error);
@@ -118,7 +118,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
let publicKey = searchUser.publicKey; let publicKey = searchUser.publicKey;
if (!publicKey) { if (!publicKey) {
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken); const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken);
publicKey = fetchedPublicKey; publicKey = fetchedPublicKey;
} }
@@ -1,6 +1,6 @@
import { MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialList, MaterialListItem } from "@/utils/material";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { deleteAccount } from "@/core/api/account"; import api from "@/core/api";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
@@ -23,7 +23,7 @@ export function AccountPanel({ onClose }: AccountPanelProps) {
cancelText: "Cancel" cancelText: "Cancel"
}); });
await deleteAccount(authToken); await api.user.auth.deleteAccount(authToken);
logout(); logout();
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -2,7 +2,7 @@ import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import type { DialogProps } from "@/core/types"; import type { DialogProps } from "@/core/types";
import { useUserStore } from "@/state/user"; 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 { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; 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; if (!current || !next || next !== confirm) return;
setBusy(true); setBusy(true);
try { 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(""); setCurrent("");
setNext(""); setNext("");
setConfirm(""); setConfirm("");
@@ -2,7 +2,8 @@ import { useState, useEffect } from "react";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
import { useUserStore } from "@/state/user"; 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 { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
@@ -24,7 +25,7 @@ export function DevicesPanel() {
setDevicesLoading(true); setDevicesLoading(true);
try { try {
const deviceList = await listDevices(authToken); const deviceList = await api.user.devices.list(authToken);
updateDevices(deviceList); updateDevices(deviceList);
} catch (error) { } catch (error) {
console.error("Failed to load devices:", error); console.error("Failed to load devices:", error);
@@ -48,7 +49,7 @@ export function DevicesPanel() {
draft.add(sessionId); draft.add(sessionId);
}); });
await revokeDevice(authToken, sessionId); await api.user.devices.revoke(authToken, sessionId);
await loadDevices(); await loadDevices();
} catch (error) { } catch (error) {
if (error !== "cancelled") { if (error !== "cancelled") {
@@ -72,7 +73,7 @@ export function DevicesPanel() {
cancelText: "Cancel" cancelText: "Cancel"
}); });
await logoutAllOtherDevices(authToken); await api.user.devices.revokeAll(authToken);
await loadDevices(); await loadDevices();
} catch (error) { } catch (error) {
if (error !== "cancelled") { if (error !== "cancelled") {
@@ -3,7 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; 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"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function NotificationsPanel() { export function NotificationsPanel() {
@@ -73,7 +73,7 @@ export function NotificationsPanel() {
} }
// Then unsubscribe from server // Then unsubscribe from server
await unsubscribeFromPush(authToken); await api.push.subscription.unsubscribe(authToken);
// After unsubscribing, permission is still granted but we're not subscribed // After unsubscribing, permission is still granted but we're not subscribed
// So we keep the state as disabled (false) // So we keep the state as disabled (false)
+8 -9
View File
@@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote";
import { parse } from "marked"; import { parse } from "marked";
import { escape as escapeHtml } from "he"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; 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 { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; import { useProfileStore } from "@/state/profile";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
@@ -223,14 +222,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// no-op decrypt indicator removed from UI // no-op decrypt indicator removed from UI
// Fetch encrypted file // Fetch encrypted file
const response = await fetch(file.path, { 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"); if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer(); const encryptedData = await response.arrayBuffer();
// Get current user's keys // Get current user's keys
const keys = getCurrentKeys(); const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key // 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 // Fetch with credentials/headers when not a blob URL
const response = await fetch(src, { const response = await fetch(src, {
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
credentials: "include" credentials: "include"
}); });
if (!response.ok) throw new Error("Failed to download image"); 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 // If not decrypted or public file, fetch with credentials/headers
const response = await fetch(file.path, { 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" credentials: "include"
}); });
if (!response.ok) throw new Error("Failed to download file"); 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; if (!user.authToken || !message.user_id) return;
try { try {
const userProfile = await fetchUserProfileById(user.authToken, message.user_id); const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id);
if (userProfile) { if (userProfile) {
setProfileDialog({ setProfileDialog({
...userProfile, ...userProfile,
@@ -434,9 +433,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
let userProfile; let userProfile;
if (profileLink.userId) { if (profileLink.userId) {
userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId);
} else if (profileLink.username) { } else if (profileLink.username) {
userProfile = await fetchUserProfile(user.authToken, profileLink.username); userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username);
} }
if (userProfile) { if (userProfile) {
@@ -1,13 +1,5 @@
import { MessagePanel } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import { import api from "@/core/api";
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "@/core/api/dm";
import { fetchUserProfileById } from "@/core/api/account/profile";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types"; import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { formatDMUsername } from "@/pages/chat/hooks/useDM";
@@ -63,7 +55,7 @@ export class DMPanel extends MessagePanel {
} }
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { 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( const username = formatDMUsername(
env.senderId, env.senderId,
env.recipientId, env.recipientId,
@@ -111,7 +103,7 @@ export class DMPanel extends MessagePanel {
this.setLoading(true); this.setLoading(true);
try { 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[] = []; const decryptedMessages: Message[] = [];
let maxIncomingId = 0; let maxIncomingId = 0;
@@ -157,14 +149,14 @@ export class DMPanel extends MessagePanel {
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
if (files.length === 0) { if (files.length === 0) {
await sendDMViaWebSocket( await api.chats.dm.send(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
json, json,
this.currentUser.authToken this.currentUser.authToken
); );
} else { } else {
await sendDmWithFiles( await api.chats.dm.sendWithFiles(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
json, json,
@@ -228,7 +220,7 @@ export class DMPanel extends MessagePanel {
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try { try {
// Decrypt new content in-place // Decrypt new content in-place
const plaintext = await decryptDm( const plaintext = await api.chats.dm.decrypt(
{ {
id, id,
senderId: 0, senderId: 0,
@@ -330,7 +322,7 @@ export class DMPanel extends MessagePanel {
this.deleteMessageImmediately(messageId); this.deleteMessageImmediately(messageId);
// Fire and forget server deletion; UI already updated // 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<void> { async handleEditMessage(messageId: number, content: string): Promise<void> {
@@ -345,7 +337,7 @@ export class DMPanel extends MessagePanel {
reply_to_id: msg?.reply_to?.id ?? undefined 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); console.error("Failed to edit DM:", e);
}); });
} }
@@ -354,7 +346,7 @@ export class DMPanel extends MessagePanel {
if (!this.dmData || !this.currentUser.authToken) return null; if (!this.dmData || !this.currentUser.authToken) return null;
try { 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; if (!userProfile) return null;
return { return {
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/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 { export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false; private messagesLoaded: boolean = false;
@@ -41,7 +41,7 @@ export class PublicChatPanel extends MessagePanel {
this.setLoading(true); this.setLoading(true);
try { try {
const messages = await fetchMessages(this.currentUser.authToken); const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken);
if (messages && messages.length > 0) { if (messages && messages.length > 0) {
this.clearMessages(); this.clearMessages();
messages.forEach((msg: Message) => { messages.forEach((msg: Message) => {
@@ -61,9 +61,9 @@ export class PublicChatPanel extends MessagePanel {
try { try {
if (files.length === 0) { if (files.length === 0) {
await sendMessage(content, replyToId ?? null, this.currentUser.authToken); await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken);
} else { } else {
await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
} }
} catch (error) { } catch (error) {
console.error("Error sending message:", error); console.error("Error sending message:", error);
+3 -4
View File
@@ -1,9 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import type { User } from "@/core/types"; import type { User } from "@/core/types";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import { restoreKeys } from "@/core/api/account"; import api from "@/core/api";
import { API_BASE_URL } from "@/core/config"; 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 { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
@@ -84,11 +83,11 @@ export const useUserStore = create<UserStore>((set) => ({
if (token) { if (token) {
const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token, true) headers: api.user.auth.getAuthHeaders(token, true)
}); });
if (fullResponse.ok) { if (fullResponse.ok) {
const user: User = await fullResponse.json(); const user: User = await fullResponse.json();
restoreKeys(); api.user.auth.restoreKeys();
if (user.suspended) { if (user.suspended) {
set({ set({