/
nasya
/
SafeDrop
Обзор
Документация
Войти
/
nasya
/
SafeDrop
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/web/src/lib/api/share-api.ts
395 строк
13 KB
nasya
Initial commit
24 май 2026, 16:12
24 май 2026, 16:12
16bc032
Код
Авторство
О чём код?
import type { CreateRecipientShareRequest, CreateSecretShareRequest, ShareMode } from "@safedrop/shared"; import { downloadEncryptedBlob, type ObjectDescriptor, uploadEncryptedBlob } from "./file-api"; export type ShareRecord = CreateSecretShareRequest & { createdAt: string; createdByIdentityId: string | null; id: string; mode: ShareMode; objectKey: string; recipientIdentityId?: string; recipientKeyVersion?: number; revokedAt: string | null; token: string; viewCount: number; wrappedKeyB64?: string; }; export type CreatedSecretShare = { publicUrl: string; share: ShareRecord; upload: ObjectDescriptor; }; export type CreatedRecipientShare = { share: ShareRecord; upload: ObjectDescriptor; }; export type PublicShareDetails = { download: ObjectDescriptor; share: ShareRecord; }; async function jsonOrError<T>(response: Response, fallbackMessage: string): Promise<T> { if (!response.ok) { throw new Error(`${fallbackMessage}: ${response.status}`); } return (await response.json()) as T; } function authHeaders(accessToken?: string | null): Record<string, string> { return accessToken ? { authorization: `Bearer ${accessToken}` } : {}; } export function buildSecretShareUrl(origin: string, token: string, fragmentKey: string): string { return `${origin}/share/${encodeURIComponent(token)}#key=${encodeURIComponent(fragmentKey)}`; } export function buildRecipientShareUrl(origin: string, token: string): string { return `${origin}/share/${encodeURIComponent(token)}`; } export function parseShareToken(pathname: string): string | null { const match = /^\/share\/([^/]+)$/u.exec(pathname); return match ? decodeURIComponent(match[1]) : null; } export function readFragmentKey(hash: string): string | null { const value = hash.startsWith("#") ? hash.slice(1) : hash; const params = new URLSearchParams(value); return params.get("key"); } export async function createSecretShareMetadata( apiUrl: string, request: CreateSecretShareRequest, accessToken?: string | null, fetchImpl: typeof fetch = fetch, ): Promise<CreatedSecretShare> { const response = await fetchImpl(`${apiUrl}/api/shares/secret`, { body: JSON.stringify(request), headers: { ...authHeaders(accessToken), "content-type": "application/json" }, method: "POST", }); return jsonOrError<CreatedSecretShare>(response, "Не удалось создать ссылку"); } export async function createSecretShare( apiUrl: string, request: CreateSecretShareRequest, ciphertext: Uint8Array, accessToken?: string | null, fetchImpl: typeof fetch = fetch, ): Promise<CreatedSecretShare> { const created = await createSecretShareMetadata(apiUrl, request, accessToken, fetchImpl); await uploadEncryptedBlob(created.upload, ciphertext, request.mimeType, fetchImpl); return created; } export async function createRecipientShareMetadata( apiUrl: string, request: CreateRecipientShareRequest, accessToken?: string | null, fetchImpl: typeof fetch = fetch, ): Promise<CreatedRecipientShare> { const response = await fetchImpl(`${apiUrl}/api/shares/recipient`, { body: JSON.stringify(request), headers: { ...authHeaders(accessToken), "content-type": "application/json" }, method: "POST", }); return jsonOrError<CreatedRecipientShare>(response, "Не удалось создать ссылку получателю"); } export async function createRecipientShare( apiUrl: string, request: CreateRecipientShareRequest, ciphertext: Uint8Array, accessToken?: string | null, fetchImpl: typeof fetch = fetch, ): Promise<CreatedRecipientShare> { const created = await createRecipientShareMetadata(apiUrl, request, accessToken, fetchImpl); await uploadEncryptedBlob(created.upload, ciphertext, request.mimeType, fetchImpl); return created; } export async function listActiveShares( apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch, ): Promise<ShareRecord[]> { const response = await fetchImpl(`${apiUrl}/api/shares/active`, { headers: authHeaders(accessToken), }); return jsonOrError<ShareRecord[]>(response, "Не удалось получить активные ссылки"); } export async function revokeShare( apiUrl: string, accessToken: string, shareId: string, fetchImpl: typeof fetch = fetch, ): Promise<void> { const response = await fetchImpl(`${apiUrl}/api/shares/active/${encodeURIComponent(shareId)}`, { headers: authHeaders(accessToken), method: "DELETE", }); if (!response.ok) { throw new Error(`Не удалось отозвать ссылку: ${response.status}`); } } export async function getPublicShare( apiUrl: string, token: string, fetchImpl: typeof fetch = fetch, ): Promise<PublicShareDetails> { const response = await fetchImpl(`${apiUrl}/api/shares/public/${encodeURIComponent(token)}`); return jsonOrError<PublicShareDetails>(response, "Не удалось открыть ссылку"); } export async function recordPublicShareAccess( apiUrl: string, token: string, fetchImpl: typeof fetch = fetch, ): Promise<ShareRecord> { const response = await fetchImpl(`${apiUrl}/api/shares/public/${encodeURIComponent(token)}/access`, { method: "POST", }); const result = await jsonOrError<{ share: ShareRecord }>(response, "Не удалось обновить счетчик просмотров"); return result.share; } export async function downloadPublicShareBlob( details: PublicShareDetails, fetchImpl: typeof fetch = fetch, ): Promise<Uint8Array> { return downloadEncryptedBlob(details.download, fetchImpl); } if (import.meta.rstest) { const { describe, expect, it } = import.meta.rstest; const shareRequest = { aesGcmIvB64: "0123456789ab", ciphertextSha256B64: "0123456789abcdef0123456789abcdef", fileName: "secret.txt", mimeType: "text/plain", sizeBytes: 5, } satisfies CreateSecretShareRequest; const recipientShareRequest = { ...shareRequest, recipientIdentityId: "bob", recipientKeyVersion: 1, wrappedKeyB64: "wrapped-fek", } satisfies CreateRecipientShareRequest; describe("share API client", () => { it("creates secret share metadata and uploads encrypted blob without fragment key in payload", 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/shares/secret")) { expect(JSON.parse(init?.body as string)).toEqual(shareRequest); expect(init?.body as string).not.toContain("fragment-key"); return new Response( JSON.stringify({ publicUrl: "/share/token-1#key=<client-side-aes-key>", share: { ...shareRequest, createdAt: "2026-05-21T00:00:00.000Z", createdByIdentityId: null, id: "share-1", mode: "secret-link", objectKey: "shares/share-1.bin", revokedAt: null, token: "token-1", viewCount: 0, }, upload: { method: "PUT", objectKey: "shares/share-1.bin", url: "http://minio/upload" }, }), { headers: { "content-type": "application/json" }, status: 201 }, ); } if (String(url) === "http://minio/upload") { expect(init?.method).toBe("PUT"); return new Response(null, { status: 200 }); } return new Response(null, { status: 404 }); }) as typeof fetch; const created = await createSecretShare( "http://api.local", shareRequest, new Uint8Array([1, 2, 3]), "token-1", fetchImpl, ); expect(created.share.token).toBe("token-1"); expect(String(calls[0]?.body)).not.toContain("fragment-key"); expect(String(calls[0]?.body)).not.toContain("token-1"); expect(calls.map((call) => call.method)).toEqual(["POST", "PUT"]); }); it("reads public share metadata and maps not found to a readable error", async () => { const ok = await getPublicShare( "http://api.local", "token-1", (async () => new Response( JSON.stringify({ download: { method: "GET", objectKey: "shares/share-1.bin", url: "http://minio/download" }, share: { ...shareRequest, createdAt: "2026-05-21T00:00:00.000Z", createdByIdentityId: null, id: "share-1", mode: "secret-link", objectKey: "shares/share-1.bin", revokedAt: null, token: "token-1", viewCount: 1, }, }), { headers: { "content-type": "application/json" } }, )) as typeof fetch, ); const missingFetch = (async () => new Response(JSON.stringify({ error: "share_not_found" }), { status: 404 })) as typeof fetch; expect(ok.share.token).toBe("token-1"); await expect(getPublicShare("http://api.local", "expired", missingFetch)).rejects.toThrow( "Не удалось открыть ссылку: 404", ); }); it("records public share access only through explicit access endpoint", async () => { const calls: Array<{ method?: string; url: string }> = []; const share = await recordPublicShareAccess("http://api.local", "token-1", (async ( url: string | URL | Request, init?: RequestInit, ) => { calls.push({ method: init?.method, url: String(url) }); return new Response( JSON.stringify({ share: { ...shareRequest, createdAt: "2026-05-21T00:00:00.000Z", createdByIdentityId: null, id: "share-1", mode: "secret-link", objectKey: "shares/share-1.bin", revokedAt: null, token: "token-1", viewCount: 1, }, }), { headers: { "content-type": "application/json" } }, ); }) as typeof fetch); expect(share.viewCount).toBe(1); expect(calls).toEqual([{ method: "POST", url: "http://api.local/api/shares/public/token-1/access" }]); }); it("builds public URLs with token in path and key only in fragment", () => { const url = buildSecretShareUrl("http://localhost:5173", "token-1", "fragment-key"); expect(url).toBe("http://localhost:5173/share/token-1#key=fragment-key"); expect(new URL(url).pathname).toBe("/share/token-1"); expect(new URL(url).search).toBe(""); expect(readFragmentKey(new URL(url).hash)).toBe("fragment-key"); expect(parseShareToken(new URL(url).pathname)).toBe("token-1"); }); it("creates recipient share metadata and uploads encrypted blob without sender signature", 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/shares/recipient")) { const body = JSON.parse(init?.body as string) as Record<string, unknown>; expect(body).toEqual(recipientShareRequest); expect(body.senderSignature).toBeUndefined(); return new Response( JSON.stringify({ share: { ...recipientShareRequest, createdAt: "2026-05-21T00:00:00.000Z", createdByIdentityId: null, id: "share-2", mode: "recipient-public-key", objectKey: "shares/share-2.bin", revokedAt: null, token: "token-2", viewCount: 0, }, upload: { method: "PUT", objectKey: "shares/share-2.bin", url: "http://minio/recipient-upload" }, }), { headers: { "content-type": "application/json" }, status: 201 }, ); } if (String(url) === "http://minio/recipient-upload") { expect(init?.method).toBe("PUT"); return new Response(null, { status: 200 }); } return new Response(null, { status: 404 }); }) as typeof fetch; const created = await createRecipientShare( "http://api.local", recipientShareRequest, new Uint8Array([1, 2, 3]), null, fetchImpl, ); expect(created.share.mode).toBe("recipient-public-key"); expect(calls.map((call) => call.method)).toEqual(["POST", "PUT"]); }); it("builds recipient share URLs without fragment keys", () => { const url = buildRecipientShareUrl("http://localhost:5173", "token-2"); expect(url).toBe("http://localhost:5173/share/token-2"); expect(new URL(url).hash).toBe(""); expect(new URL(url).pathname).toBe("/share/token-2"); }); it("lists and revokes active links with bearer token", async () => { const calls: Array<{ method?: string; url: string }> = []; const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ method: init?.method, url: String(url) }); expect((init?.headers as Record<string, string>).authorization).toBe("Bearer access-token"); if (String(url).endsWith("/api/shares/active")) { return new Response( JSON.stringify([ { ...shareRequest, createdAt: "2026-05-21T00:00:00.000Z", createdByIdentityId: "alice", id: "share-1", mode: "secret-link", objectKey: "shares/share-1.bin", revokedAt: null, token: "", viewCount: 0, }, ]), { headers: { "content-type": "application/json" } }, ); } return new Response(null, { status: 204 }); }) as typeof fetch; const shares = await listActiveShares("http://api.local", "access-token", fetchImpl); await revokeShare("http://api.local", "access-token", "share-1", fetchImpl); expect(shares).toHaveLength(1); expect(calls.map((call) => [call.method ?? "GET", call.url])).toEqual([ ["GET", "http://api.local/api/shares/active"], ["DELETE", "http://api.local/api/shares/active/share-1"], ]); }); }); }