/
nasya
/
SafeDrop
Обзор
Документация
Войти
/
nasya
/
SafeDrop
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/web/src/lib/api/file-api.ts
451 строка
14 KB
nasya
Initial commit
24 май 2026, 16:12
24 май 2026, 16:12
16bc032
Код
Авторство
О чём код?
import type { CreateFileRequest, FileSignatureStatus, FileStatus, PublicKeyRecord } from "@safedrop/shared"; export type ObjectDescriptor = { method: "GET" | "PUT"; objectKey: string; url: string; }; export type WrappedFileKeyRecord = CreateFileRequest["wrappedKeys"][number] & { readAt?: string; recipientEmail?: string; recipientHiddenAt?: string; signatureCheckedAt?: string; signatureStatus?: FileSignatureStatus; }; export type FileRecord = Omit<CreateFileRequest, "wrappedKeys"> & { createdAt: string; id: string; objectKey: string; senderEmail?: string; senderHiddenAt?: string; senderIdentityId: string; status: FileStatus; wrappedKeys: WrappedFileKeyRecord[]; }; export type FileDetails = { download: ObjectDescriptor; file: FileRecord; }; export type CreatedFile = { file: FileRecord; upload: ObjectDescriptor; }; export type PublicKeysLookup = { email?: string; identityId: string; publicKeys: PublicKeyRecord[]; }; export type UpdateReadStateRequest = { read: boolean; signatureStatus?: Exclude<FileSignatureStatus, "unchecked">; }; export class ApiAuthError extends Error { constructor() { super("Нужно войти в аккаунт."); } } function authHeaders(accessToken: string) { return { authorization: `Bearer ${accessToken}`, }; } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); return copy.buffer; } async function jsonOrError<T>(response: Response, fallbackMessage: string): Promise<T> { if (response.status === 401) { throw new ApiAuthError(); } if (!response.ok) { throw new Error(`${fallbackMessage}: ${response.status}`); } return (await response.json()) as T; } export async function getPublicKeys( apiUrl: string, identityId: string, fetchImpl: typeof fetch = fetch, ): Promise<PublicKeysLookup> { const response = await fetchImpl(`${apiUrl}/api/keys/public/${encodeURIComponent(identityId)}`); return jsonOrError<PublicKeysLookup>(response, "Не удалось получить публичные ключи получателя"); } export async function getPublicKeysByEmail( apiUrl: string, email: string, fetchImpl: typeof fetch = fetch, ): Promise<PublicKeysLookup> { const response = await fetchImpl(`${apiUrl}/api/keys/public/email/${encodeURIComponent(email)}`); if (response.status === 404) { throw new Error("Получатель не найден или еще не настроил ключи."); } return jsonOrError<PublicKeysLookup>(response, "Не удалось найти получателя"); } export function selectActivePublicKey(keys: PublicKeyRecord[]): PublicKeyRecord { const active = keys.find((key) => key.status === "active"); if (!active) { throw new Error("У получателя пока не настроены ключи."); } return active; } export async function createFileMetadata( apiUrl: string, accessToken: string, request: CreateFileRequest, fetchImpl: typeof fetch = fetch, ): Promise<CreatedFile> { const response = await fetchImpl(`${apiUrl}/api/files`, { body: JSON.stringify(request), headers: { ...authHeaders(accessToken), "content-type": "application/json", }, method: "POST", }); return jsonOrError<CreatedFile>(response, "Не удалось подготовить файл"); } export async function uploadEncryptedBlob( upload: ObjectDescriptor, ciphertext: Uint8Array, mimeType: string, fetchImpl: typeof fetch = fetch, ): Promise<void> { const response = await fetchImpl(upload.url, { body: toArrayBuffer(ciphertext), headers: { "content-type": mimeType }, method: upload.method, }); if (!response.ok) { throw new Error(`Не удалось отправить файл: ${response.status}`); } } export async function updateFileStatus( apiUrl: string, accessToken: string, fileId: string, status: FileStatus, fetchImpl: typeof fetch = fetch, ): Promise<FileRecord> { const response = await fetchImpl(`${apiUrl}/api/files/${encodeURIComponent(fileId)}/status`, { body: JSON.stringify({ status }), headers: { ...authHeaders(accessToken), "content-type": "application/json", }, method: "PATCH", }); return jsonOrError<FileRecord>(response, "Не удалось обновить статус файла"); } export async function updateFileReadState( apiUrl: string, accessToken: string, fileId: string, request: UpdateReadStateRequest, fetchImpl: typeof fetch = fetch, ): Promise<FileRecord> { const response = await fetchImpl(`${apiUrl}/api/files/${encodeURIComponent(fileId)}/read-state`, { body: JSON.stringify(request), headers: { ...authHeaders(accessToken), "content-type": "application/json", }, method: "PATCH", }); return jsonOrError<FileRecord>(response, "Не удалось обновить состояние входящего файла"); } export async function markInboxRead( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<number> { const response = await fetchImpl(`${apiUrl}/api/files/inbox/read-all`, { headers: authHeaders(accessToken), method: "PATCH", }); const result = await jsonOrError<{ updated: number }>(response, "Не удалось отметить входящие прочитанными"); return result.updated; } export async function clearInboxHistory( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<number> { const response = await fetchImpl(`${apiUrl}/api/files/inbox/history`, { headers: authHeaders(accessToken), method: "DELETE", }); const result = await jsonOrError<{ updated: number }>(response, "Не удалось очистить историю входящих"); return result.updated; } export async function clearSentHistory( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<number> { const response = await fetchImpl(`${apiUrl}/api/files/sent/history`, { headers: authHeaders(accessToken), method: "DELETE", }); const result = await jsonOrError<{ updated: number }>(response, "Не удалось очистить историю отправленных"); return result.updated; } export async function uploadEncryptedFile( apiUrl: string, accessToken: string, request: CreateFileRequest, ciphertext: Uint8Array, fetchImpl: typeof fetch = fetch, ): Promise<FileRecord> { const created = await createFileMetadata(apiUrl, accessToken, request, fetchImpl); await uploadEncryptedBlob(created.upload, ciphertext, request.mimeType, fetchImpl); return updateFileStatus(apiUrl, accessToken, created.file.id, "available", fetchImpl); } export async function listInbox( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<FileRecord[]> { const response = await fetchImpl(`${apiUrl}/api/files/inbox`, { headers: authHeaders(accessToken), }); return jsonOrError<FileRecord[]>(response, "Не удалось получить входящие файлы"); } export async function listSent( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<FileRecord[]> { const response = await fetchImpl(`${apiUrl}/api/files/sent`, { headers: authHeaders(accessToken), }); return jsonOrError<FileRecord[]>(response, "Не удалось получить отправленные файлы"); } export async function getFileDetails( apiUrl: string, accessToken: string, fileId: string, fetchImpl: typeof fetch = fetch, ): Promise<FileDetails> { const response = await fetchImpl(`${apiUrl}/api/files/${encodeURIComponent(fileId)}`, { headers: authHeaders(accessToken), }); return jsonOrError<FileDetails>(response, "Не удалось получить файл"); } export async function downloadEncryptedBlob( download: ObjectDescriptor, fetchImpl: typeof fetch = fetch, ): Promise<Uint8Array> { const response = await fetchImpl(download.url, { method: download.method, }); if (!response.ok) { throw new Error(`Не удалось скачать encrypted blob: ${response.status}`); } return new Uint8Array(await response.arrayBuffer()); } if (import.meta.rstest) { const { describe, expect, it } = import.meta.rstest; const fileRequest = { aesGcmIvB64: "0123456789ab", ciphertextSha256B64: "0123456789abcdef0123456789abcdef", fileName: "report.txt", mimeType: "text/plain", sizeBytes: 5, wrappedKeys: [ { alg: "RSA-OAEP-256", recipientIdentityId: "bob", recipientKeyVersion: 1, wrappedKeyB64: "0123456789abcdef", }, ], } satisfies CreateFileRequest; describe("file API client", () => { it("uploads metadata, encrypted blob and marks file available", async () => { const calls: Array<{ body?: BodyInit | null; method?: string; url: string }> = []; const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ body: init?.body, method: init?.method, url: String(url) }); if (String(url).endsWith("/api/files")) { expect((init?.headers as Record<string, string>).authorization).toBe("Bearer token-1"); expect(JSON.parse(init?.body as string)).toEqual(fileRequest); return new Response( JSON.stringify({ file: { ...fileRequest, createdAt: "2026-05-21T00:00:00.000Z", id: "file-1", objectKey: "files/file-1.bin", senderIdentityId: "alice", status: "pending-upload", }, upload: { method: "PUT", objectKey: "files/file-1.bin", url: "http://minio/upload" }, }), { headers: { "content-type": "application/json" }, status: 201 }, ); } if (String(url) === "http://minio/upload") { expect(init?.method).toBe("PUT"); expect(init?.body).toBeInstanceOf(ArrayBuffer); return new Response(null, { status: 200 }); } if (String(url).endsWith("/api/files/file-1/status")) { expect(JSON.parse(init?.body as string)).toEqual({ status: "available" }); return new Response( JSON.stringify({ ...fileRequest, createdAt: "2026-05-21T00:00:00.000Z", id: "file-1", objectKey: "files/file-1.bin", senderIdentityId: "alice", status: "available", }), { headers: { "content-type": "application/json" } }, ); } return new Response(null, { status: 404 }); }) as typeof fetch; const result = await uploadEncryptedFile( "http://api.local", "token-1", fileRequest, new Uint8Array([1, 2, 3]), fetchImpl, ); expect(result.status).toBe("available"); expect(calls.map((call) => call.method)).toEqual(["POST", "PUT", "PATCH"]); }); it("maps 401 list responses to auth errors", async () => { const fetchImpl = (async () => new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 })) as typeof fetch; await expect(listInbox("http://api.local", "bad-token", fetchImpl)).rejects.toBeInstanceOf(ApiAuthError); }); it("updates recipient read state, marks inbox read and clears history", async () => { const calls: Array<{ body?: BodyInit | null; method?: string; url: string }> = []; const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ body: init?.body, method: init?.method, url: String(url) }); if (String(url).endsWith("/api/files/file-1/read-state")) { expect((init?.headers as Record<string, string>).authorization).toBe("Bearer token-1"); expect(JSON.parse(init?.body as string)).toEqual({ read: true, signatureStatus: "verified" }); return new Response( JSON.stringify({ ...fileRequest, createdAt: "2026-05-21T00:00:00.000Z", id: "file-1", objectKey: "files/file-1.bin", senderIdentityId: "alice", status: "available", wrappedKeys: [{ ...fileRequest.wrappedKeys[0], readAt: "2026-05-21T00:00:00.000Z" }], }), { headers: { "content-type": "application/json" } }, ); } if (String(url).endsWith("/api/files/inbox/read-all")) { expect(init?.method).toBe("PATCH"); return new Response(JSON.stringify({ updated: 2 }), { headers: { "content-type": "application/json" }, }); } if (String(url).endsWith("/api/files/inbox/history") || String(url).endsWith("/api/files/sent/history")) { expect(init?.method).toBe("DELETE"); return new Response(JSON.stringify({ updated: 1 }), { headers: { "content-type": "application/json" }, }); } return new Response(null, { status: 404 }); }) as typeof fetch; const updated = await updateFileReadState( "http://api.local", "token-1", "file-1", { read: true, signatureStatus: "verified" }, fetchImpl, ); const count = await markInboxRead("http://api.local", "token-1", fetchImpl); const clearedInbox = await clearInboxHistory("http://api.local", "token-1", fetchImpl); const clearedSent = await clearSentHistory("http://api.local", "token-1", fetchImpl); expect(updated.wrappedKeys[0].readAt).toBeTruthy(); expect(count).toBe(2); expect(clearedInbox).toBe(1); expect(clearedSent).toBe(1); expect(calls.map((call) => call.method)).toEqual(["PATCH", "PATCH", "DELETE", "DELETE"]); }); it("selects active public keys", async () => { const keys = await getPublicKeysByEmail( "http://api.local", "bob@example.com", (async () => new Response( JSON.stringify({ email: "bob@example.com", identityId: "bob", publicKeys: [ { createdAt: "2026-05-21T00:00:00.000Z", encryptionPublicKeyJwk: { kty: "RSA" }, status: "retired", version: 1, }, { createdAt: "2026-05-21T00:00:00.000Z", encryptionPublicKeyJwk: { kty: "RSA" }, status: "active", version: 2, }, ], }), { headers: { "content-type": "application/json" } }, )) as typeof fetch, ); expect(keys.identityId).toBe("bob"); expect(keys.email).toBe("bob@example.com"); expect(selectActivePublicKey(keys.publicKeys).version).toBe(2); }); it("maps missing email lookup to a neutral recipient error", async () => { const fetchImpl = (async () => new Response(JSON.stringify({ error: "public_keys_not_found" }), { status: 404 })) as typeof fetch; await expect(getPublicKeysByEmail("http://api.local", "missing@example.com", fetchImpl)).rejects.toThrow( "Получатель не найден или еще не настроил ключи.", ); }); }); }