diff --git a/bin/quak.ts b/bin/quak.ts index c89092e..dd3c087 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -9,6 +9,10 @@ import envPaths from "env-paths"; import { Client, type ClientSnapshot } from "../src/client.js"; import { init } from "../src/crypto/index.js"; import { runBackup } from "../src/backup.js"; +import { + listMissingThumbnails, + fixMissingThumbnails, +} from "../src/thumbnails.js"; const paths = envPaths("quak", { suffix: "" }); const sessionPath = join(paths.data, "session.json"); @@ -366,4 +370,90 @@ program process.exit(result.failed > 0 ? 1 : 0); }); +const helper = program + .command("helper") + .description("Maintenance and repair utilities"); + +helper + .command("list-missing-thumbnails") + .description("List files whose thumbnails are missing or empty") + .option("--json", "Output as JSON array") + .action(async (opts: { json?: boolean }) => { + await init(); + const client = requireSession(); + const missing = await listMissingThumbnails(client, (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }); + + if (opts.json) { + stdout.write(JSON.stringify(missing, null, 2) + "\n"); + } else { + if (missing.length === 0) { + stderr.write("No missing thumbnails found.\n"); + } else { + stderr.write( + `\n${missing.length} file(s) with missing thumbnails:\n`, + ); + for (const m of missing) { + stdout.write( + `${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`, + ); + } + } + } + }); + +helper + .command("fix-missing-thumbnails") + .description( + "Generate and upload thumbnails for files that are missing them", + ) + .option( + "--file ", + "Specific file IDs to fix (default: fix all missing)", + ) + .option("--json", "Output as JSON") + .action(async (opts: { file?: string[]; json?: boolean }) => { + await init(); + const client = requireSession(); + + let fileIDs: number[]; + if (opts.file && opts.file.length > 0) { + fileIDs = opts.file.map(Number).filter(Number.isFinite); + } else { + stderr.write("Scanning for missing thumbnails...\n"); + const missing = await listMissingThumbnails(client, (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }); + fileIDs = missing.map((m) => m.fileID); + if (fileIDs.length === 0) { + stderr.write("No missing thumbnails found.\n"); + return; + } + stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); + } + + const results = await fixMissingThumbnails(client, fileIDs, (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }); + + if (opts.json) { + stdout.write(JSON.stringify(results, null, 2) + "\n"); + } else { + const ok = results.filter((r) => r.success).length; + const fail = results.filter((r) => !r.success).length; + stderr.write(`\n--- Done ---\n`); + stderr.write(` Fixed: ${ok}\n`); + stderr.write(` Failed: ${fail}\n`); + if (fail > 0) { + stderr.write("\nFailed files:\n"); + for (const r of results.filter((r) => !r.success)) { + stderr.write(` ${r.fileID}\t${r.title}\t${r.error}\n`); + } + } + } + + process.exit(results.some((r) => !r.success) ? 1 : 0); + }); + program.parse(); diff --git a/package.json b/package.json index c967ef3..836f04d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@eslint/js": "9.38.0", "@types/libsodium-wrappers-sumo": "0.8.2", "@types/node": "22.18.13", + "@types/sharp": "0.32.0", "eslint": "9.38.0", "prettier": "3.8.1", "typescript": "5.9.3", @@ -41,6 +42,7 @@ "commander": "14.0.3", "env-paths": "4.0.0", "fast-srp-hap": "2.0.4", - "libsodium-wrappers-sumo": "0.8.4" + "libsodium-wrappers-sumo": "0.8.4", + "sharp": "0.34.5" } } diff --git a/src/api/client.ts b/src/api/client.ts index fc5e877..32b1ae7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -162,6 +162,52 @@ export class ApiClient { return resp.body; } + async getUploadURL( + contentLength: number, + contentMD5: string, + ): Promise<{ objectKey: string; url: string }> { + return this.postJSON("/files/upload-url", { + contentLength, + contentMD5, + }); + } + + async putFile(presignedURL: string, data: Uint8Array): Promise { + const resp = await this._fetch(presignedURL, { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(data.length), + }, + body: data, + }); + if (!resp.ok) { + throw new Error(`PUT to presigned URL failed: HTTP ${resp.status}`); + } + } + + async putJSON(path: string, body: unknown): Promise { + const url = `${this.apiOrigin}${path}`; + const resp = await this._fetch(url, { + method: "PUT", + headers: this.headers({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + }); + await this.throwIfError(resp); + return (await resp.json()) as T; + } + + async updateThumbnail( + fileID: number, + objectKey: string, + decryptionHeader: string, + ): Promise { + await this.putJSON("/files/thumbnail", { + fileID, + thumbnail: { objectKey, decryptionHeader }, + }); + } + async getThumbnailStream( fileID: number, ): Promise> { diff --git a/src/client.ts b/src/client.ts index 8223f87..3f145d7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -119,6 +119,11 @@ export class Client { ); } + getApiClient(): ApiClient { + this.assertLoggedIn(); + return this.api; + } + private assertLoggedIn(): void { if (this.loggedOut) throw new Error("Client has been logged out"); } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index a8c6658..ef129f7 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -9,6 +9,7 @@ export { deriveKEK, deriveLoginSubkey } from "./kdf.js"; export { decryptBox, decryptSealed } from "./box.js"; export { decryptBlob, + encryptBlob, initStreamPull, pullStreamChunk, STREAM_CHUNK_OVERHEAD, diff --git a/src/crypto/stream.ts b/src/crypto/stream.ts index f0c4757..f255c59 100644 --- a/src/crypto/stream.ts +++ b/src/crypto/stream.ts @@ -8,6 +8,23 @@ export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024; // 16 bytes of Poly1305 tag plus 1 byte of secretstream tag. export const STREAM_CHUNK_OVERHEAD = 17; +// Encrypt a small blob as a single secretstream chunk with TAG_FINAL. +// Returns the header and ciphertext. Used for encrypting thumbnails +// and metadata before upload. +export const encryptBlob = ( + plaintext: Uint8Array, + key: Uint8Array, +): { header: Uint8Array; ciphertext: Uint8Array } => { + const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); + const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push( + push.state, + plaintext, + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + return { header: push.header, ciphertext }; +}; + // Opaque handle to libsodium's secretstream pull state. Threaded through // successive pullStreamChunk calls. export type StreamPullState = sodium.StateAddress; diff --git a/src/thumbnails.ts b/src/thumbnails.ts new file mode 100644 index 0000000..efdc84d --- /dev/null +++ b/src/thumbnails.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; +import sharp from "sharp"; +import type { Client } from "./client.js"; +import { encryptBlob, toBase64 } from "./crypto/index.js"; +import { downloadFile } from "./download/index.js"; +import type { EnteFile } from "./model/types.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const THUMB_MAX_DIMENSION = 720; +const THUMB_JPEG_QUALITY = 50; + +export interface MissingThumbnailInfo { + fileID: number; + title: string; + collection: string; + reason: string; +} + +export interface ThumbnailFixResult { + fileID: number; + title: string; + collection: string; + success: boolean; + error?: string; +} + +export type ProgressCallback = (message: string) => void; + +export const listMissingThumbnails = async ( + client: Client, + onProgress?: ProgressCallback, +): Promise => { + const log = onProgress ?? (() => {}); + const missing: MissingThumbnailInfo[] = []; + const seen = new Set(); + + const collections = await client.listCollections(); + for (const col of collections) { + log(`[${col.name}] Checking thumbnails...`); + const files = await client.listFiles(col.id, col.key); + for (const file of files) { + if (seen.has(file.id)) continue; + seen.add(file.id); + try { + const api = client.getApiClient(); + const stream = await api.getThumbnailStream(file.id); + const reader = stream.getReader(); + let totalBytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (value) totalBytes += value.length; + if (done) break; + } + if (totalBytes === 0) { + missing.push({ + fileID: file.id, + title: file.metadata.title, + collection: col.name, + reason: "empty thumbnail (0 bytes)", + }); + } + } catch { + missing.push({ + fileID: file.id, + title: file.metadata.title, + collection: col.name, + reason: "thumbnail fetch failed", + }); + } + } + } + return missing; +}; + +const generateThumbnail = async (originalPath: string): Promise => { + const result = await sharp(originalPath) + .rotate() + .resize(THUMB_MAX_DIMENSION, THUMB_MAX_DIMENSION, { + fit: "inside", + withoutEnlargement: true, + }) + .jpeg({ quality: THUMB_JPEG_QUALITY }) + .toBuffer(); + return new Uint8Array(result); +}; + +export const fixMissingThumbnails = async ( + client: Client, + fileIDs: number[], + onProgress?: ProgressCallback, +): Promise => { + const log = onProgress ?? (() => {}); + const results: ThumbnailFixResult[] = []; + const api = client.getApiClient(); + + const collections = await client.listCollections(); + const fileMap = new Map< + number, + { file: EnteFile; collectionName: string } + >(); + + for (const col of collections) { + const files = await client.listFiles(col.id, col.key); + for (const file of files) { + if (fileIDs.includes(file.id) && !fileMap.has(file.id)) { + fileMap.set(file.id, { + file, + collectionName: col.name, + }); + } + } + } + + for (const fileID of fileIDs) { + const entry = fileMap.get(fileID); + if (!entry) { + results.push({ + fileID, + title: "unknown", + collection: "unknown", + success: false, + error: "file not found in any collection", + }); + continue; + } + + const { file, collectionName } = entry; + const tmpDir = mkdtempSync(join(tmpdir(), "quak-thumb-")); + + try { + log( + `[${collectionName}] Downloading ${file.metadata.title} for thumbnail generation...`, + ); + const origPath = join(tmpDir, "original"); + await downloadFile(api, file, origPath); + + log( + `[${collectionName}] Generating thumbnail for ${file.metadata.title}...`, + ); + const thumbJpeg = await generateThumbnail(origPath); + + log( + `[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`, + ); + const { header, ciphertext } = encryptBlob(thumbJpeg, file.key); + + const md5 = createHash("md5").update(ciphertext).digest("base64"); + const { objectKey, url } = await api.getUploadURL( + ciphertext.length, + md5, + ); + await api.putFile(url, ciphertext); + await api.updateThumbnail(file.id, objectKey, toBase64(header)); + + log( + `[${collectionName}] Thumbnail uploaded for ${file.metadata.title}`, + ); + results.push({ + fileID, + title: file.metadata.title, + collection: collectionName, + success: true, + }); + } catch (err) { + log( + `[${collectionName}] FAILED ${file.metadata.title}: ${err instanceof Error ? err.message : err}`, + ); + results.push({ + fileID, + title: file.metadata.title, + collection: collectionName, + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + } + + return results; +}; diff --git a/test/api/upload.test.ts b/test/api/upload.test.ts new file mode 100644 index 0000000..e9426e0 --- /dev/null +++ b/test/api/upload.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the upload-related ApiClient methods added for thumbnail + * repair: `putJSON`, `putFile`, `getUploadURL`, and `updateThumbnail`. + * + * `putFile` sends a raw PUT to a presigned S3 URL. It must NOT send + * quak's auth headers (X-Auth-Token, X-Client-Package) because the + * presigned URL carries its own S3 auth in the query string. Sending + * extra headers can cause S3 to reject the request. + * + * `putJSON` is like `postJSON` but sends PUT. Used by `updateThumbnail` + * to register the uploaded thumbnail's object key with the Ente API. + */ + +import { describe, expect, it } from "vitest"; +import { ApiClient } from "../../src/api/client.js"; + +const jsonResponse = ( + body: unknown, + status = 200, + headers: Record = {}, +): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + +const recordingFetch = ( + ...responses: Response[] +): { + fetch: typeof globalThis.fetch; + calls: { url: string; init: RequestInit | undefined }[]; +} => { + const calls: { url: string; init: RequestInit | undefined }[] = []; + let i = 0; + const fake = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + calls.push({ url, init }); + if (i >= responses.length) { + throw new Error(`recordingFetch: no response for call #${i}`); + } + return responses[i++]!; + }; + return { fetch: fake as typeof globalThis.fetch, calls }; +}; + +describe("ApiClient.putJSON", () => { + it("sends a PUT request with JSON body and auth headers", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({ ok: true })); + const client = new ApiClient({ fetch, authToken: "tok" }); + + await client.putJSON("/files/thumbnail", { + fileID: 42, + thumbnail: { objectKey: "k", decryptionHeader: "h" }, + }); + + expect(calls[0]!.init?.method).toBe("PUT"); + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("X-Auth-Token")).toBe("tok"); + expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quak"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.fileID).toBe(42); + expect(body.thumbnail.objectKey).toBe("k"); + }); +}); + +describe("ApiClient.putFile", () => { + it("PUTs raw bytes to the presigned URL without auth headers", async () => { + const { fetch, calls } = recordingFetch( + new Response(null, { status: 200 }), + ); + const client = new ApiClient({ fetch, authToken: "secret-tok" }); + const data = new Uint8Array([1, 2, 3, 4, 5]); + + await client.putFile("https://s3.example.com/presigned?sig=abc", data); + + expect(calls[0]!.url).toBe("https://s3.example.com/presigned?sig=abc"); + expect(calls[0]!.init?.method).toBe("PUT"); + const headers = new Headers(calls[0]!.init?.headers as HeadersInit); + expect(headers.get("Content-Type")).toBe("application/octet-stream"); + expect(headers.get("Content-Length")).toBe("5"); + // Must NOT leak auth headers to S3 + expect(headers.has("X-Auth-Token")).toBe(false); + expect(headers.has("X-Client-Package")).toBe(false); + }); + + it("throws on non-2xx response from presigned URL", async () => { + const { fetch } = recordingFetch( + new Response("Forbidden", { status: 403 }), + ); + const client = new ApiClient({ fetch }); + + await expect( + client.putFile("https://s3.example.com/bad", new Uint8Array(10)), + ).rejects.toThrow(/403/); + }); +}); + +describe("ApiClient.getUploadURL", () => { + it("POSTs to /files/upload-url with contentLength and contentMD5", async () => { + const { fetch, calls } = recordingFetch( + jsonResponse({ objectKey: "user/thumb123", url: "https://s3/put" }), + ); + const client = new ApiClient({ fetch, authToken: "tok" }); + + const result = await client.getUploadURL(5000, "abc123=="); + + expect(calls[0]!.url).toBe("https://api.ente.io/files/upload-url"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.contentLength).toBe(5000); + expect(body.contentMD5).toBe("abc123=="); + expect(result.objectKey).toBe("user/thumb123"); + expect(result.url).toBe("https://s3/put"); + }); +}); + +describe("ApiClient.updateThumbnail", () => { + it("PUTs to /files/thumbnail with fileID, objectKey, and decryptionHeader", async () => { + const { fetch, calls } = recordingFetch(jsonResponse({})); + const client = new ApiClient({ fetch, authToken: "tok" }); + + await client.updateThumbnail(42, "user/obj", "headerBase64=="); + + expect(calls[0]!.url).toBe("https://api.ente.io/files/thumbnail"); + expect(calls[0]!.init?.method).toBe("PUT"); + const body = JSON.parse(calls[0]!.init?.body as string); + expect(body.fileID).toBe(42); + expect(body.thumbnail.objectKey).toBe("user/obj"); + expect(body.thumbnail.decryptionHeader).toBe("headerBase64=="); + }); +}); diff --git a/test/crypto/encrypt-blob.test.ts b/test/crypto/encrypt-blob.test.ts new file mode 100644 index 0000000..86a1c1a --- /dev/null +++ b/test/crypto/encrypt-blob.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for `crypto.encryptBlob`. + * + * `encryptBlob` is the push-side counterpart to `decryptBlob`. It + * encrypts a small payload as a single secretstream chunk with + * TAG_FINAL and returns the header + ciphertext. Used for encrypting + * thumbnails before upload to the Ente server. + * + * The critical invariant is that `decryptBlob(encryptBlob(...))` is the + * identity function. If either side drifts, uploaded thumbnails become + * unreadable. + */ + +import sodium from "libsodium-wrappers-sumo"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + decryptBlob, + encryptBlob, + init, + STREAM_CHUNK_OVERHEAD, +} from "../../src/crypto/index.js"; + +describe("crypto.encryptBlob", () => { + beforeAll(async () => { + await init(); + await sodium.ready; + }); + + it("round-trips with decryptBlob for arbitrary data", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = sodium.randombytes_buf(500); + const { header, ciphertext } = encryptBlob(plaintext, key); + const recovered = decryptBlob(ciphertext, header, key); + expect(recovered).toEqual(plaintext); + }); + + it("round-trips a zero-length payload", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob(new Uint8Array(0), key); + const recovered = decryptBlob(ciphertext, header, key); + expect(recovered.length).toBe(0); + }); + + it("ciphertext is exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = sodium.randombytes_buf(1234); + const { ciphertext } = encryptBlob(plaintext, key); + expect(ciphertext.length).toBe( + plaintext.length + STREAM_CHUNK_OVERHEAD, + ); + }); + + it("header is 24 bytes (secretstream XChaCha20 header size)", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header } = encryptBlob(new Uint8Array([1, 2, 3]), key); + expect(header.length).toBe( + sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES, + ); + }); + + it("produces different ciphertext for different keys", () => { + const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = new Uint8Array([1, 2, 3, 4, 5]); + const enc1 = encryptBlob(plaintext, k1); + const enc2 = encryptBlob(plaintext, k2); + expect(enc1.ciphertext).not.toEqual(enc2.ciphertext); + }); + + it("produces different ciphertext on each call (random nonce in header)", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const plaintext = new Uint8Array([9, 9, 9]); + const a = encryptBlob(plaintext, key); + const b = encryptBlob(plaintext, key); + expect(a.header).not.toEqual(b.header); + expect(a.ciphertext).not.toEqual(b.ciphertext); + }); + + it("decryptBlob rejects ciphertext encrypted with a different key", () => { + const k1 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const k2 = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + new Uint8Array([1, 2, 3]), + k1, + ); + expect(() => decryptBlob(ciphertext, header, k2)).toThrow(); + }); + + it("decryptBlob rejects tampered ciphertext from encryptBlob", () => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + sodium.randombytes_buf(100), + key, + ); + ciphertext[ciphertext.length - 1] = + ciphertext[ciphertext.length - 1]! ^ 0x01; + expect(() => decryptBlob(ciphertext, header, key)).toThrow(); + }); +}); diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts new file mode 100644 index 0000000..ac00a87 --- /dev/null +++ b/test/thumbnails/thumbnails.test.ts @@ -0,0 +1,502 @@ +/** + * Tests for `listMissingThumbnails` and `fixMissingThumbnails`. + * + * These use a mock Ente server that serves encrypted collections, files, + * and thumbnails. The mock server has deliberate gaps: some files have + * working thumbnails, others return 404 or empty bodies. The tests + * verify that the detection and repair logic handles each case correctly. + * + * `fixMissingThumbnails` is the most complex function in quak: it + * downloads the original file, generates a JPEG thumbnail with sharp, + * encrypts it with secretstream push, gets a presigned upload URL, + * uploads to S3, and registers the new thumbnail with the API. The + * test verifies each step actually happened and the uploaded data is + * a valid encrypted blob that decrypts to a JPEG. + */ + +import sodium from "libsodium-wrappers-sumo"; +import sharp from "sharp"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + init, + toBase64, + decryptBlob, + fromBase64, + deriveKEK, + deriveLoginSubkey, +} from "../../src/crypto/index.js"; +import { SRP, SrpServer } from "fast-srp-hap"; +import { Client } from "../../src/client.js"; +import { + listMissingThumbnails, + fixMissingThumbnails, +} from "../../src/thumbnails.js"; +import type { KeyAttributes } from "../../src/auth/types.js"; + +// --------------------------------------------------------------------------- +// Mock server with controllable thumbnail behavior +// --------------------------------------------------------------------------- + +const TEST_EMAIL = "thumb@example.com"; +const TEST_PASSWORD = "thumbpass"; +const TEST_OPS = 2; +const TEST_MEM = 64 * 1024 * 1024; + +interface ThumbMockState { + verifier: Buffer; + srpAttributes: Record; + keyAttributes: KeyAttributes; + encryptedToken: string; + collections: Record[]; + filesByCollection: Record[]>; + fileCiphertexts: Record; + fileKeys: Record; + thumbnailBehavior: Record; + // Captures from fix operations + uploadedThumbnails: { + fileID: number; + objectKey: string; + decryptionHeader: string; + ciphertext: Uint8Array; + }[]; +} + +let mock: ThumbMockState; + +const buildThumbMock = async (): Promise => { + const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); + const loginSubKeyBytes = deriveLoginSubkey(kek); + + const srpUserID = "thumb-srp"; + const srpSalt = sodium.randombytes_buf(16); + const verifier = SRP.computeVerifier( + SRP.params["4096"], + Buffer.from(srpSalt), + Buffer.from(srpUserID), + Buffer.from(loginSubKeyBytes), + ); + + const masterKey = sodium.randombytes_buf(32); + const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek); + const kp = sodium.crypto_box_keypair(); + const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encSK = sodium.crypto_secretbox_easy( + kp.privateKey, + skNonce, + masterKey, + ); + const tokenBytes = sodium.randombytes_buf(32); + const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); + + const keyAttributes: KeyAttributes = { + kekSalt: toBase64(kekSalt), + encryptedKey: toBase64(encryptedKey), + keyDecryptionNonce: toBase64(keyNonce), + publicKey: toBase64(kp.publicKey), + encryptedSecretKey: toBase64(encSK), + secretKeyDecryptionNonce: toBase64(skNonce), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + }; + + // One collection with 3 files: ok thumbnail, empty thumbnail, 404 thumbnail + const collKey = sodium.crypto_secretbox_keygen(); + const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCK = sodium.crypto_secretbox_easy(collKey, ckN, masterKey); + const nameB = new TextEncoder().encode("Photos"); + const cnN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCN = sodium.crypto_secretbox_easy(nameB, cnN, collKey); + + const rawCollection = { + id: 1, + owner: { id: 42 }, + encryptedKey: toBase64(encCK), + keyDecryptionNonce: toBase64(ckN), + encryptedName: toBase64(encCN), + nameDecryptionNonce: toBase64(cnN), + type: "album", + updationTime: 1700000000000000, + }; + + // Generate a real tiny JPEG that sharp can process + const tinyJpeg = await sharp({ + create: { width: 100, height: 80, channels: 3, background: "red" }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + + const fileKeys: Record = {}; + const fileCiphertexts: Record = {}; + const rawFiles: Record[] = []; + + for (const fileID of [100, 101, 102]) { + const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + fileKeys[fileID] = fk; + const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey); + + const meta = JSON.stringify({ + title: `file-${fileID}.jpg`, + fileType: 0, + creationTime: 1700000000000000, + modificationTime: 1700000000000000, + }); + const metaPush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( + metaPush.state, + new TextEncoder().encode(meta), + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + + // Encrypt the tiny JPEG as the file body + const filePush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encFile = sodium.crypto_secretstream_xchacha20poly1305_push( + filePush.state, + new Uint8Array(tinyJpeg), + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + fileCiphertexts[fileID] = encFile; + + rawFiles.push({ + id: fileID, + collectionID: 1, + ownerID: 42, + encryptedKey: toBase64(encFK), + keyDecryptionNonce: toBase64(fkN), + metadata: { + encryptedData: toBase64(encMeta), + decryptionHeader: toBase64(metaPush.header), + }, + file: { decryptionHeader: toBase64(filePush.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: 1700000000000000, + }); + } + + return { + verifier, + srpAttributes: { + srpUserID, + srpSalt: toBase64(srpSalt), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + kekSalt: toBase64(kekSalt), + isEmailMFAEnabled: false, + }, + keyAttributes, + encryptedToken: toBase64(encToken), + collections: [rawCollection], + filesByCollection: { 1: rawFiles }, + fileCiphertexts, + fileKeys, + thumbnailBehavior: { + 100: "ok", + 101: "empty", + 102: "404", + }, + uploadedThumbnails: [], + }; +}; + +const buildThumbFetch = (m: ThumbMockState) => { + let srpServer: SrpServer; + + return (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const parsed = new URL(url); + const path = parsed.pathname; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + // SRP auth flow + if (path === "/users/srp/attributes") + return json({ attributes: m.srpAttributes }); + if (path === "/users/srp/create-session") { + const body = JSON.parse(init?.body as string); + const serverKey = await SRP.genKey(); + srpServer = new SrpServer( + SRP.params["4096"], + m.verifier, + serverKey, + ); + const B = srpServer.computeB(); + srpServer.setA(Buffer.from(body.srpA, "base64")); + return json({ sessionID: "s1", srpB: B.toString("base64") }); + } + if (path === "/users/srp/verify-session") { + const body = JSON.parse(init?.body as string); + srpServer.checkM1(Buffer.from(body.srpM1, "base64")); + return json({ + srpM2: srpServer.computeM2().toString("base64"), + id: 42, + keyAttributes: m.keyAttributes, + encryptedToken: m.encryptedToken, + }); + } + + // Collections & files + if (path === "/collections/v2") + return json({ collections: m.collections }); + if (path === "/collections/v2/diff") { + const collID = Number(parsed.searchParams.get("collectionID")); + return json({ + diff: m.filesByCollection[collID] ?? [], + hasMore: false, + }); + } + + // File download (for fix: download original to generate thumb) + if ( + url.includes("files.ente.io") || + path.startsWith("/files/download/") + ) { + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + const ct = m.fileCiphertexts[fileID]; + if (ct) return new Response(ct, { status: 200 }); + return new Response("not found", { status: 404 }); + } + + // Thumbnail download (for list: check if thumbnail exists) + if ( + url.includes("thumbnails.ente.io") || + path.startsWith("/files/preview/") + ) { + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + const behavior = m.thumbnailBehavior[fileID]; + if (behavior === "ok") { + return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { + status: 200, + }); + } + if (behavior === "empty") { + return new Response(new Uint8Array(0), { status: 200 }); + } + return new Response("not found", { status: 404 }); + } + + // Upload URL minting + if (path === "/files/upload-url") { + return json({ + objectKey: `42/thumb-${Date.now()}`, + url: "https://s3.mock.test/presigned-put", + }); + } + + // Presigned PUT (S3 upload) + if (url.startsWith("https://s3.mock.test/")) { + const body = init?.body; + // Store the uploaded bytes for later inspection + if (body instanceof Uint8Array) { + (m as Record)._lastUploadedCiphertext = body; + } else if (body instanceof ArrayBuffer) { + (m as Record)._lastUploadedCiphertext = + new Uint8Array(body); + } + return new Response(null, { status: 200 }); + } + + // Update thumbnail metadata + if (path === "/files/thumbnail" && init?.method === "PUT") { + const reqBody = JSON.parse(init?.body as string); + m.uploadedThumbnails.push({ + fileID: reqBody.fileID, + objectKey: reqBody.thumbnail.objectKey, + decryptionHeader: reqBody.thumbnail.decryptionHeader, + ciphertext: + ((m as Record) + ._lastUploadedCiphertext as Uint8Array) ?? + new Uint8Array(0), + }); + return json({}); + } + + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeAll(async () => { + await init(); + await sodium.ready; + mock = await buildThumbMock(); +}); + +describe("listMissingThumbnails", () => { + it("identifies files with empty and 404 thumbnails, ignores working ones", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + + const missing = await listMissingThumbnails(client); + + // File 100 has a working thumbnail → not reported + // File 101 has an empty thumbnail → reported + // File 102 has a 404 thumbnail → reported + expect(missing.length).toBe(2); + const ids = missing.map((m) => m.fileID).sort(); + expect(ids).toEqual([101, 102]); + + const emptyEntry = missing.find((m) => m.fileID === 101)!; + expect(emptyEntry.reason).toContain("empty"); + expect(emptyEntry.title).toBe("file-101.jpg"); + expect(emptyEntry.collection).toBe("Photos"); + + const notFoundEntry = missing.find((m) => m.fileID === 102)!; + expect(notFoundEntry.reason).toContain("fetch failed"); + }); + + it("deduplicates files seen in multiple collections", async () => { + // Add the same files to a second collection in the mock + const mockWithDupes = await buildThumbMock(); + const dupeCollection = { + ...mockWithDupes.collections[0]!, + id: 2, + }; + mockWithDupes.collections.push( + dupeCollection as Record, + ); + mockWithDupes.filesByCollection[2] = + mockWithDupes.filesByCollection[1]!; + + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mockWithDupes) }, + }); + + const missing = await listMissingThumbnails(client); + + // Should still be 2, not 4 (each file checked only once) + expect(missing.length).toBe(2); + }); +}); + +describe("fixMissingThumbnails", () => { + it("downloads original, generates thumbnail, encrypts, uploads, and registers", async () => { + const fixMock = await buildThumbMock(); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [101]); + + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(true); + expect(results[0]!.fileID).toBe(101); + expect(results[0]!.title).toBe("file-101.jpg"); + expect(results[0]!.collection).toBe("Photos"); + + // Verify the uploaded thumbnail was registered + expect(fixMock.uploadedThumbnails.length).toBe(1); + const upload = fixMock.uploadedThumbnails[0]!; + expect(upload.fileID).toBe(101); + expect(upload.objectKey).toContain("42/"); + expect(upload.decryptionHeader.length).toBeGreaterThan(0); + + // Verify the uploaded ciphertext can be decrypted back to a JPEG + const fileKey = fixMock.fileKeys[101]!; + const decrypted = decryptBlob( + upload.ciphertext, + fromBase64(upload.decryptionHeader), + fileKey, + ); + // JPEG magic bytes: FF D8 FF + expect(decrypted[0]).toBe(0xff); + expect(decrypted[1]).toBe(0xd8); + expect(decrypted[2]).toBe(0xff); + // Verify sharp produced a reasonably sized thumbnail + expect(decrypted.length).toBeGreaterThan(100); + expect(decrypted.length).toBeLessThan(50000); + }); + + it("reports failure for nonexistent file IDs without crashing", async () => { + const fixMock = await buildThumbMock(); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [999]); + + expect(results.length).toBe(1); + expect(results[0]!.success).toBe(false); + expect(results[0]!.fileID).toBe(999); + expect(results[0]!.error).toContain("not found"); + }); + + it("continues after one file fails and reports mixed results", async () => { + const fixMock = await buildThumbMock(); + // Make file 102 fail by removing its ciphertext so download fails + delete fixMock.fileCiphertexts[102]; + + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(fixMock) }, + }); + + const results = await fixMissingThumbnails(client, [101, 102]); + + expect(results.length).toBe(2); + const success = results.find((r) => r.fileID === 101)!; + const failure = results.find((r) => r.fileID === 102)!; + expect(success.success).toBe(true); + expect(failure.success).toBe(false); + }); +}); + +describe("Client.getApiClient", () => { + it("returns the ApiClient when logged in", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + + const api = client.getApiClient(); + expect(api).toBeDefined(); + expect(typeof api.getJSON).toBe("function"); + }); + + it("throws after logout", async () => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildThumbFetch(mock) }, + }); + client.logout(); + + expect(() => client.getApiClient()).toThrow(/logged out/); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6398ef0..f16f660 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@emnapi/runtime@^1.7.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + "@esbuild/aix-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" @@ -223,6 +230,153 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@img/colour@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.1.0.tgz#b0c2c2fa661adf75effd6b4964497cd80010bb9d" + integrity sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ== + +"@img/sharp-darwin-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz#6e0732dcade126b6670af7aa17060b926835ea86" + integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.2.4" + +"@img/sharp-darwin-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" + integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.2.4" + +"@img/sharp-libvips-darwin-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz#2894c0cb87d42276c3889942e8e2db517a492c43" + integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== + +"@img/sharp-libvips-darwin-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" + integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== + +"@img/sharp-libvips-linux-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" + integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== + +"@img/sharp-libvips-linux-arm@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" + integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== + +"@img/sharp-libvips-linux-ppc64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" + integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== + +"@img/sharp-libvips-linux-riscv64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" + integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== + +"@img/sharp-libvips-linux-s390x@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" + integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== + +"@img/sharp-libvips-linux-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" + integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== + +"@img/sharp-libvips-linuxmusl-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" + integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== + +"@img/sharp-libvips-linuxmusl-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" + integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== + +"@img/sharp-linux-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" + integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.2.4" + +"@img/sharp-linux-arm@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" + integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.2.4" + +"@img/sharp-linux-ppc64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" + integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== + optionalDependencies: + "@img/sharp-libvips-linux-ppc64" "1.2.4" + +"@img/sharp-linux-riscv64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" + integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== + optionalDependencies: + "@img/sharp-libvips-linux-riscv64" "1.2.4" + +"@img/sharp-linux-s390x@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" + integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.2.4" + +"@img/sharp-linux-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" + integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.2.4" + +"@img/sharp-linuxmusl-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" + integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + +"@img/sharp-linuxmusl-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" + integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + +"@img/sharp-wasm32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" + integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== + dependencies: + "@emnapi/runtime" "^1.7.0" + +"@img/sharp-win32-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" + integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== + +"@img/sharp-win32-ia32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" + integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== + +"@img/sharp-win32-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" + integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== + "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" @@ -403,6 +557,13 @@ dependencies: undici-types "~6.21.0" +"@types/sharp@^0.32.0": + version "0.32.0" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.32.0.tgz#fc3ac6df6b456319bae807c3d24efdc6631cdd6f" + integrity sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw== + dependencies: + sharp "*" + "@typescript-eslint/eslint-plugin@8.46.2": version "8.46.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" @@ -716,6 +877,11 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +detect-libc@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + env-paths@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-4.0.0.tgz#d0bb1f84a81d2542581bf7b7e8085d0683b39097" @@ -1280,11 +1446,45 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -semver@^7.6.0: +semver@^7.6.0, semver@^7.7.3: version "7.8.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df" integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA== +sharp@*, sharp@^0.34.5: + version "0.34.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0" + integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg== + dependencies: + "@img/colour" "^1.0.0" + detect-libc "^2.1.2" + semver "^7.7.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.34.5" + "@img/sharp-darwin-x64" "0.34.5" + "@img/sharp-libvips-darwin-arm64" "1.2.4" + "@img/sharp-libvips-darwin-x64" "1.2.4" + "@img/sharp-libvips-linux-arm" "1.2.4" + "@img/sharp-libvips-linux-arm64" "1.2.4" + "@img/sharp-libvips-linux-ppc64" "1.2.4" + "@img/sharp-libvips-linux-riscv64" "1.2.4" + "@img/sharp-libvips-linux-s390x" "1.2.4" + "@img/sharp-libvips-linux-x64" "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + "@img/sharp-linux-arm" "0.34.5" + "@img/sharp-linux-arm64" "0.34.5" + "@img/sharp-linux-ppc64" "0.34.5" + "@img/sharp-linux-riscv64" "0.34.5" + "@img/sharp-linux-s390x" "0.34.5" + "@img/sharp-linux-x64" "0.34.5" + "@img/sharp-linuxmusl-arm64" "0.34.5" + "@img/sharp-linuxmusl-x64" "0.34.5" + "@img/sharp-wasm32" "0.34.5" + "@img/sharp-win32-arm64" "0.34.5" + "@img/sharp-win32-ia32" "0.34.5" + "@img/sharp-win32-x64" "0.34.5" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -1366,6 +1566,11 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"