Refactor APIs

This commit is contained in:
2025-11-18 22:02:44 +03:00
Unverified
parent e23959fbd9
commit a15d3a08bf
35 changed files with 1075 additions and 313 deletions
@@ -1,5 +1,5 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi";
import { getAuthHeaders } from "./index";
export interface DeviceInfo {
session_id: string;
@@ -18,20 +18,19 @@ export interface DeviceInfo {
}
export async function listDevices(token: string): Promise<DeviceInfo[]> {
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) });
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to fetch devices");
const data = await res.json();
return data.devices as DeviceInfo[];
}
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) });
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to revoke device");
}
export async function logoutAllOtherDevices(token: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) });
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to logout all devices");
}
@@ -1,12 +1,15 @@
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { API_BASE_URL } from "@/core/config";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
import type { Headers } from "@/core/types";
/**
* Generates authentication headers for API requests
* @param {string | null} token - Authentication token
* @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type
*/
@@ -23,54 +26,15 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head
return headers;
}
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
async function fetchPublicKey(token: string): Promise<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);
export interface CheckAuthResponse {
authenticated: boolean;
username: string;
admin: boolean;
}
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
}
async function fetchBackupBlob(token: string): Promise<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;
}
}
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson }
const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
export interface LogoutResponse {
status: string;
message: string;
}
export interface UserKeyPairMemory {
@@ -78,6 +42,9 @@ export interface UserKeyPairMemory {
privateKey: Uint8Array;
}
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
export function getCurrentKeys(): UserKeyPairMemory | null {
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
return null;
@@ -94,6 +61,72 @@ function saveKeys(
localStorage.setItem("privateKey", encodedPrivateKey);
}
/**
* Checks if the current user is authenticated
*/
export async function checkAuth(token: string): Promise<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);
@@ -147,13 +180,41 @@ export function getAuthToken(): string | null {
}
/**
* Derive a client-side authentication secret so the raw password never leaves the client.
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
* Changes the user's password
*/
export async function deriveAuthSecret(username: string, password: string): Promise<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 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();
}
+275
View File
@@ -0,0 +1,275 @@
import { getAuthHeaders } from ".";
import { API_BASE_URL } from "@/core/config";
import type { UserProfile } from "@/core/types";
export interface ProfileData {
profile_picture?: string;
username?: string;
display_name?: string;
description?: string;
}
export interface UploadResponse {
profile_picture_url: string;
}
/**
* Loads user profile data from the server
*/
export async function loadProfile(token: string): Promise<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 uploadProfilePicture(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 updateProfile(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 fetchUserProfile(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 fetchUserProfileById(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;
}
}
/**
* Toggles verification status for a user (owner only)
*/
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error verifying user:', error);
return null;
}
}
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<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 checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
// Check cache first
if (similarityCache.has(userId)) {
return similarityCache.get(userId) ?? null;
}
try {
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
headers: getAuthHeaders(token, true)
});
let result: {isSimilar: boolean, similarTo?: string} | null = null;
if (response.ok) {
result = await response.json();
}
// Cache the result (even if null/error)
similarityCache.set(userId, result);
return result;
} catch (error) {
console.error('Error checking user similarity:', error);
const result: null = null;
// Cache null result to avoid retrying on errors
similarityCache.set(userId, result);
return result;
}
}
/**
* Suspends a user account (admin only)
*/
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
method: 'POST',
headers: getAuthHeaders(token, true),
body: JSON.stringify({ reason })
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error suspending user:', error);
return null;
}
}
/**
* Unsuspends a user account (admin only)
*/
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error unsuspending user:', error);
return null;
}
}
/**
* Deletes a user account (admin only)
*/
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error deleting user:', error);
return null;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
/**
* Fetches the current user's public key
*/
export async function fetchPublicKey(token: string): Promise<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;
}
/**
* 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");
}
+178
View File
@@ -0,0 +1,178 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<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 fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<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 sendDmWithFiles(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 fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<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 deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface DMConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
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 || [];
}
+8 -27
View File
@@ -1,12 +1,14 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./authApi";
import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./authApi";
import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "./crypto";
import { fetchUsers, searchUsers } from "./users";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
@@ -23,20 +25,6 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string
return new TextDecoder().decode(msg);
}
export async function fetchUsers(token: string): Promise<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 || [];
}
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;
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
@@ -46,6 +34,9 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
return data.messages || [];
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
@@ -185,13 +176,3 @@ export async function fetchDMConversations(token: string): Promise<DMConversatio
return data.conversations || [];
}
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 || [];
}
+39
View File
@@ -0,0 +1,39 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
/**
* Gets the URL for a normal (unencrypted) file
*/
export function getNormalFileUrl(filename: string): string {
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
}
/**
* Gets the URL for an encrypted file
*/
export function getEncryptedFileUrl(filename: string): string {
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
}
/**
* Fetches a normal file (unencrypted)
*/
export async function fetchNormalFile(filename: string, token: string): Promise<Blob> {
const res = await fetch(getNormalFileUrl(filename), {
headers: getAuthHeaders(token, false)
});
if (!res.ok) throw new Error("Failed to fetch file");
return await res.blob();
}
/**
* Fetches an encrypted file
*/
export async function fetchEncryptedFile(filename: string, token: string): Promise<Blob> {
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();
}
+87
View File
@@ -0,0 +1,87 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import type { Message, Messages, SendMessageRequest } from "@/core/types";
import { request } from "@/core/websocket";
/**
* Fetches public chat messages
*/
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<Message[]> {
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
if (beforeId) {
url += `&before_id=${beforeId}`;
}
const response = await fetch(url, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data: Messages = await response.json();
return data.messages || [];
}
/**
* Sends a public chat message via WebSocket
*/
export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise<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 sendMessageWithFiles(
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 fetch(`${API_BASE_URL}/send_message`, {
method: "POST",
headers: getAuthHeaders(authToken, false),
body: form
});
if (!res.ok) {
const error = await res.text();
throw new Error(error || "Failed to send message with files");
}
}
/**
* Edits a public chat message
*/
export async function editMessage(messageId: number, newContent: string, authToken: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
method: "PUT",
headers: getAuthHeaders(authToken, true),
body: JSON.stringify({ content: newContent })
});
if (!res.ok) throw new Error("Failed to edit message");
}
/**
* Deletes a public chat message
*/
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
method: "DELETE",
headers: getAuthHeaders(authToken, true)
});
if (!res.ok) throw new Error("Failed to delete message");
}
+54
View File
@@ -0,0 +1,54 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
export interface BlocklistResponse {
words: string[];
}
export interface BlocklistUpdateRequest {
words: string[];
}
export interface BlocklistUpdateResponse {
added?: string[];
removed?: string[];
words: string[];
}
/**
* Fetches the current blocklist (admin only)
*/
export async function getBlocklist(token: string): Promise<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 addToBlocklist(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 removeFromBlocklist(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();
}
+65 -1
View File
@@ -1,4 +1,4 @@
import { getAuthHeaders } from "./authApi";
import { getAuthHeaders } from "./account";
import { API_BASE_URL } from "@/core/config";
import type { UserProfile } from "@/core/types";
@@ -208,3 +208,67 @@ export async function checkUserSimilarity(userId: number, token: string): Promis
return result;
}
}
/**
* Suspends a user account (admin only)
*/
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
method: 'POST',
headers: getAuthHeaders(token),
body: JSON.stringify({ reason })
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error suspending user:', error);
return null;
}
}
/**
* Unsuspends a user account (admin only)
*/
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
method: 'POST',
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error unsuspending user:', error);
return null;
}
}
/**
* Deletes a user account (admin only)
*/
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
method: 'POST',
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error deleting user:', error);
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
export interface PushSubscriptionRequest {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface PushSubscriptionResponse {
status: string;
message: string;
}
/**
* Subscribes the current user to push notifications
*/
export async function subscribeToPush(
subscription: PushSubscriptionRequest,
token: string
): Promise<PushSubscriptionResponse> {
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<PushSubscriptionResponse> {
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();
}
-36
View File
@@ -1,36 +0,0 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi";
export async function changePassword(
token: string,
username: string,
currentPassword: string,
newPassword: string,
logoutAllExceptCurrent: boolean
): Promise<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),
body: JSON.stringify({
currentPasswordDerived: currentDerived,
newPasswordDerived: newDerived,
logoutAllExceptCurrent
})
});
if (!res.ok) throw new Error("Failed to change password");
}
export async function deleteAccount(token: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/account/delete`, {
method: "POST",
headers: getAuthHeaders(token)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
throw new Error(error.detail || "Failed to delete account");
}
}
+28
View File
@@ -0,0 +1,28 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import type { User } from "@/core/types";
/**
* Fetches a list of all users (excluding current user)
*/
export async function fetchUsers(token: string): Promise<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 || [];
}
+15
View File
@@ -0,0 +1,15 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import type { IceServersResponse } from "@/core/types";
/**
* Fetches ICE server configuration for WebRTC
*/
export async function getIceServers(token: string): Promise<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();
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy
import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { getCurrentKeys } from "@/core/api/authApi";
import { getCurrentKeys } from "@/core/api/account";
import type { WrappedSessionKeyPayload } from "@/core/types";
export interface CallSessionKey {
+8 -13
View File
@@ -1,8 +1,9 @@
import { getAuthHeaders, getAuthToken } from "@/core/api/authApi";
import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types";
import { getAuthToken } from "@/core/api/account";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import { getIceServers as fetchIceServers } from "@/core/api/webrtc";
import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { fetchUserPublicKey } from "@/core/api/dmApi";
import { fetchUserPublicKey } from "@/core/api/dm";
import { importAesGcmKey } from "@/utils/crypto/symmetric";
import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils";
@@ -99,16 +100,10 @@ export class WebRTCCall {
*/
private async getIceServers(): Promise<RTCIceServer[]> {
try {
const response = await fetch("/api/webrtc/ice", {
headers: getAuthHeaders(getAuthToken()!)
});
if (response.ok) {
const data = await response.json() as IceServersResponse;
return data.iceServers || [];
} else {
console.warn("Failed to fetch ICE servers:", response.status, response.statusText);
}
const token = getAuthToken();
if (!token) throw new Error("No auth token");
const data = await fetchIceServers(token);
return data.iceServers || [];
} catch (error) {
console.warn("Failed to fetch ICE servers:", error);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi";
import { checkUserSimilarity } from "@/core/api/account/profile";
import { useAppState } from "@/pages/chat/state";
import { MaterialIcon } from "@/utils/material";
@@ -1,5 +1,5 @@
import { useState } from "react";
import { verifyUser } from "@/core/api/profileApi";
import { verifyUser } from "@/core/api/account/profile";
import { useAppState } from "@/pages/chat/state";
import { MaterialButton } from "@/utils/material";
@@ -1,4 +1,4 @@
import { API_BASE_URL } from "@/core/config";
import { subscribeToPush } from "@/core/api/push";
import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
@@ -89,16 +89,8 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
};
try {
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(subscriptionData)
});
return response.ok;
await subscribeToPush(subscriptionData, token);
return true;
} catch (error) {
console.error("Failed to send subscription to server:", error);
return false;