Compare commits
10 Commits
ebd247696b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6171d275e9 | |||
| 25d3c612cf | |||
| 5e6069f574 | |||
| 21a1a78f07 | |||
| 8cd57f4d12 | |||
| c8e7971445 | |||
| 73bfec5a9e | |||
| f3958e911d | |||
| 6729e8bdc3 | |||
| 16ea7b1f03 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ coverage/
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
|
# Compiled binary (built by make build-bin)
|
||||||
|
bin/quak
|
||||||
|
|
||||||
# quak runtime data (in case anyone runs the CLI from inside the repo)
|
# quak runtime data (in case anyone runs the CLI from inside the repo)
|
||||||
.quak/
|
.quak/
|
||||||
|
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: test lint fmt fmt-check check build dev clean docker hooks
|
.PHONY: test lint fmt fmt-check check build build-bin dev clean docker hooks
|
||||||
|
|
||||||
# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped.
|
# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped.
|
||||||
# On macOS without coreutils this is empty and the cap is skipped.
|
# On macOS without coreutils this is empty and the cap is skipped.
|
||||||
@@ -26,6 +26,9 @@ check: test lint fmt-check
|
|||||||
build:
|
build:
|
||||||
@$(YARN) tsc
|
@$(YARN) tsc
|
||||||
|
|
||||||
|
build-bin:
|
||||||
|
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak"
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@$(YARN) tsc --watch
|
@$(YARN) tsc --watch
|
||||||
|
|
||||||
|
|||||||
665
README.md
665
README.md
@@ -5,6 +5,15 @@ quak is a WTFPL-licensed TypeScript client library and CLI by
|
|||||||
encrypted photo hosting service. It logs in, enumerates collections and files,
|
encrypted photo hosting service. It logs in, enumerates collections and files,
|
||||||
and downloads individual images while decrypting them on the way to disk.
|
and downloads individual images while decrypting them on the way to disk.
|
||||||
|
|
||||||
|
quak also includes a resilient backup command that downloads every file in the
|
||||||
|
account into a deduplicated local directory tree, skipping files that already
|
||||||
|
exist on disk and continuing past individual download failures instead of
|
||||||
|
crashing. It decrypts and persists all three metadata layers (basic, private
|
||||||
|
magic, public magic) per file, including camera info, GPS coordinates, captions,
|
||||||
|
and any face/keyword labels the Ente clients have added. A helper subcommand can
|
||||||
|
detect and regenerate missing thumbnails, encrypting and uploading them back to
|
||||||
|
the server.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -14,7 +23,6 @@ yarn install
|
|||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
# Log in (prompts for email, password, and OTP/TOTP if required).
|
# Log in (prompts for email, password, and OTP/TOTP if required).
|
||||||
# Stores an encrypted session under $XDG_CONFIG_HOME/quak/.
|
|
||||||
yarn quak login
|
yarn quak login
|
||||||
|
|
||||||
# List the user's collections (albums).
|
# List the user's collections (albums).
|
||||||
@@ -23,8 +31,11 @@ yarn quak collections
|
|||||||
# List files in a collection.
|
# List files in a collection.
|
||||||
yarn quak files --collection 12345
|
yarn quak files --collection 12345
|
||||||
|
|
||||||
# Download and decrypt a single file to ./out/.
|
# Download and decrypt a single file.
|
||||||
yarn quak get 67890 --out ./out/
|
yarn quak get 67890 --out ./photo.jpg
|
||||||
|
|
||||||
|
# Back up every file in the account.
|
||||||
|
yarn quak backup ./my-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
For library use:
|
For library use:
|
||||||
@@ -32,12 +43,27 @@ For library use:
|
|||||||
```ts
|
```ts
|
||||||
import { Client } from "quak";
|
import { Client } from "quak";
|
||||||
|
|
||||||
const client = await Client.fromSavedSession();
|
const client = await Client.login({
|
||||||
|
email: "you@example.com",
|
||||||
|
password: "your-password",
|
||||||
|
});
|
||||||
|
|
||||||
for (const c of await client.listCollections()) {
|
for (const c of await client.listCollections()) {
|
||||||
console.log(c.id, c.name);
|
console.log(c.id, c.name);
|
||||||
|
const files = await client.listFiles(c.id, c.key);
|
||||||
|
for (const f of files) {
|
||||||
|
console.log(` ${f.metadata.title} [${f.metadata.fileType}]`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const file = await client.getFile(67890);
|
|
||||||
await client.downloadFile(file, "./out/");
|
// Download a file
|
||||||
|
const files = await client.listFiles(collectionID, collectionKey);
|
||||||
|
await client.downloadFile(files[0], "./photo.jpg");
|
||||||
|
|
||||||
|
// Serialize session for later (consumer handles persistence)
|
||||||
|
const snapshot = client.toJSON();
|
||||||
|
// ... later:
|
||||||
|
const restored = Client.fromJSON(snapshot);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
@@ -48,11 +74,13 @@ CLI) work, but they are slow, buggy, and difficult to script against. The
|
|||||||
Flutter app fails to sync reliably. The web app is heavy. The desktop app is the
|
Flutter app fails to sync reliably. The web app is heavy. The desktop app is the
|
||||||
web app inside a slow Electron wrapper. The Go CLI is the closest thing to a
|
web app inside a slow Electron wrapper. The Go CLI is the closest thing to a
|
||||||
usable tool, but it is awkward to integrate from anything that is not a shell.
|
usable tool, but it is awkward to integrate from anything that is not a shell.
|
||||||
|
The Go CLI's backup mode crashes entirely when a single file download fails,
|
||||||
|
which makes it useless as an actual backup tool.
|
||||||
|
|
||||||
quak is the first step in fixing that. This repo ships a small, correct,
|
quak fixes these problems. This repo ships a correct, well-tested implementation
|
||||||
well-tested implementation of Ente's cryptographic protocol and its read-only
|
of Ente's cryptographic protocol and API surface, plus a CLI that proves the
|
||||||
API surface, plus a CLI that proves the library is enough to do real work
|
library is enough to do real work without a UI. The backup command is resilient
|
||||||
without a UI.
|
by design: per-file errors are logged and the run continues.
|
||||||
|
|
||||||
The longer-term goal of this project is a simple desktop client for Ente, built
|
The longer-term goal of this project is a simple desktop client for Ente, built
|
||||||
on this library in Electron (or a comparable runtime), with two priorities above
|
on this library in Electron (or a comparable runtime), with two priorities above
|
||||||
@@ -60,11 +88,6 @@ everything else: correctness and stability. Performance and simplicity follow
|
|||||||
from those. Features will be added only after the protocol layer is correct, the
|
from those. Features will be added only after the protocol layer is correct, the
|
||||||
local cache is reliable, and the UI is responsive on a five-year-old laptop.
|
local cache is reliable, and the UI is responsive on a five-year-old laptop.
|
||||||
|
|
||||||
This first release is deliberately scoped to read operations: log in, walk the
|
|
||||||
account, decrypt and save files. Upload, sharing, deletion, and bidirectional
|
|
||||||
sync are out of scope. Adding them later is straightforward; doing them right
|
|
||||||
requires the protocol layer to be correct first.
|
|
||||||
|
|
||||||
## Development workflow
|
## Development workflow
|
||||||
|
|
||||||
All work on quak is test-driven. No exceptions.
|
All work on quak is test-driven. No exceptions.
|
||||||
@@ -107,14 +130,16 @@ the CLI is for humans.
|
|||||||
quak/
|
quak/
|
||||||
src/
|
src/
|
||||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
||||||
api/ HTTP client + typed endpoint wrappers
|
api/ HTTP client (ApiClient class)
|
||||||
auth/ login flow (SRP + email OTP + TOTP), key unwrap
|
auth/ login flow (SRP + email OTP + TOTP), key unwrap
|
||||||
model/ decrypted Collection, File, Metadata types
|
model/ decrypted Collection, File, Metadata types + decrypt fns
|
||||||
session/ on-disk session persistence (token + master key)
|
download/ streaming file/thumbnail download + decryption
|
||||||
|
backup.ts resilient full-account backup with dedup
|
||||||
|
thumbnails.ts detect + regenerate missing thumbnails
|
||||||
client.ts high-level Client class assembled from the above
|
client.ts high-level Client class assembled from the above
|
||||||
index.ts public library exports
|
index.ts public library exports
|
||||||
bin/
|
bin/
|
||||||
quak.ts CLI entrypoint (commander.js)
|
quak.ts CLI entrypoint (commander.js)
|
||||||
test/ unit + integration tests (vitest)
|
test/ unit + integration tests (vitest)
|
||||||
Makefile
|
Makefile
|
||||||
Dockerfile
|
Dockerfile
|
||||||
@@ -150,9 +175,13 @@ The key hierarchy, derived during login, is:
|
|||||||
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
||||||
master key (for owned collections). Per-file keys are decrypted with
|
master key (for owned collections). Per-file keys are decrypted with
|
||||||
`crypto_secretbox_open_easy` using the collection key. File metadata is a
|
`crypto_secretbox_open_easy` using the collection key. File metadata is a
|
||||||
secretbox under the file key. File content is a chunked
|
secretstream blob (single chunk, TAG_FINAL) under the file key. File content is
|
||||||
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a 4 MiB
|
a chunked `crypto_secretstream_xchacha20poly1305` stream under the file key,
|
||||||
plaintext chunk size and a 17-byte authentication overhead per chunk.
|
with a 4 MiB plaintext chunk size and a 17-byte authentication overhead per
|
||||||
|
chunk. Thumbnails use the same secretstream blob format as metadata.
|
||||||
|
|
||||||
|
For upload (thumbnail repair), `encryptBlob` performs the push side: a single
|
||||||
|
secretstream chunk with TAG_FINAL, returning the header and ciphertext.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
@@ -163,8 +192,8 @@ Production endpoints:
|
|||||||
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
|
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
|
||||||
|
|
||||||
A custom API endpoint is configurable for self-hosted servers via the
|
A custom API endpoint is configurable for self-hosted servers via the
|
||||||
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route through
|
constructor option `apiOrigin`. When set, file downloads route through
|
||||||
`<endpoint>/files/download/<id>` instead of the dedicated CDN host.
|
`<apiOrigin>/files/download/<id>` instead of the dedicated CDN host.
|
||||||
|
|
||||||
Required request headers on every authenticated call:
|
Required request headers on every authenticated call:
|
||||||
|
|
||||||
@@ -184,514 +213,100 @@ Endpoints used:
|
|||||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
||||||
collection; paginate while `hasMore` is true.
|
collection; paginate while `hasMore` is true.
|
||||||
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
|
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
|
||||||
|
- `POST /files/upload-url`: mint a presigned upload URL (for thumbnail repair).
|
||||||
|
- `PUT /files/thumbnail`: register an uploaded thumbnail's object key.
|
||||||
|
|
||||||
### Session persistence
|
### Session handling
|
||||||
|
|
||||||
After login, quak writes an encrypted session blob to
|
The `Client` class holds the auth token, master key, secret key, and public key
|
||||||
`$XDG_CONFIG_HOME/quak/session.json` (default `~/.config/quak/session.json`)
|
in memory. There is no on-disk session store in the library; the consumer
|
||||||
containing the auth token, the user's master key, the user's secret key, and the
|
decides how to persist sessions.
|
||||||
user's email. The session file is itself encrypted with a key derived from a
|
|
||||||
per-machine random value stored in the OS keychain when available, falling back
|
`client.toJSON()` returns a `ClientSnapshot` (a plain serializable object with
|
||||||
to a key file at mode `0600` in the same config directory. The master key and
|
base64-encoded keys) that the consumer can write to disk, a database, or
|
||||||
secret key are never written to disk in cleartext.
|
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
|
||||||
|
working client from that snapshot without re-authenticating.
|
||||||
|
|
||||||
|
The CLI stores the snapshot at the platform-appropriate data directory via
|
||||||
|
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
||||||
|
`$XDG_DATA_HOME/quak/session.json` on Linux. The file is written with mode
|
||||||
|
`0600`. The key material is stored in cleartext in the JSON; treat this file as
|
||||||
|
you would treat the password itself.
|
||||||
|
|
||||||
### CLI surface
|
### CLI surface
|
||||||
|
|
||||||
- `quak login`: interactive login, writes session.
|
```
|
||||||
- `quak logout`: deletes the session.
|
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
||||||
- `quak whoami`: prints the logged-in email.
|
quak whoami print logged-in account as JSON
|
||||||
- `quak collections`: list collections (id, name, type, file count).
|
quak logout delete saved session
|
||||||
- `quak files --collection <id>`: list files in a collection (id, name, type,
|
quak collections [--json] list all collections
|
||||||
creation time, size).
|
quak files --collection <id> [--json] list files in a collection
|
||||||
- `quak get <fileID> --out <dir>`: download and decrypt a file.
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
- `quak get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
|
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
||||||
|
quak backup <dir> [--json] full incremental backup
|
||||||
All commands accept `--json` for machine-readable output.
|
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
|
||||||
|
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
|
||||||
## API reference
|
|
||||||
|
|
||||||
The complete public surface of the library, expressed as TypeScript
|
|
||||||
declarations. Every exported name is listed here. Anything not listed is
|
|
||||||
internal.
|
|
||||||
|
|
||||||
### Type aliases
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/model/types.ts
|
|
||||||
export type Bytes = Uint8Array;
|
|
||||||
export type Base64 = string; // standard base64 unless noted
|
|
||||||
export type Base64URL = string; // URL-safe base64
|
|
||||||
export type Microseconds = number; // unix epoch microseconds (int64-ish)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Crypto module
|
`get` and `get-thumb` search all collections for the file ID when `--collection`
|
||||||
|
is not specified. All listing and backup commands support `--json` for
|
||||||
|
machine-readable output.
|
||||||
|
|
||||||
```ts
|
### Backup layout
|
||||||
// src/crypto/index.ts
|
|
||||||
|
|
||||||
// Lazily initializes libsodium. Safe to call repeatedly; the first call
|
`quak backup <dir>` produces:
|
||||||
// performs the init, subsequent calls are no-ops.
|
|
||||||
export function init(): Promise<void>;
|
|
||||||
|
|
||||||
// Argon2id over a UTF-8 password and a 16-byte salt, producing a 32-byte
|
```
|
||||||
// key. memLimit is in bytes, opsLimit is the iteration count, both as
|
<dir>/
|
||||||
// returned by the server in SRP / key attributes.
|
originals/
|
||||||
export function deriveKEK(
|
<fileID>.<ext> actual file content (one per unique file)
|
||||||
password: string,
|
<fileID>.json all decrypted metadata for that file
|
||||||
salt: Bytes,
|
collections/
|
||||||
opsLimit: number,
|
<name>/
|
||||||
memLimit: number,
|
<title> -> ../../originals/<fileID>.<ext> (symlink)
|
||||||
): Promise<Bytes>;
|
<name>.json collection metadata + file list
|
||||||
|
|
||||||
// crypto_kdf_derive_from_key with subkey id 1 and context "loginctx",
|
|
||||||
// returning the first 16 bytes. Used as the SRP password.
|
|
||||||
export function deriveLoginSubkey(kek: Bytes): Bytes;
|
|
||||||
|
|
||||||
// crypto_secretbox_open_easy. Returns plaintext or throws on auth failure.
|
|
||||||
export function decryptBox(ciphertext: Bytes, nonce: Bytes, key: Bytes): Bytes;
|
|
||||||
|
|
||||||
// crypto_box_seal_open. Used to recover the auth token after login.
|
|
||||||
export function decryptSealed(
|
|
||||||
ciphertext: Bytes,
|
|
||||||
publicKey: Bytes,
|
|
||||||
secretKey: Bytes,
|
|
||||||
): Bytes;
|
|
||||||
|
|
||||||
// crypto_secretstream_xchacha20poly1305_init_pull. Returned state is opaque
|
|
||||||
// and threaded through pullStreamChunk.
|
|
||||||
export function initStreamPull(header: Bytes, key: Bytes): StreamPullState;
|
|
||||||
|
|
||||||
// crypto_secretstream_xchacha20poly1305_pull. Tag values follow libsodium's
|
|
||||||
// constants: 0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL.
|
|
||||||
export function pullStreamChunk(
|
|
||||||
state: StreamPullState,
|
|
||||||
ciphertext: Bytes,
|
|
||||||
): { plaintext: Bytes; tag: number };
|
|
||||||
|
|
||||||
// Convenience helpers. fromBase64 accepts both standard and URL-safe.
|
|
||||||
export function fromBase64(s: Base64 | Base64URL): Bytes;
|
|
||||||
export function toBase64(b: Bytes): Base64;
|
|
||||||
export function toBase64URL(b: Bytes): Base64URL;
|
|
||||||
|
|
||||||
// Plaintext chunk size used by Ente for file content streams.
|
|
||||||
export const STREAM_CHUNK_SIZE: number; // 4 * 1024 * 1024
|
|
||||||
|
|
||||||
// Encrypted-chunk overhead: secretstream auth tag (16) + tag byte (1).
|
|
||||||
export const STREAM_CHUNK_OVERHEAD: number; // 17
|
|
||||||
|
|
||||||
export interface StreamPullState {
|
|
||||||
/* opaque */
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auth module
|
Each file is downloaded exactly once regardless of how many collections it
|
||||||
|
appears in. On subsequent runs, existing originals are skipped. If a download
|
||||||
```ts
|
fails, the error is logged and the backup continues with the next file. The exit
|
||||||
// src/auth/types.ts
|
code is non-zero if any files failed.
|
||||||
|
|
||||||
export interface KDFParams {
|
|
||||||
kekSalt: Base64;
|
|
||||||
memLimit: number;
|
|
||||||
opsLimit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KeyAttributes {
|
|
||||||
kekSalt: Base64;
|
|
||||||
encryptedKey: Base64;
|
|
||||||
keyDecryptionNonce: Base64;
|
|
||||||
publicKey: Base64;
|
|
||||||
encryptedSecretKey: Base64;
|
|
||||||
secretKeyDecryptionNonce: Base64;
|
|
||||||
memLimit: number;
|
|
||||||
opsLimit: number;
|
|
||||||
masterKeyEncryptedWithRecoveryKey?: Base64;
|
|
||||||
masterKeyDecryptionNonce?: Base64;
|
|
||||||
recoveryKeyEncryptedWithMasterKey?: Base64;
|
|
||||||
recoveryKeyDecryptionNonce?: Base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SRPAttributes {
|
|
||||||
srpUserID: string;
|
|
||||||
srpSalt: Base64;
|
|
||||||
memLimit: number;
|
|
||||||
opsLimit: number;
|
|
||||||
kekSalt: Base64;
|
|
||||||
isEmailMFAEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizationResponse {
|
|
||||||
id: number; // user ID
|
|
||||||
keyAttributes?: KeyAttributes;
|
|
||||||
encryptedToken?: Base64URL; // sealed-box-encrypted to user's pubkey
|
|
||||||
twoFactorSessionID?: string;
|
|
||||||
passkeySessionID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LoginChallenge =
|
|
||||||
| { kind: "complete"; response: AuthorizationResponse }
|
|
||||||
| { kind: "totp"; sessionID: string }
|
|
||||||
| { kind: "passkey"; sessionID: string }
|
|
||||||
| { kind: "emailOTP" };
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/auth/index.ts
|
|
||||||
|
|
||||||
// Begin login. Returns a challenge that tells the caller what to do next:
|
|
||||||
// supply a TOTP code, supply an email OTP, follow a passkey URL, or stop
|
|
||||||
// because login is already complete.
|
|
||||||
export function beginLogin(
|
|
||||||
api: ApiClient,
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<LoginChallenge>;
|
|
||||||
|
|
||||||
// Submit a TOTP code from an authenticator app. Returns the final
|
|
||||||
// AuthorizationResponse on success.
|
|
||||||
export function submitTOTP(
|
|
||||||
api: ApiClient,
|
|
||||||
sessionID: string,
|
|
||||||
code: string,
|
|
||||||
): Promise<AuthorizationResponse>;
|
|
||||||
|
|
||||||
// Request and submit an email-delivered one-time code. Two calls, because
|
|
||||||
// the first triggers email delivery and the second verifies it.
|
|
||||||
export function requestEmailOTP(api: ApiClient, email: string): Promise<void>;
|
|
||||||
export function submitEmailOTP(
|
|
||||||
api: ApiClient,
|
|
||||||
email: string,
|
|
||||||
code: string,
|
|
||||||
): Promise<AuthorizationResponse>;
|
|
||||||
|
|
||||||
// Given an AuthorizationResponse and the user's password, decrypt the master
|
|
||||||
// key, secret key, and auth token. Throws on bad password or tampered data.
|
|
||||||
export function unwrapAuth(
|
|
||||||
response: AuthorizationResponse,
|
|
||||||
password: string,
|
|
||||||
): Promise<{
|
|
||||||
masterKey: Bytes;
|
|
||||||
secretKey: Bytes;
|
|
||||||
publicKey: Bytes;
|
|
||||||
token: string; // base64 URL-safe; goes into X-Auth-Token
|
|
||||||
}>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Model module
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/model/index.ts
|
|
||||||
|
|
||||||
export type CollectionType =
|
|
||||||
| "album"
|
|
||||||
| "folder"
|
|
||||||
| "favorites"
|
|
||||||
| "uncategorized"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export interface Collection {
|
|
||||||
id: number;
|
|
||||||
ownerID: number;
|
|
||||||
key: Bytes; // decrypted
|
|
||||||
name: string; // decrypted
|
|
||||||
type: CollectionType;
|
|
||||||
updationTime: Microseconds;
|
|
||||||
isShared: boolean; // true if owner != current user
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileType = "image" | "video" | "livePhoto" | "unknown";
|
|
||||||
|
|
||||||
export interface FileMetadata {
|
|
||||||
title: string;
|
|
||||||
fileType: FileType;
|
|
||||||
creationTime: Microseconds;
|
|
||||||
modificationTime: Microseconds;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
hash?: string; // base64 of file SHA256
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileBlob {
|
|
||||||
decryptionHeader: Base64;
|
|
||||||
size?: number; // size of the encrypted body, if known from server
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnteFile {
|
|
||||||
id: number;
|
|
||||||
collectionID: number;
|
|
||||||
ownerID: number;
|
|
||||||
key: Bytes; // decrypted file key
|
|
||||||
metadata: FileMetadata;
|
|
||||||
file: FileBlob;
|
|
||||||
thumbnail: FileBlob;
|
|
||||||
updationTime: Microseconds;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP client
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/api/client.ts
|
|
||||||
|
|
||||||
export interface ApiClientOptions {
|
|
||||||
apiOrigin?: string; // default https://api.ente.io
|
|
||||||
filesOrigin?: string; // default https://files.ente.io
|
|
||||||
thumbsOrigin?: string; // default https://thumbnails.ente.io
|
|
||||||
authToken?: string;
|
|
||||||
fetch?: typeof fetch; // injectable for tests
|
|
||||||
userAgent?: string; // default "quak/<version>"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
readonly status: number;
|
|
||||||
readonly code?: string;
|
|
||||||
readonly requestID?: string;
|
|
||||||
readonly body?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
constructor(opts?: ApiClientOptions);
|
|
||||||
setAuthToken(token: string): void;
|
|
||||||
clearAuthToken(): void;
|
|
||||||
|
|
||||||
getJSON<T>(
|
|
||||||
path: string,
|
|
||||||
query?: Record<string, string | number | undefined>,
|
|
||||||
): Promise<T>;
|
|
||||||
postJSON<T>(path: string, body: unknown): Promise<T>;
|
|
||||||
|
|
||||||
// Streaming download from the file CDN. Caller is responsible for
|
|
||||||
// consuming the stream.
|
|
||||||
getFileStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
|
|
||||||
getThumbnailStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/session/index.ts
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
email: string;
|
|
||||||
userID: number;
|
|
||||||
token: string; // base64 URL-safe
|
|
||||||
masterKey: Bytes; // 32 bytes, never serialized in cleartext
|
|
||||||
secretKey: Bytes; // 32 bytes, never serialized in cleartext
|
|
||||||
publicKey: Bytes; // 32 bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionStoreOptions {
|
|
||||||
path?: string; // default $XDG_CONFIG_HOME/quak/session.json
|
|
||||||
keychainService?: string; // default "berlin.sneak.quak"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SessionStore {
|
|
||||||
constructor(opts?: SessionStoreOptions);
|
|
||||||
load(): Promise<Session | null>;
|
|
||||||
save(s: Session): Promise<void>;
|
|
||||||
clear(): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/client.ts
|
|
||||||
|
|
||||||
export interface LoginPrompt {
|
|
||||||
password: () => Promise<string>;
|
|
||||||
emailOTP?: () => Promise<string>; // called when account uses email OTP
|
|
||||||
totp?: () => Promise<string>; // called when TOTP is required
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientOptions extends ApiClientOptions {
|
|
||||||
sessionStore?: SessionStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadResult {
|
|
||||||
path: string;
|
|
||||||
bytesWritten: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Client {
|
|
||||||
// Static constructors. Both end with a Client that has a populated
|
|
||||||
// session and a ready ApiClient.
|
|
||||||
static login(
|
|
||||||
email: string,
|
|
||||||
prompt: LoginPrompt,
|
|
||||||
opts?: ClientOptions,
|
|
||||||
): Promise<Client>;
|
|
||||||
static fromSavedSession(opts?: ClientOptions): Promise<Client>;
|
|
||||||
|
|
||||||
readonly api: ApiClient;
|
|
||||||
readonly session: Readonly<Session>;
|
|
||||||
|
|
||||||
// Account.
|
|
||||||
whoami(): { email: string; userID: number };
|
|
||||||
saveSession(): Promise<void>;
|
|
||||||
logout(): Promise<void>; // clears session on disk and in memory
|
|
||||||
|
|
||||||
// Collections.
|
|
||||||
listCollections(opts?: { sinceTime?: Microseconds }): Promise<Collection[]>;
|
|
||||||
getCollection(id: number): Promise<Collection>;
|
|
||||||
|
|
||||||
// Files.
|
|
||||||
listFiles(
|
|
||||||
collectionID: number,
|
|
||||||
opts?: { sinceTime?: Microseconds },
|
|
||||||
): Promise<EnteFile[]>;
|
|
||||||
getFile(fileID: number): Promise<EnteFile>;
|
|
||||||
|
|
||||||
// Downloads. If outPath is omitted, a path is constructed from the
|
|
||||||
// decrypted metadata title in the current working directory. If outPath
|
|
||||||
// is a directory, the filename is taken from the metadata title. If
|
|
||||||
// outPath is a file path, that file is written.
|
|
||||||
downloadFile(
|
|
||||||
file: EnteFile | number,
|
|
||||||
outPath?: string,
|
|
||||||
): Promise<DownloadResult>;
|
|
||||||
downloadThumbnail(
|
|
||||||
file: EnteFile | number,
|
|
||||||
outPath?: string,
|
|
||||||
): Promise<DownloadResult>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Public exports (`src/index.ts`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { Client } from "./client";
|
|
||||||
export { ApiClient, ApiError } from "./api/client";
|
|
||||||
export { SessionStore } from "./session";
|
|
||||||
export * from "./model";
|
|
||||||
export type {
|
|
||||||
Session,
|
|
||||||
LoginPrompt,
|
|
||||||
ClientOptions,
|
|
||||||
DownloadResult,
|
|
||||||
} from "./client";
|
|
||||||
```
|
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
Phase 1: scaffolding
|
|
||||||
|
|
||||||
- [x] `git init`, write README
|
|
||||||
- [x] Create `initial-scaffolding` feature branch
|
|
||||||
- [x] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`, `.editorconfig`,
|
|
||||||
`.prettierrc`, `.prettierignore`, `.dockerignore`
|
|
||||||
- [x] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
|
|
||||||
`hooks`, plus `build`, `dev`, `clean`
|
|
||||||
- [x] Add `Dockerfile` running `make check` against pinned node image
|
|
||||||
- [x] Add `.gitea/workflows/check.yml` running `docker build .`
|
|
||||||
- [x] Add `package.json`, `tsconfig.json`, pinned dev versions of `typescript`,
|
|
||||||
`prettier`, `eslint`, `typescript-eslint`, `vitest`, `@types/node` (the
|
|
||||||
runtime deps `libsodium-wrappers-sumo`, `secure-remote-password`,
|
|
||||||
`commander`, etc. land with their respective implementation phases)
|
|
||||||
- [x] Smoke test: `make check` and `make docker` both pass
|
|
||||||
|
|
||||||
Phase 2: crypto primitives
|
|
||||||
|
|
||||||
- [x] Wrap libsodium init as an awaitable singleton
|
|
||||||
- [x] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
|
|
||||||
- [x] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
|
|
||||||
bytes)
|
|
||||||
- [x] `decryptBox(ciphertext, nonce, key)` for secretbox
|
|
||||||
- [x] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
|
|
||||||
- [x] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
|
|
||||||
plaintext chunks, 17-byte overhead)
|
|
||||||
- [x] Round-trip tests against vectors generated by libsodium directly
|
|
||||||
- [x] Base64 helpers (`fromBase64`, `toBase64`, `toBase64URL`) accepting all
|
|
||||||
four sodium variants on input
|
|
||||||
|
|
||||||
Phase 3: SRP + auth
|
|
||||||
|
|
||||||
- [x] SRP-6a client using `fast-srp-hap` with the 4096-bit group (matching the
|
|
||||||
upstream Ente web client)
|
|
||||||
- [x] `beginLogin(api, email, password)` returning a `LoginChallenge`
|
|
||||||
- [x] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
|
|
||||||
- [x] `submitTOTP(api, sessionID, code)`
|
|
||||||
- [x] `unwrapAuth(response, password)` returning master key, secret key, public
|
|
||||||
key, and decrypted token (URL-safe-no-padding base64)
|
|
||||||
- [x] `src/auth/types.ts` with `KeyAttributes`, `SRPAttributes`,
|
|
||||||
`AuthorizationResponse`, and `LoginChallenge`
|
|
||||||
- [x] Tests with mock SRP server performing real 4096-bit math end-to-end
|
|
||||||
|
|
||||||
Phase 4: HTTP client + endpoints
|
|
||||||
|
|
||||||
- [x] `ApiClient` that attaches `X-Auth-Token` and `X-Client-Package`
|
|
||||||
- [x] `ApiError` that surfaces the server's error code and request id
|
|
||||||
- [x] `getJSON` / `postJSON` with query-param and JSON-body handling
|
|
||||||
- [x] `getFileStream` / `getThumbnailStream` with self-hosted routing
|
|
||||||
- [ ] Typed wrappers for the endpoints listed above
|
|
||||||
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
errors
|
errors
|
||||||
|
- [ ] Update the API reference section below to match the current implementation
|
||||||
Phase 5: collections and files
|
|
||||||
|
|
||||||
- [x] `decryptCollection(raw, masterKey)` with key + name decryption, type
|
|
||||||
mapping, isShared flag
|
|
||||||
- [x] `decryptFile(raw, collectionKey)` with key + metadata decryption
|
|
||||||
(secretstream blob, not secretbox), fileType mapping, header passthrough
|
|
||||||
- [x] `decryptBlob(ciphertext, header, key)` convenience for single-chunk
|
|
||||||
secretstream decryption (used by file metadata and magic metadata)
|
|
||||||
- [x] Model types: `Collection`, `EnteFile`, `FileMetadata`, `RawCollection`,
|
|
||||||
`RawEnteFile`
|
|
||||||
- [x] Live-tested against real Ente API (collection names + file metadata)
|
|
||||||
- [ ] Higher-level `listCollections()` / `listFiles()` with pagination
|
|
||||||
|
|
||||||
Phase 6: download
|
|
||||||
|
|
||||||
- [x] `downloadFile(api, file, outPath?)` streams encrypted body, buffers to 4
|
|
||||||
MiB chunk boundary, decrypts via secretstream pull, writes to disk. Falls
|
|
||||||
back to `metadata.title` when outPath is omitted.
|
|
||||||
- [x] `downloadThumbnail(api, file, outPath?)` same for thumbnails
|
|
||||||
- [x] Live integration test: logs in, decrypts collections and files, downloads
|
|
||||||
a real JPEG from the dev account and verifies it on disk
|
|
||||||
|
|
||||||
Phase 7: Client class
|
|
||||||
|
|
||||||
- [x] `Client.login({ email, password, totp?, emailOTP? })` performs the full
|
|
||||||
SRP handshake, key unwrap, returns a ready Client
|
|
||||||
- [x] `Client.fromJSON(snapshot)` restores from a serialized snapshot
|
|
||||||
- [x] `client.toJSON()` produces a `ClientSnapshot` the consumer can persist
|
|
||||||
- [x] `client.whoami()`, `client.logout()`
|
|
||||||
- [x] `client.listCollections()` with decryption
|
|
||||||
- [x] `client.listFiles(collectionID, collectionKey)` with pagination
|
|
||||||
- [x] `client.downloadFile(file, outPath?)` and `client.downloadThumbnail()`
|
|
||||||
- [x] Literate test/client/usage.test.ts tutorial covering the entire API
|
|
||||||
|
|
||||||
Phase 8: CLI
|
|
||||||
|
|
||||||
- [x] `quak login` (interactive TTY prompts or QUAK_EMAIL/QUAK_PASSWORD env vars
|
|
||||||
for non-interactive use)
|
|
||||||
- [x] `quak whoami`, `quak logout`
|
|
||||||
- [x] `quak collections` and `quak files --collection <id>`
|
|
||||||
- [x] `quak get <fileID>` and `quak get-thumb <fileID>` with --out and
|
|
||||||
--collection options; searches all collections when --collection omitted
|
|
||||||
- [x] `quak backup <dir>` with originals/ dedup, collections/ symlinks,
|
|
||||||
per-collection and per-file JSON metadata, incremental skip, per-file
|
|
||||||
error resilience, --json flag
|
|
||||||
- [x] `--json` output on every listing/backup command
|
|
||||||
- [x] Progress output to stderr for backup
|
|
||||||
- [x] Session persistence via env-paths (~/Library/Application Support/quak/ on
|
|
||||||
macOS, XDG_DATA_HOME/quak/ on Linux)
|
|
||||||
|
|
||||||
Phase 9: docs and 1.0
|
|
||||||
|
|
||||||
- [ ] Update README Getting Started and Design sections to match current state
|
|
||||||
- [ ] All TODO items above checked
|
|
||||||
- [ ] `make docker` green
|
- [ ] `make docker` green
|
||||||
- [ ] Tag `v1.0.0`
|
- [ ] Tag `v1.0.0`
|
||||||
|
|
||||||
Phase 10 and beyond: desktop client (separate repo)
|
Future (desktop client, separate repo):
|
||||||
|
|
||||||
- [ ] Spike Electron app skeleton consuming this library
|
- [ ] Electron app skeleton consuming this library
|
||||||
- [ ] Local cache (SQLite) keyed on `(collectionID, fileID, updationTime)`
|
- [ ] Local cache (SQLite) keyed on `(collectionID, fileID, updationTime)`
|
||||||
- [ ] Background sync worker that streams new files into the cache
|
- [ ] Background sync worker that streams new files into the cache
|
||||||
- [ ] Read-only gallery UI: thumbnails, full-image view, basic search
|
- [ ] Gallery UI: thumbnails, full-image view, basic search
|
||||||
- [ ] Add upload, delete, and share back into the library before the desktop UI
|
- [ ] Upload, delete, and share operations in the library
|
||||||
exposes them
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
The API reference section below is from an earlier draft and does not fully
|
||||||
|
reflect the current implementation. The authoritative API documentation is in
|
||||||
|
the test files, particularly `test/client/usage.test.ts` which is a literate
|
||||||
|
tutorial walking through every operation. Run `yarn test` to verify the examples
|
||||||
|
are correct.
|
||||||
|
|
||||||
|
The key types and their actual signatures can be found in:
|
||||||
|
|
||||||
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
||||||
|
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `ApiError`
|
||||||
|
- `src/auth/types.ts`: `KeyAttributes`, `SRPAttributes`,
|
||||||
|
`AuthorizationResponse`, `LoginChallenge`
|
||||||
|
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileBlob`,
|
||||||
|
`RawCollection`, `RawEnteFile`, `RawMagicMetadata`
|
||||||
|
- `src/download/index.ts`: `DownloadResult`
|
||||||
|
- `src/backup.ts`: `BackupResult`, `BackupError`
|
||||||
|
- `src/thumbnails.ts`: `MissingThumbnailInfo`, `ThumbnailFixResult`
|
||||||
|
|
||||||
## Source attribution
|
## Source attribution
|
||||||
|
|
||||||
@@ -702,6 +317,38 @@ is rewritten in TypeScript in this repository. Protocol fidelity is verified
|
|||||||
against the upstream implementations in `web/packages/base/`,
|
against the upstream implementations in `web/packages/base/`,
|
||||||
`mobile/apps/photos/lib/`, and `cli/`.
|
`mobile/apps/photos/lib/`, and `cli/`.
|
||||||
|
|
||||||
|
## For LLMs
|
||||||
|
|
||||||
|
If you are an LLM agent working on this repository, read and follow these
|
||||||
|
documents:
|
||||||
|
|
||||||
|
- **`REPO_POLICIES.md`** in the repo root. It is copied from
|
||||||
|
<https://git.eeqj.de/sneak/prompts> and covers repository structure, tooling,
|
||||||
|
Makefile targets, Dockerfile conventions, dependency pinning, and commit
|
||||||
|
hygiene. All external dependencies must be pinned by cryptographic hash in
|
||||||
|
`yarn.lock`. Never `git add -A`. Never force-push to main.
|
||||||
|
|
||||||
|
- **The "Development workflow" section above.** All changes go on feature
|
||||||
|
branches. Tests are written first and committed in a failing state before the
|
||||||
|
implementation. Tests are the canonical API documentation and must be
|
||||||
|
commented thoroughly. `main` is always green.
|
||||||
|
|
||||||
|
- **Required checks before every commit:** `make lint` (eslint + prettier check)
|
||||||
|
and `make fmt-check` must pass. The pre-commit hook enforces this.
|
||||||
|
`make check` (which also runs tests) must pass before merging to `main`.
|
||||||
|
|
||||||
|
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
|
||||||
|
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
|
||||||
|
|
||||||
|
- **Testing:** vitest. Tests go in `test/` mirroring the `src/` structure.
|
||||||
|
`make test` must complete in under 20 seconds. Use `mkdtempSync` for temporary
|
||||||
|
directories, never manual timestamp paths.
|
||||||
|
|
||||||
|
- **Code style:** `const` for everything, `let` if reassignment is needed, never
|
||||||
|
`var`. Avoid unnecessary comments. No hand-rolled crypto. The
|
||||||
|
`LLM_PROSE_TELLS.md` document in the prompts repo applies to any prose written
|
||||||
|
in this repository (README, comments, commit messages).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
WTFPL. See [LICENSE](LICENSE).
|
WTFPL. See [LICENSE](LICENSE).
|
||||||
|
|||||||
22
bin/quak.ts
22
bin/quak.ts
@@ -9,6 +9,7 @@ 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 { runMetadataBackup } from "../src/metadata-backup.js";
|
||||||
import {
|
import {
|
||||||
listMissingThumbnails,
|
listMissingThumbnails,
|
||||||
fixMissingThumbnails,
|
fixMissingThumbnails,
|
||||||
@@ -333,6 +334,26 @@ program
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("backup-metadata")
|
||||||
|
.description(
|
||||||
|
"Dump all decrypted account metadata to a directory of JSON files",
|
||||||
|
)
|
||||||
|
.argument("<dir>", "Output directory")
|
||||||
|
.option(
|
||||||
|
"--exif",
|
||||||
|
"Download each file and extract full EXIF/IPTC/XMP metadata (slow)",
|
||||||
|
)
|
||||||
|
.option("--all", "Alias for --exif")
|
||||||
|
.action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => {
|
||||||
|
await init();
|
||||||
|
const client = requireSession();
|
||||||
|
await runMetadataBackup(client, dir, {
|
||||||
|
exif: opts.exif || opts.all,
|
||||||
|
onProgress: (msg) => stderr.write(msg + "\n"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("backup")
|
.command("backup")
|
||||||
.description(
|
.description(
|
||||||
@@ -456,4 +477,5 @@ helper
|
|||||||
process.exit(results.some((r) => !r.success) ? 1 : 0);
|
process.exit(results.some((r) => !r.success) ? 1 : 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await init();
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"@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,8 +40,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "14.0.3",
|
"commander": "14.0.3",
|
||||||
"env-paths": "4.0.0",
|
"env-paths": "4.0.0",
|
||||||
|
"exif-reader": "2.0.3",
|
||||||
"fast-srp-hap": "2.0.4",
|
"fast-srp-hap": "2.0.4",
|
||||||
"libsodium-wrappers-sumo": "0.8.4",
|
"jpeg-js": "0.4.4",
|
||||||
"sharp": "0.34.5"
|
"libsodium-wrappers-sumo": "0.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
270
src/metadata-backup.ts
Normal file
270
src/metadata-backup.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { gunzipSync } from "node:zlib";
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import * as jpeg from "jpeg-js";
|
||||||
|
import exifReader from "exif-reader";
|
||||||
|
import type { Client } from "./client.js";
|
||||||
|
import { decryptBlob, fromBase64 } from "./crypto/index.js";
|
||||||
|
import type { EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
|
export type ProgressCallback = (message: string) => void;
|
||||||
|
|
||||||
|
export interface MetadataBackupOptions {
|
||||||
|
exif?: boolean;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizePath = (name: string): string =>
|
||||||
|
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
|
||||||
|
|
||||||
|
interface RawRemoteFileData {
|
||||||
|
fileID: number;
|
||||||
|
encryptedData: string;
|
||||||
|
decryptionHeader: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMLDataForFiles = async (
|
||||||
|
client: Client,
|
||||||
|
fileIDs: number[],
|
||||||
|
fileKeys: Map<number, Uint8Array>,
|
||||||
|
): Promise<Map<number, Record<string, unknown>>> => {
|
||||||
|
const api = client.getApiClient();
|
||||||
|
const result = new Map<number, Record<string, unknown>>();
|
||||||
|
const batchSize = 200;
|
||||||
|
|
||||||
|
for (let i = 0; i < fileIDs.length; i += batchSize) {
|
||||||
|
const batch = fileIDs.slice(i, i + batchSize);
|
||||||
|
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
|
||||||
|
"/files/data/fetch",
|
||||||
|
{ type: "mldata", fileIDs: batch },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const entry of data ?? []) {
|
||||||
|
const key = fileKeys.get(entry.fileID);
|
||||||
|
if (!key) continue;
|
||||||
|
try {
|
||||||
|
const decrypted = decryptBlob(
|
||||||
|
fromBase64(entry.encryptedData),
|
||||||
|
fromBase64(entry.decryptionHeader),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
const jsonStr = gunzipSync(Buffer.from(decrypted)).toString(
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
result.set(entry.fileID, JSON.parse(jsonStr));
|
||||||
|
} catch {
|
||||||
|
// Corrupted ML data for this file; skip it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
|
||||||
|
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
||||||
|
// header) or undefined if no APP1 marker is found.
|
||||||
|
const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => {
|
||||||
|
if (buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < buf.length - 1) {
|
||||||
|
if (buf[offset] !== 0xff) return undefined;
|
||||||
|
const marker = buf[offset + 1]!;
|
||||||
|
if (marker === 0xda) break; // start of scan, no more markers
|
||||||
|
if (offset + 3 >= buf.length) break;
|
||||||
|
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
|
||||||
|
if (marker === 0xe1) {
|
||||||
|
// APP1 — check for "Exif\0\0" header
|
||||||
|
if (
|
||||||
|
buf[offset + 4] === 0x45 &&
|
||||||
|
buf[offset + 5] === 0x78 &&
|
||||||
|
buf[offset + 6] === 0x69 &&
|
||||||
|
buf[offset + 7] === 0x66
|
||||||
|
) {
|
||||||
|
return Buffer.from(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + offset + 4,
|
||||||
|
len - 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += 2 + len;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractImageMetadata = (
|
||||||
|
fileBytes: Uint8Array,
|
||||||
|
): Record<string, unknown> | undefined => {
|
||||||
|
try {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Try to get dimensions from JPEG decode
|
||||||
|
try {
|
||||||
|
const decoded = jpeg.decode(fileBytes, {
|
||||||
|
useTArray: true,
|
||||||
|
formatAsRGBA: false,
|
||||||
|
});
|
||||||
|
result.format = "jpeg";
|
||||||
|
result.width = decoded.width;
|
||||||
|
result.height = decoded.height;
|
||||||
|
} catch {
|
||||||
|
// Not a JPEG or corrupt; still try EXIF extraction
|
||||||
|
}
|
||||||
|
|
||||||
|
const exifBuf = extractExifFromJpeg(fileBytes);
|
||||||
|
if (exifBuf) {
|
||||||
|
try {
|
||||||
|
result.exif = exifReader(exifBuf);
|
||||||
|
} catch {
|
||||||
|
result.exifRaw = exifBuf.toString("base64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
||||||
|
const xmpStart = Buffer.from(fileBytes).indexOf("<?xpacket begin");
|
||||||
|
if (xmpStart !== -1) {
|
||||||
|
const xmpEnd = Buffer.from(fileBytes).indexOf(
|
||||||
|
"<?xpacket end",
|
||||||
|
xmpStart,
|
||||||
|
);
|
||||||
|
if (xmpEnd !== -1) {
|
||||||
|
const end = Buffer.from(fileBytes).indexOf("?>", xmpEnd);
|
||||||
|
result.xmp = Buffer.from(fileBytes)
|
||||||
|
.subarray(xmpStart, end !== -1 ? end + 2 : xmpEnd + 50)
|
||||||
|
.toString("utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractExif = async (
|
||||||
|
client: Client,
|
||||||
|
file: EnteFile,
|
||||||
|
): Promise<Record<string, unknown> | undefined> => {
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "quak-exif-"));
|
||||||
|
try {
|
||||||
|
const origPath = join(tmpDir, "original");
|
||||||
|
await client.downloadFile(file, origPath);
|
||||||
|
const fileBytes = new Uint8Array(readFileSync(origPath));
|
||||||
|
return extractImageMetadata(fileBytes);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runMetadataBackup = async (
|
||||||
|
client: Client,
|
||||||
|
outDir: string,
|
||||||
|
opts?: MetadataBackupOptions,
|
||||||
|
): Promise<void> => {
|
||||||
|
const log = opts?.onProgress ?? (() => {});
|
||||||
|
const wantExif = opts?.exif ?? false;
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
mkdirSync(join(outDir, "collections"), { recursive: true });
|
||||||
|
|
||||||
|
const { email, userID } = client.whoami();
|
||||||
|
writeFileSync(
|
||||||
|
join(outDir, "account.json"),
|
||||||
|
JSON.stringify({ email, userID }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
log("Fetching collections...");
|
||||||
|
const collections = await client.listCollections();
|
||||||
|
|
||||||
|
const allFiles: { file: EnteFile; colDirName: string }[] = [];
|
||||||
|
const fileKeys = new Map<number, Uint8Array>();
|
||||||
|
const seenFileIDs = new Set<number>();
|
||||||
|
|
||||||
|
for (const col of collections) {
|
||||||
|
const dirName = `${col.id}-${sanitizePath(col.name || "unnamed")}`;
|
||||||
|
const colDir = join(outDir, "collections", dirName);
|
||||||
|
mkdirSync(colDir, { recursive: true });
|
||||||
|
|
||||||
|
const collectionMeta: Record<string, unknown> = {
|
||||||
|
id: col.id,
|
||||||
|
name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
ownerID: col.ownerID,
|
||||||
|
isShared: col.isShared,
|
||||||
|
updationTime: col.updationTime,
|
||||||
|
};
|
||||||
|
if (col.magicMetadata) collectionMeta.magicMetadata = col.magicMetadata;
|
||||||
|
if (col.pubMagicMetadata)
|
||||||
|
collectionMeta.pubMagicMetadata = col.pubMagicMetadata;
|
||||||
|
if (col.sharedMagicMetadata)
|
||||||
|
collectionMeta.sharedMagicMetadata = col.sharedMagicMetadata;
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(colDir, "_collection.json"),
|
||||||
|
JSON.stringify(collectionMeta, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
log(`[${col.name}] Fetching files...`);
|
||||||
|
const files = await client.listFiles(col.id, col.key);
|
||||||
|
log(`[${col.name}] ${files.length} file(s)`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
allFiles.push({ file, colDirName: dirName });
|
||||||
|
if (!seenFileIDs.has(file.id)) {
|
||||||
|
fileKeys.set(file.id, file.key);
|
||||||
|
seenFileIDs.add(file.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Fetching ML data (face detections, CLIP embeddings)...");
|
||||||
|
const mlDataMap = await fetchMLDataForFiles(
|
||||||
|
client,
|
||||||
|
[...fileKeys.keys()],
|
||||||
|
fileKeys,
|
||||||
|
);
|
||||||
|
log(`Got ML data for ${mlDataMap.size} file(s)`);
|
||||||
|
|
||||||
|
const writtenFileIDs = new Set<number>();
|
||||||
|
for (const { file, colDirName } of allFiles) {
|
||||||
|
const colDir = join(outDir, "collections", colDirName);
|
||||||
|
|
||||||
|
const fileMeta: Record<string, unknown> = {
|
||||||
|
id: file.id,
|
||||||
|
collectionID: file.collectionID,
|
||||||
|
ownerID: file.ownerID,
|
||||||
|
metadata: file.metadata,
|
||||||
|
updationTime: file.updationTime,
|
||||||
|
};
|
||||||
|
if (file.magicMetadata) fileMeta.magicMetadata = file.magicMetadata;
|
||||||
|
if (file.pubMagicMetadata)
|
||||||
|
fileMeta.pubMagicMetadata = file.pubMagicMetadata;
|
||||||
|
|
||||||
|
const ml = mlDataMap.get(file.id);
|
||||||
|
if (ml) fileMeta.mlData = ml;
|
||||||
|
|
||||||
|
if (wantExif && !writtenFileIDs.has(file.id)) {
|
||||||
|
log(`[${file.metadata.title}] Extracting EXIF...`);
|
||||||
|
const exifData = await extractExif(client, file);
|
||||||
|
if (exifData) fileMeta.imageMetadata = exifData;
|
||||||
|
}
|
||||||
|
writtenFileIDs.add(file.id);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(colDir, `${file.id}.json`),
|
||||||
|
JSON.stringify(fileMeta, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Metadata backup complete.");
|
||||||
|
};
|
||||||
@@ -57,6 +57,9 @@ export const decryptCollection = (
|
|||||||
type: parseCollectionType(raw.type),
|
type: parseCollectionType(raw.type),
|
||||||
updationTime: raw.updationTime,
|
updationTime: raw.updationTime,
|
||||||
isShared: currentUserID !== undefined && raw.owner.id !== currentUserID,
|
isShared: currentUserID !== undefined && raw.owner.id !== currentUserID,
|
||||||
|
magicMetadata: decryptMagicMetadata(raw.magicMetadata, key),
|
||||||
|
pubMagicMetadata: decryptMagicMetadata(raw.pubMagicMetadata, key),
|
||||||
|
sharedMagicMetadata: decryptMagicMetadata(raw.sharedMagicMetadata, key),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export interface Collection {
|
|||||||
type: CollectionType;
|
type: CollectionType;
|
||||||
updationTime: Microseconds;
|
updationTime: Microseconds;
|
||||||
isShared: boolean;
|
isShared: boolean;
|
||||||
|
magicMetadata?: Record<string, unknown>;
|
||||||
|
pubMagicMetadata?: Record<string, unknown>;
|
||||||
|
sharedMagicMetadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileType = "image" | "video" | "livePhoto" | "unknown";
|
export type FileType = "image" | "video" | "livePhoto" | "unknown";
|
||||||
@@ -59,6 +62,9 @@ export interface RawCollection {
|
|||||||
type: string;
|
type: string;
|
||||||
updationTime: number;
|
updationTime: number;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
|
magicMetadata?: RawMagicMetadata;
|
||||||
|
pubMagicMetadata?: RawMagicMetadata;
|
||||||
|
sharedMagicMetadata?: RawMagicMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawMagicMetadata {
|
export interface RawMagicMetadata {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import sharp from "sharp";
|
import { readFileSync } from "node:fs";
|
||||||
|
import * as jpeg from "jpeg-js";
|
||||||
import type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
import { encryptBlob, toBase64 } from "./crypto/index.js";
|
import { encryptBlob, toBase64 } from "./crypto/index.js";
|
||||||
import { downloadFile } from "./download/index.js";
|
import { downloadFile } from "./download/index.js";
|
||||||
@@ -74,16 +75,71 @@ export const listMissingThumbnails = async (
|
|||||||
return missing;
|
return missing;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateThumbnail = async (originalPath: string): Promise<Uint8Array> => {
|
// Bilinear resize of RGBA pixel buffer
|
||||||
const result = await sharp(originalPath)
|
const resizeRGBA = (
|
||||||
.rotate()
|
src: Uint8Array,
|
||||||
.resize(THUMB_MAX_DIMENSION, THUMB_MAX_DIMENSION, {
|
srcW: number,
|
||||||
fit: "inside",
|
srcH: number,
|
||||||
withoutEnlargement: true,
|
dstW: number,
|
||||||
})
|
dstH: number,
|
||||||
.jpeg({ quality: THUMB_JPEG_QUALITY })
|
): Uint8Array => {
|
||||||
.toBuffer();
|
const dst = new Uint8Array(dstW * dstH * 4);
|
||||||
return new Uint8Array(result);
|
const xRatio = srcW / dstW;
|
||||||
|
const yRatio = srcH / dstH;
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
const srcY = y * yRatio;
|
||||||
|
const y0 = Math.floor(srcY);
|
||||||
|
const y1 = Math.min(y0 + 1, srcH - 1);
|
||||||
|
const fy = srcY - y0;
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
const srcX = x * xRatio;
|
||||||
|
const x0 = Math.floor(srcX);
|
||||||
|
const x1 = Math.min(x0 + 1, srcW - 1);
|
||||||
|
const fx = srcX - x0;
|
||||||
|
const i00 = (y0 * srcW + x0) * 4;
|
||||||
|
const i10 = (y0 * srcW + x1) * 4;
|
||||||
|
const i01 = (y1 * srcW + x0) * 4;
|
||||||
|
const i11 = (y1 * srcW + x1) * 4;
|
||||||
|
const di = (y * dstW + x) * 4;
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
dst[di + c] = Math.round(
|
||||||
|
src[i00 + c]! * (1 - fx) * (1 - fy) +
|
||||||
|
src[i10 + c]! * fx * (1 - fy) +
|
||||||
|
src[i01 + c]! * (1 - fx) * fy +
|
||||||
|
src[i11 + c]! * fx * fy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
||||||
|
const decoded = jpeg.decode(fileBytes, {
|
||||||
|
useTArray: true,
|
||||||
|
formatAsRGBA: true,
|
||||||
|
});
|
||||||
|
const { width: srcW, height: srcH } = decoded;
|
||||||
|
const scale = Math.min(
|
||||||
|
THUMB_MAX_DIMENSION / srcW,
|
||||||
|
THUMB_MAX_DIMENSION / srcH,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const dstW = Math.round(srcW * scale);
|
||||||
|
const dstH = Math.round(srcH * scale);
|
||||||
|
|
||||||
|
let pixels: Uint8Array;
|
||||||
|
if (scale < 1) {
|
||||||
|
pixels = resizeRGBA(decoded.data, srcW, srcH, dstW, dstH);
|
||||||
|
} else {
|
||||||
|
pixels = decoded.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = jpeg.encode(
|
||||||
|
{ data: pixels, width: dstW, height: dstH },
|
||||||
|
THUMB_JPEG_QUALITY,
|
||||||
|
);
|
||||||
|
return new Uint8Array(encoded.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fixMissingThumbnails = async (
|
export const fixMissingThumbnails = async (
|
||||||
@@ -139,7 +195,8 @@ export const fixMissingThumbnails = async (
|
|||||||
log(
|
log(
|
||||||
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
|
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
|
||||||
);
|
);
|
||||||
const thumbJpeg = await generateThumbnail(origPath);
|
const fileBytes = readFileSync(origPath);
|
||||||
|
const thumbJpeg = generateThumbnail(new Uint8Array(fileBytes));
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,
|
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,
|
||||||
|
|||||||
623
test/cli/metadata-backup.test.ts
Normal file
623
test/cli/metadata-backup.test.ts
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `quak backup-metadata <dir>`.
|
||||||
|
*
|
||||||
|
* This command dumps all decrypted account metadata into a directory
|
||||||
|
* tree of plain JSON files, without downloading any file content. It
|
||||||
|
* is fast (no multi-megabyte downloads) and produces a complete
|
||||||
|
* plaintext record of every collection name, file title, creation
|
||||||
|
* date, GPS coordinate, camera model, caption, face label, and any
|
||||||
|
* other metadata the Ente clients have attached.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
*
|
||||||
|
* <dir>/
|
||||||
|
* account.json { email, userID }
|
||||||
|
* collections/
|
||||||
|
* <id>-<sanitized-name>/
|
||||||
|
* _collection.json { id, name, type, magicMetadata?, ... }
|
||||||
|
* <fileID>.json { id, metadata, magicMetadata?, pubMagicMetadata? }
|
||||||
|
*
|
||||||
|
* The test builds a mock server with two collections, each with files
|
||||||
|
* that have different combinations of metadata layers, and verifies
|
||||||
|
* the output tree is correct and complete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { SRP, SrpServer } from "fast-srp-hap";
|
||||||
|
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
init,
|
||||||
|
toBase64,
|
||||||
|
deriveKEK,
|
||||||
|
deriveLoginSubkey,
|
||||||
|
encryptBlob,
|
||||||
|
} from "../../src/crypto/index.js";
|
||||||
|
import * as jpegJs from "jpeg-js";
|
||||||
|
import { Client } from "../../src/client.js";
|
||||||
|
import { runMetadataBackup } from "../../src/metadata-backup.js";
|
||||||
|
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||||
|
|
||||||
|
const TEST_EMAIL = "metabackup@example.com";
|
||||||
|
const TEST_PASSWORD = "metapass";
|
||||||
|
const TEST_OPS = 2;
|
||||||
|
const TEST_MEM = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface MetaMockState {
|
||||||
|
verifier: Buffer;
|
||||||
|
srpAttributes: Record<string, unknown>;
|
||||||
|
keyAttributes: KeyAttributes;
|
||||||
|
encryptedToken: string;
|
||||||
|
collections: Record<string, unknown>[];
|
||||||
|
filesByCollection: Record<number, Record<string, unknown>[]>;
|
||||||
|
// For ML data and EXIF tests
|
||||||
|
encryptedMLData: Record<
|
||||||
|
number,
|
||||||
|
{ encryptedData: string; decryptionHeader: string }
|
||||||
|
>;
|
||||||
|
fileCiphertexts: Record<number, Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mock: MetaMockState;
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||||
|
return { ciphertext, nonce };
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptStreamBlob = (plaintext: Uint8Array, key: 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 { ciphertext, header: push.header };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetaMock = async (): Promise<MetaMockState> => {
|
||||||
|
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 = "meta-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 { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
|
||||||
|
masterKey,
|
||||||
|
kek,
|
||||||
|
);
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
|
||||||
|
kp.privateKey,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const tokenBytes = sodium.randombytes_buf(32);
|
||||||
|
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
|
||||||
|
|
||||||
|
const keyAttributes: KeyAttributes = {
|
||||||
|
kekSalt: toBase64(kekSalt),
|
||||||
|
encryptedKey: toBase64(encMK),
|
||||||
|
keyDecryptionNonce: toBase64(mkNonce),
|
||||||
|
publicKey: toBase64(kp.publicKey),
|
||||||
|
encryptedSecretKey: toBase64(encSK),
|
||||||
|
secretKeyDecryptionNonce: toBase64(skNonce),
|
||||||
|
memLimit: TEST_MEM,
|
||||||
|
opsLimit: TEST_OPS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collection 1: "Vacation" with collection-level pubMagicMetadata
|
||||||
|
const ck1 = sodium.crypto_secretbox_keygen();
|
||||||
|
const { ciphertext: encCK1, nonce: ck1N } = encryptSecretbox(
|
||||||
|
ck1,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const { ciphertext: encCN1, nonce: cn1N } = encryptSecretbox(
|
||||||
|
new TextEncoder().encode("Vacation"),
|
||||||
|
ck1,
|
||||||
|
);
|
||||||
|
const collPubMagic = JSON.stringify({ coverID: 999, sortBy: "date" });
|
||||||
|
const { ciphertext: encCollPM, header: collPMHeader } = encryptStreamBlob(
|
||||||
|
new TextEncoder().encode(collPubMagic),
|
||||||
|
ck1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawColl1 = {
|
||||||
|
id: 10,
|
||||||
|
owner: { id: 42 },
|
||||||
|
encryptedKey: toBase64(encCK1),
|
||||||
|
keyDecryptionNonce: toBase64(ck1N),
|
||||||
|
encryptedName: toBase64(encCN1),
|
||||||
|
nameDecryptionNonce: toBase64(cn1N),
|
||||||
|
type: "album",
|
||||||
|
updationTime: 1700000000000000,
|
||||||
|
pubMagicMetadata: {
|
||||||
|
version: 1,
|
||||||
|
count: 1,
|
||||||
|
data: toBase64(encCollPM),
|
||||||
|
header: toBase64(collPMHeader),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collection 2: "Work" with no magic metadata
|
||||||
|
const ck2 = sodium.crypto_secretbox_keygen();
|
||||||
|
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
|
||||||
|
ck2,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
|
||||||
|
new TextEncoder().encode("Work"),
|
||||||
|
ck2,
|
||||||
|
);
|
||||||
|
const rawColl2 = {
|
||||||
|
id: 20,
|
||||||
|
owner: { id: 42 },
|
||||||
|
encryptedKey: toBase64(encCK2),
|
||||||
|
keyDecryptionNonce: toBase64(ck2N),
|
||||||
|
encryptedName: toBase64(encCN2),
|
||||||
|
nameDecryptionNonce: toBase64(cn2N),
|
||||||
|
type: "folder",
|
||||||
|
updationTime: 1700000000000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// File 100 in coll 10: has metadata + pubMagicMetadata
|
||||||
|
const fk1 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { ciphertext: encFK1, nonce: fk1N } = encryptSecretbox(fk1, ck1);
|
||||||
|
const meta1 = JSON.stringify({
|
||||||
|
title: "beach.jpg",
|
||||||
|
fileType: 0,
|
||||||
|
creationTime: 1700000000000000,
|
||||||
|
modificationTime: 1700000000000000,
|
||||||
|
latitude: 35.6762,
|
||||||
|
longitude: 139.6503,
|
||||||
|
});
|
||||||
|
const { ciphertext: encMeta1, header: meta1Header } = encryptStreamBlob(
|
||||||
|
new TextEncoder().encode(meta1),
|
||||||
|
fk1,
|
||||||
|
);
|
||||||
|
const pubMagic1 = JSON.stringify({
|
||||||
|
w: 3000,
|
||||||
|
h: 2000,
|
||||||
|
cameraMake: "SONY",
|
||||||
|
cameraModel: "DSC-RX1RM3",
|
||||||
|
});
|
||||||
|
const { ciphertext: encPM1, header: pm1Header } = encryptStreamBlob(
|
||||||
|
new TextEncoder().encode(pubMagic1),
|
||||||
|
fk1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawFile1 = {
|
||||||
|
id: 100,
|
||||||
|
collectionID: 10,
|
||||||
|
ownerID: 42,
|
||||||
|
encryptedKey: toBase64(encFK1),
|
||||||
|
keyDecryptionNonce: toBase64(fk1N),
|
||||||
|
metadata: {
|
||||||
|
encryptedData: toBase64(encMeta1),
|
||||||
|
decryptionHeader: toBase64(meta1Header),
|
||||||
|
},
|
||||||
|
pubMagicMetadata: {
|
||||||
|
version: 1,
|
||||||
|
count: 1,
|
||||||
|
data: toBase64(encPM1),
|
||||||
|
header: toBase64(pm1Header),
|
||||||
|
},
|
||||||
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
|
updationTime: 1700000000000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// File 200 in coll 20: metadata only, no magic metadata
|
||||||
|
const fk2 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { ciphertext: encFK2, nonce: fk2N } = encryptSecretbox(fk2, ck2);
|
||||||
|
const meta2 = JSON.stringify({
|
||||||
|
title: "diagram.png",
|
||||||
|
fileType: 0,
|
||||||
|
creationTime: 1710000000000000,
|
||||||
|
modificationTime: 1710000000000000,
|
||||||
|
});
|
||||||
|
const { ciphertext: encMeta2, header: meta2Header } = encryptStreamBlob(
|
||||||
|
new TextEncoder().encode(meta2),
|
||||||
|
fk2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawFile2 = {
|
||||||
|
id: 200,
|
||||||
|
collectionID: 20,
|
||||||
|
ownerID: 42,
|
||||||
|
encryptedKey: toBase64(encFK2),
|
||||||
|
keyDecryptionNonce: toBase64(fk2N),
|
||||||
|
metadata: {
|
||||||
|
encryptedData: toBase64(encMeta2),
|
||||||
|
decryptionHeader: toBase64(meta2Header),
|
||||||
|
},
|
||||||
|
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
|
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||||
|
updationTime: 1710000000000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
|
||||||
|
const mlPayload = JSON.stringify({
|
||||||
|
face: {
|
||||||
|
version: 1,
|
||||||
|
client: "test",
|
||||||
|
width: 3000,
|
||||||
|
height: 2000,
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
faceID: "face-abc",
|
||||||
|
detection: {
|
||||||
|
box: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||||||
|
landmarks: [
|
||||||
|
{ x: 0.15, y: 0.25 },
|
||||||
|
{ x: 0.25, y: 0.25 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
score: 0.98,
|
||||||
|
blur: 12.5,
|
||||||
|
embedding: [0.1, 0.2, 0.3],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clip: {
|
||||||
|
version: 1,
|
||||||
|
client: "test",
|
||||||
|
embedding: [0.5, 0.6, 0.7],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gzipped = gzipSync(Buffer.from(mlPayload));
|
||||||
|
const { header: mlHeader, ciphertext: mlCiphertext } = encryptBlob(
|
||||||
|
new Uint8Array(gzipped),
|
||||||
|
fk1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate a real JPEG for EXIF extraction tests
|
||||||
|
const jw = 100;
|
||||||
|
const jh = 80;
|
||||||
|
const jpixels = new Uint8Array(jw * jh * 4);
|
||||||
|
for (let i = 0; i < jpixels.length; i += 4) {
|
||||||
|
jpixels[i] = 255;
|
||||||
|
jpixels[i + 1] = 0;
|
||||||
|
jpixels[i + 2] = 0;
|
||||||
|
jpixels[i + 3] = 255;
|
||||||
|
}
|
||||||
|
const tinyJpeg = jpegJs.encode(
|
||||||
|
{ data: jpixels, width: jw, height: jh },
|
||||||
|
80,
|
||||||
|
).data;
|
||||||
|
const filePush1 =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk1);
|
||||||
|
const encFileBody1 = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||||
|
filePush1.state,
|
||||||
|
new Uint8Array(tinyJpeg),
|
||||||
|
null,
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||||
|
);
|
||||||
|
// Patch rawFile1's file.decryptionHeader to match the push header
|
||||||
|
rawFile1.file.decryptionHeader = toBase64(filePush1.header);
|
||||||
|
|
||||||
|
return {
|
||||||
|
verifier,
|
||||||
|
srpAttributes: {
|
||||||
|
srpUserID,
|
||||||
|
srpSalt: toBase64(srpSalt),
|
||||||
|
memLimit: TEST_MEM,
|
||||||
|
opsLimit: TEST_OPS,
|
||||||
|
kekSalt: toBase64(kekSalt),
|
||||||
|
isEmailMFAEnabled: false,
|
||||||
|
},
|
||||||
|
keyAttributes,
|
||||||
|
encryptedToken: toBase64(encToken),
|
||||||
|
collections: [rawColl1, rawColl2],
|
||||||
|
filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
|
||||||
|
encryptedMLData: {
|
||||||
|
100: {
|
||||||
|
encryptedData: toBase64(mlCiphertext),
|
||||||
|
decryptionHeader: toBase64(mlHeader),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileCiphertexts: { 100: encFileBody1 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMetaFetch = (m: MetaMockState) => {
|
||||||
|
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 path = new URL(url).pathname;
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/collections/v2")
|
||||||
|
return json({ collections: m.collections });
|
||||||
|
if (path === "/collections/v2/diff") {
|
||||||
|
const collID = Number(
|
||||||
|
new URL(url).searchParams.get("collectionID"),
|
||||||
|
);
|
||||||
|
return json({
|
||||||
|
diff: m.filesByCollection[collID] ?? [],
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/files/data/fetch") {
|
||||||
|
const body = JSON.parse(init?.body as string);
|
||||||
|
const data = (body.fileIDs as number[])
|
||||||
|
.filter((id: number) => m.encryptedMLData[id])
|
||||||
|
.map((id: number) => ({
|
||||||
|
fileID: id,
|
||||||
|
...m.encryptedMLData[id],
|
||||||
|
updatedAt: 1700000000000000,
|
||||||
|
}));
|
||||||
|
return json({ data });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.includes("files.ente.io") ||
|
||||||
|
path.startsWith("/files/download/")
|
||||||
|
) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
mock = await buildMetaMock();
|
||||||
|
testDir = mkdtempSync(join(tmpdir(), "quak-meta-backup-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (testDir && existsSync(testDir))
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quak backup-metadata", () => {
|
||||||
|
it("writes account.json with email and userID", async () => {
|
||||||
|
const outDir = join(testDir, "full");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const account = JSON.parse(
|
||||||
|
readFileSync(join(outDir, "account.json"), "utf-8"),
|
||||||
|
);
|
||||||
|
expect(account.email).toBe(TEST_EMAIL);
|
||||||
|
expect(account.userID).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates per-collection directories with _collection.json", async () => {
|
||||||
|
const outDir = join(testDir, "collections");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
expect(collDirs.length).toBe(2);
|
||||||
|
|
||||||
|
// Find the Vacation collection dir (prefixed with ID)
|
||||||
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||||
|
expect(vacDir).toBeDefined();
|
||||||
|
const collMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", vacDir, "_collection.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(collMeta.id).toBe(10);
|
||||||
|
expect(collMeta.name).toBe("Vacation");
|
||||||
|
expect(collMeta.type).toBe("album");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts collection-level pubMagicMetadata", async () => {
|
||||||
|
const outDir = join(testDir, "coll-magic");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||||
|
const collMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", vacDir, "_collection.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(collMeta.pubMagicMetadata).toBeDefined();
|
||||||
|
expect(collMeta.pubMagicMetadata.coverID).toBe(999);
|
||||||
|
expect(collMeta.pubMagicMetadata.sortBy).toBe("date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes per-file JSON with all three metadata layers", async () => {
|
||||||
|
const outDir = join(testDir, "file-meta");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||||
|
const fileMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", vacDir, "100.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(fileMeta.id).toBe(100);
|
||||||
|
expect(fileMeta.metadata.title).toBe("beach.jpg");
|
||||||
|
expect(fileMeta.metadata.latitude).toBeCloseTo(35.6762);
|
||||||
|
expect(fileMeta.pubMagicMetadata.cameraMake).toBe("SONY");
|
||||||
|
expect(fileMeta.pubMagicMetadata.w).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles files with no magic metadata gracefully", async () => {
|
||||||
|
const outDir = join(testDir, "no-magic");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
const workDir = collDirs.find((d) => d.includes("Work"))!;
|
||||||
|
const fileMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", workDir, "200.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(fileMeta.id).toBe(200);
|
||||||
|
expect(fileMeta.metadata.title).toBe("diagram.png");
|
||||||
|
expect(fileMeta.pubMagicMetadata).toBeUndefined();
|
||||||
|
expect(fileMeta.magicMetadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is incremental: second run does not fail", async () => {
|
||||||
|
const outDir = join(testDir, "incremental");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const account = JSON.parse(
|
||||||
|
readFileSync(join(outDir, "account.json"), "utf-8"),
|
||||||
|
);
|
||||||
|
expect(account.email).toBe(TEST_EMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches and decrypts ML data by default", async () => {
|
||||||
|
const outDir = join(testDir, "ml-data");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir);
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||||
|
const fileMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", vacDir, "100.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ML data should be present and decrypted
|
||||||
|
expect(fileMeta.mlData).toBeDefined();
|
||||||
|
expect(fileMeta.mlData.face).toBeDefined();
|
||||||
|
expect(fileMeta.mlData.face.faces.length).toBe(1);
|
||||||
|
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
|
||||||
|
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
|
||||||
|
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
|
||||||
|
expect(fileMeta.mlData.clip).toBeDefined();
|
||||||
|
expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts EXIF from downloaded files when --exif is set", async () => {
|
||||||
|
const outDir = join(testDir, "exif-data");
|
||||||
|
const client = await Client.login({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
apiOptions: { fetch: buildMetaFetch(mock) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await runMetadataBackup(client, outDir, { exif: true });
|
||||||
|
|
||||||
|
const collDirs = readdirSync(join(outDir, "collections"));
|
||||||
|
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||||
|
const fileMeta = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", vacDir, "100.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// imageMetadata from JPEG parsing should be present
|
||||||
|
expect(fileMeta.imageMetadata).toBeDefined();
|
||||||
|
expect(fileMeta.imageMetadata.format).toBe("jpeg");
|
||||||
|
expect(fileMeta.imageMetadata.width).toBe(100);
|
||||||
|
expect(fileMeta.imageMetadata.height).toBe(80);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* verify that the detection and repair logic handles each case correctly.
|
* verify that the detection and repair logic handles each case correctly.
|
||||||
*
|
*
|
||||||
* `fixMissingThumbnails` is the most complex function in quak: it
|
* `fixMissingThumbnails` is the most complex function in quak: it
|
||||||
* downloads the original file, generates a JPEG thumbnail with sharp,
|
* downloads the original file, generates a JPEG thumbnail with jpeg-js,
|
||||||
* encrypts it with secretstream push, gets a presigned upload URL,
|
* encrypts it with secretstream push, gets a presigned upload URL,
|
||||||
* uploads to S3, and registers the new thumbnail with the API. The
|
* uploads to S3, and registers the new thumbnail with the API. The
|
||||||
* test verifies each step actually happened and the uploaded data is
|
* test verifies each step actually happened and the uploaded data is
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import sodium from "libsodium-wrappers-sumo";
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
import sharp from "sharp";
|
import * as jpegJs from "jpeg-js";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
init,
|
init,
|
||||||
@@ -120,12 +120,20 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
|
|||||||
updationTime: 1700000000000000,
|
updationTime: 1700000000000000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate a real tiny JPEG that sharp can process
|
// Generate a real tiny JPEG via jpeg-js
|
||||||
const tinyJpeg = await sharp({
|
const w = 100;
|
||||||
create: { width: 100, height: 80, channels: 3, background: "red" },
|
const h = 80;
|
||||||
})
|
const pixels = new Uint8Array(w * h * 4);
|
||||||
.jpeg({ quality: 80 })
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
.toBuffer();
|
pixels[i] = 255; // R
|
||||||
|
pixels[i + 1] = 0; // G
|
||||||
|
pixels[i + 2] = 0; // B
|
||||||
|
pixels[i + 3] = 255; // A
|
||||||
|
}
|
||||||
|
const tinyJpeg = jpegJs.encode(
|
||||||
|
{ data: pixels, width: w, height: h },
|
||||||
|
80,
|
||||||
|
).data;
|
||||||
|
|
||||||
const fileKeys: Record<number, Uint8Array> = {};
|
const fileKeys: Record<number, Uint8Array> = {};
|
||||||
const fileCiphertexts: Record<number, Uint8Array> = {};
|
const fileCiphertexts: Record<number, Uint8Array> = {};
|
||||||
@@ -434,7 +442,7 @@ describe("fixMissingThumbnails", () => {
|
|||||||
expect(decrypted[0]).toBe(0xff);
|
expect(decrypted[0]).toBe(0xff);
|
||||||
expect(decrypted[1]).toBe(0xd8);
|
expect(decrypted[1]).toBe(0xd8);
|
||||||
expect(decrypted[2]).toBe(0xff);
|
expect(decrypted[2]).toBe(0xff);
|
||||||
// Verify sharp produced a reasonably sized thumbnail
|
// Verify jpeg-js produced a reasonably sized thumbnail
|
||||||
expect(decrypted.length).toBeGreaterThan(100);
|
expect(decrypted.length).toBeGreaterThan(100);
|
||||||
expect(decrypted.length).toBeLessThan(50000);
|
expect(decrypted.length).toBeLessThan(50000);
|
||||||
});
|
});
|
||||||
|
|||||||
217
yarn.lock
217
yarn.lock
@@ -2,13 +2,6 @@
|
|||||||
# 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"
|
||||||
@@ -230,153 +223,6 @@
|
|||||||
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"
|
||||||
@@ -557,13 +403,6 @@
|
|||||||
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"
|
||||||
@@ -877,11 +716,6 @@ 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"
|
||||||
@@ -1026,6 +860,11 @@ esutils@^2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
|
exif-reader@2.0.3:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
|
||||||
|
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
|
||||||
|
|
||||||
expect-type@^1.1.0:
|
expect-type@^1.1.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68"
|
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68"
|
||||||
@@ -1188,6 +1027,11 @@ isexe@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||||
|
|
||||||
|
jpeg-js@^0.4.4:
|
||||||
|
version "0.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa"
|
||||||
|
integrity sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==
|
||||||
|
|
||||||
js-yaml@^4.1.1:
|
js-yaml@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||||
@@ -1446,45 +1290,11 @@ run-parallel@^1.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask "^1.2.2"
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
semver@^7.6.0, semver@^7.7.3:
|
semver@^7.6.0:
|
||||||
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"
|
||||||
@@ -1566,11 +1376,6 @@ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user