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
+36
View File
@@ -0,0 +1,36 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./index";
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 listDevices(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 revokeDevice(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 logoutAllOtherDevices(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");
}
+220
View File
@@ -0,0 +1,220 @@
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 { 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
*/
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();
}
+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;
}
}