Merge: thumbnail helpers with tests
Some checks failed
check / check (push) Failing after 6s

quak helper list-missing-thumbnails: scans all files, fetches each
thumbnail, reports missing/empty ones with deduplication.

quak helper fix-missing-thumbnails: downloads originals, generates
720px JPEG via sharp, encrypts with secretstream push (encryptBlob),
uploads via presigned URL, registers via PUT /files/thumbnail.

New crypto: encryptBlob (secretstream push, single chunk TAG_FINAL).
New ApiClient: putJSON, putFile (no auth headers for S3), getUploadURL,
updateThumbnail. New Client: getApiClient() accessor.

20 new tests covering: encryptBlob round-trips and edge cases (8),
upload API methods including putFile auth-header leakage check (4),
listMissingThumbnails detection and dedup (2), fixMissingThumbnails
full pipeline with JPEG magic byte verification on decrypted upload (3),
getApiClient logged-in/logged-out behavior (2), error resilience (1).

110 total tests, all green.
This commit is contained in:
2026-06-09 12:29:41 -04:00
11 changed files with 1291 additions and 2 deletions

View File

@@ -9,6 +9,10 @@ import envPaths from "env-paths";
import { Client, type ClientSnapshot } from "../src/client.js"; import { Client, type ClientSnapshot } from "../src/client.js";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
import { runBackup } from "../src/backup.js"; import { runBackup } from "../src/backup.js";
import {
listMissingThumbnails,
fixMissingThumbnails,
} from "../src/thumbnails.js";
const paths = envPaths("quak", { suffix: "" }); const paths = envPaths("quak", { suffix: "" });
const sessionPath = join(paths.data, "session.json"); const sessionPath = join(paths.data, "session.json");
@@ -366,4 +370,90 @@ program
process.exit(result.failed > 0 ? 1 : 0); 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 <ids...>",
"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(); program.parse();

View File

@@ -31,6 +31,7 @@
"@eslint/js": "9.38.0", "@eslint/js": "9.38.0",
"@types/libsodium-wrappers-sumo": "0.8.2", "@types/libsodium-wrappers-sumo": "0.8.2",
"@types/node": "22.18.13", "@types/node": "22.18.13",
"@types/sharp": "0.32.0",
"eslint": "9.38.0", "eslint": "9.38.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "5.9.3", "typescript": "5.9.3",
@@ -41,6 +42,7 @@
"commander": "14.0.3", "commander": "14.0.3",
"env-paths": "4.0.0", "env-paths": "4.0.0",
"fast-srp-hap": "2.0.4", "fast-srp-hap": "2.0.4",
"libsodium-wrappers-sumo": "0.8.4" "libsodium-wrappers-sumo": "0.8.4",
"sharp": "0.34.5"
} }
} }

View File

@@ -162,6 +162,52 @@ export class ApiClient {
return resp.body; 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<void> {
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<T>(path: string, body: unknown): Promise<T> {
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<void> {
await this.putJSON("/files/thumbnail", {
fileID,
thumbnail: { objectKey, decryptionHeader },
});
}
async getThumbnailStream( async getThumbnailStream(
fileID: number, fileID: number,
): Promise<ReadableStream<Uint8Array>> { ): Promise<ReadableStream<Uint8Array>> {

View File

@@ -119,6 +119,11 @@ export class Client {
); );
} }
getApiClient(): ApiClient {
this.assertLoggedIn();
return this.api;
}
private assertLoggedIn(): void { private assertLoggedIn(): void {
if (this.loggedOut) throw new Error("Client has been logged out"); if (this.loggedOut) throw new Error("Client has been logged out");
} }

View File

@@ -9,6 +9,7 @@ export { deriveKEK, deriveLoginSubkey } from "./kdf.js";
export { decryptBox, decryptSealed } from "./box.js"; export { decryptBox, decryptSealed } from "./box.js";
export { export {
decryptBlob, decryptBlob,
encryptBlob,
initStreamPull, initStreamPull,
pullStreamChunk, pullStreamChunk,
STREAM_CHUNK_OVERHEAD, STREAM_CHUNK_OVERHEAD,

View File

@@ -8,6 +8,23 @@ export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024;
// 16 bytes of Poly1305 tag plus 1 byte of secretstream tag. // 16 bytes of Poly1305 tag plus 1 byte of secretstream tag.
export const STREAM_CHUNK_OVERHEAD = 17; 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 // Opaque handle to libsodium's secretstream pull state. Threaded through
// successive pullStreamChunk calls. // successive pullStreamChunk calls.
export type StreamPullState = sodium.StateAddress; export type StreamPullState = sodium.StateAddress;

183
src/thumbnails.ts Normal file
View File

@@ -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<MissingThumbnailInfo[]> => {
const log = onProgress ?? (() => {});
const missing: MissingThumbnailInfo[] = [];
const seen = new Set<number>();
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<Uint8Array> => {
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<ThumbnailFixResult[]> => {
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;
};

139
test/api/upload.test.ts Normal file
View File

@@ -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<string, string> = {},
): 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<Response> => {
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==");
});
});

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
filesByCollection: Record<number, Record<string, unknown>[]>;
fileCiphertexts: Record<number, Uint8Array>;
fileKeys: Record<number, Uint8Array>;
thumbnailBehavior: Record<number, "ok" | "empty" | "404">;
// Captures from fix operations
uploadedThumbnails: {
fileID: number;
objectKey: string;
decryptionHeader: string;
ciphertext: Uint8Array;
}[];
}
let mock: ThumbMockState;
const buildThumbMock = async (): Promise<ThumbMockState> => {
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<number, Uint8Array> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
const rawFiles: Record<string, unknown>[] = [];
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<Response> => {
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<string, unknown>)._lastUploadedCiphertext = body;
} else if (body instanceof ArrayBuffer) {
(m as Record<string, unknown>)._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<string, unknown>)
._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<string, unknown>,
);
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/);
});
});

207
yarn.lock
View File

@@ -2,6 +2,13 @@
# yarn lockfile v1 # 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": "@esbuild/aix-ppc64@0.21.5":
version "0.21.5" version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" 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" resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== 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": "@jridgewell/sourcemap-codec@^1.5.5":
version "1.5.5" version "1.5.5"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
@@ -403,6 +557,13 @@
dependencies: dependencies:
undici-types "~6.21.0" 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": "@typescript-eslint/eslint-plugin@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" 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" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== 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: env-paths@4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-4.0.0.tgz#d0bb1f84a81d2542581bf7b7e8085d0683b39097" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-4.0.0.tgz#d0bb1f84a81d2542581bf7b7e8085d0683b39097"
@@ -1280,11 +1446,45 @@ run-parallel@^1.1.9:
dependencies: dependencies:
queue-microtask "^1.2.2" queue-microtask "^1.2.2"
semver@^7.6.0: semver@^7.6.0, semver@^7.7.3:
version "7.8.0" version "7.8.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA== 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: shebang-command@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" 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" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1"
integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== 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: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"