Compare commits

Author SHA1 Message Date
clawbot a066134a17 Store live photos as their image and their video (closes #107)
check / check (push) Successful in 41s
A live photo, which Ente stores as one ZIP, is unpacked as it downloads
into its image and its video, each `<fileID>.<ext>` with its extension
from the ZIP, beside `<fileID>.livephoto.json`, which names the two.
Both are checked against the recorded hash and renamed into place only
when both are complete. The backup and the content cache count a live
photo as stored only with both files, album folders link both,
`quak get` writes both, and the content result gives the video as
`videoPath`. A ZIP an earlier version stored is replaced.

Model: opus-5-5
2026-09-23 12:50:02 +00:00
clawbot 04094a8cfb Mark the package private and declare engines and exports (closes #6)
check / check (push) Successful in 47s
quak is not published, so package.json is marked private and loses its
files field. engines.node is >=22, the major version script/bootstrap
and the Dockerfile use. The exports map makes "." and "./package.json"
the only importable paths, so the CLI-only modules stay internal.

Model: opus-5-5
2026-09-23 14:17:53 +02:00
clawbot 6a7a10f489 Bring README and TODO.md in line with the tree on next2 (closes #111)
check / check (push) Successful in 57s
The README layout lists src/library/ and the other source files, the
backup layout names failures.json and the optional thumbnails/, "Opening
a library" says an empty cache opens with no data when the first refresh
fails, and the Testing section gives the 60-second hard cap and 20-second
target for make test, with the 90-second timeout in the Dockerfile's test
phase as the backstop that catches a hung test. The next step in both
files is storing live photos
(#107) instead of a v1.0.0 tag;
tagging and releases are sneak's call alone.

Model: opus-5-5
2026-09-23 10:08:03 +02:00
clawbot 7740ebfd4d Test quak login and backup-metadata --exif (closes #110)
check / check (push) Successful in 42s
loginCommand now takes its login function and prompts from CliContext;
bin/quak.ts passes Client.login and the terminal prompts, so behaviour is
unchanged. Tests cover a login from QUAK_EMAIL/QUAK_PASSWORD with no
prompt, the TOTP prompt, a failed login, and the saved session's modes,
and show that --exif and --all each turn on EXIF extraction. Also rewraps
the header comment of src/cli-commands.ts.

Model: opus-5-5
2026-09-23 09:27:40 +02:00
clawbot ae76eb3f74 Write each backed-up original once and skip the precache (closes #106)
check / check (push) Successful in 49s
An original fetched for a backup is now written by the download writer
straight into the backup's originals/, and the content cache records it
there instead of keeping its own copy. One the cache already held is
still copied. `quak backup` opens its library with the thumbnail and
originals precache off, as the one-shot commands do.

Model: opus-5-5
2026-09-23 08:48:02 +02:00
clawbot f52c77f155 Refresh before backup-metadata and the thumbnail helpers answer (closes #100)
check / check (push) Successful in 1m0s
backup-metadata, helper list-missing-thumbnails and helper
fix-missing-thumbnails now await lib.fresh() before reading the library,
as collections, files, get and get-thumb already do. A file added since
the cache was written is included, and a failed refresh is thrown, so
the CLI prints one line and exits 1 instead of answering from a stale
or empty cache. The README lists them among the commands that refresh
first.

Model: opus-5-5
2026-09-23 08:11:37 +02:00
clawbot 6757ddea94 Make backup wait for the server refresh and fail when it fails (closes #99)
check / check (push) Successful in 1m8s
lib.backup() refreshed through the background loop's refresh, which returns
at once when one is already running and swallows a failure, so a backup
could run on the previous file list, or on an empty cache, and exit 0. It
now uses the refresh fresh() uses: it joins a running refresh or starts
one, and rejects before touching any file when it fails. The CLI's error
wrapper prints that as one line and exits 1.

Model: opus-5-5
2026-09-23 08:07:44 +02:00
clawbot 36642f4448 Print CLI errors as one line instead of a stack trace (closes #102)
check / check (push) Successful in 1m13s
An error a command throws now reaches the user as one `quak: MESSAGE`
line on stderr, and the CLI exits 1 once output has drained. The wrapper
moved from bin/quak.ts to src/cli-run.ts so it can be tested, and
bin/quak.ts awaits program.parseAsync() so async actions are awaited.

Model: opus-5-5
2026-09-23 07:33:26 +02:00
clawbot fc396d1ecc Keep another process's downloads when opening a library (closes #105)
check / check (push) Successful in 1m18s
The download writer's temp files are now named .quak-<pid>-<random>.tmp.
removeLeftoverTempFiles moves from the backup into the download module and
deletes a .quak-*.tmp file only when the process ID in its name is no
longer running; the content cache calls it at open() instead of deleting
every temp file, so a download in progress in another process sharing the
cache survives. The README backup layout and TODO.md are updated.

Model: opus-5-5
2026-09-23 07:25:28 +02:00
clawbot cb61582ae6 Make lint and test phases of the Dockerfile (closes #96)
check / check (push) Successful in 1m45s
Follows the template: Dockerfile.lint is gone; the Dockerfile has a lint
phase (eslint, prettier --check .) and a test phase (vitest, run as the
node user, which the not-writable-directory tests need), and its
last stage compiles and depends on both. script/lint and script/test
build one phase each with --no-cache; script/docker and script/cibuild
pass --no-cache, so CHECK_EPOCH and LINT_EPOCH are removed.
script/cibuild is the single image build, so CI runs lint and the tests
once each. The tests that checked the old layout are deleted,
REPO_POLICIES.md is re-copied and the README describes the new layout.

Model: opus-5-5
2026-09-23 07:18:46 +02:00
clawbot cda57eebda Skip thumbnail repairs the server always refuses (closes #109)
check / check (push) Successful in 49s
The server accepts a new thumbnail only from the file's owner and only when
it is no larger than the thumbnail size it records. Both thumbnail helpers
now skip files another account owns without fetching them. The fixer skips
a file whose recorded thumbnail size is 0 or unknown before downloading it,
and otherwise tries smaller encodings until the encrypted thumbnail fits,
skipping the file if none does.

Model: opus-5-5
2026-09-23 07:03:33 +02:00
clawbot c24c4dda4f Test the live-photo hash check's error paths (closes #117)
check / check (push) Successful in 16s
Three downloadFile tests cover a live photo whose ZIP names an unknown
compression method, one whose ZIP has no image entry and one with no
video entry. Each checks that nothing is stored and the error names the
file ID; the unreadable ZIP is also checked not to be retried. Each test
fails when its check in livePhotoHasher is removed.

Model: opus-5-5
2026-09-23 06:56:15 +02:00
clawbot 4bb75ca323 quak logout ends the session on the server (closes #108)
check / check (push) Successful in 54s
logout now calls POST /users/logout with the saved token through the new
Client.logoutOnServer(), then deletes session.json even when that call
fails; in that case it says the server session could not be ended and
exits 1. It prints the account's cache directory and says it still holds
decrypted data. The default cache path moves into
defaultCacheDirectory(), shared with Library.open, so both name the same
directory.

Model: opus-5-5
2026-09-23 06:51:12 +02:00
23 changed files with 2541 additions and 503 deletions
+127 -44
View File
@@ -214,11 +214,23 @@ quak/
auth/ login flow (SRP + email OTP + TOTP), key unwrap auth/ login flow (SRP + email OTP + TOTP), key unwrap
model/ decrypted Collection, File, Metadata types + decrypt fns model/ decrypted Collection, File, Metadata types + decrypt fns
download/ streaming file/thumbnail download + decryption download/ streaming file/thumbnail download + decryption
library/ the cache-backed Library: metadata store, read
surface, records, content cache, precache, ML data
and search, request pools
backup.ts resilient full-account backup with dedup backup.ts resilient full-account backup with dedup
metadata-backup.ts
backup-metadata: all decrypted metadata as JSON
mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata
errors.ts error types shared across layers errors.ts error types shared across layers
retry.ts retry classifier + exponential backoff with jitter retry.ts retry classifier + exponential backoff with jitter
thumbnails.ts detect + regenerate missing thumbnails 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
cli-commands.ts the CLI's commands as functions returning exit codes
cli-output.ts how the CLI prints a file's title and time
cli-read.ts fresh reads for the CLI's read commands
cli-run.ts run a command, print its error, exit with its code
cli-session.ts read the saved session file back into a Client
index.ts public library exports index.ts public library exports
bin/ bin/
quak.ts CLI entrypoint (commander.js) quak.ts CLI entrypoint (commander.js)
@@ -296,6 +308,7 @@ Endpoints used:
encrypted token plus key attributes. encrypted token plus key attributes.
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path. - `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
- `POST /users/two-factor/verify`: TOTP second factor. - `POST /users/two-factor/verify`: TOTP second factor.
- `POST /users/logout`: end the calling token's session (`quak logout`).
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since - `GET /collections/v2?sinceTime=<usec>`: list collections changed since
microsecond timestamp; pass 0 for a full enumeration. microsecond timestamp; pass 0 for a full enumeration.
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a - `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
@@ -410,7 +423,9 @@ whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
working client from that snapshot without re-authenticating; it checks every working client from that snapshot without re-authenticating; it checks every
field and each key's length first, and throws an error naming the bad field. field and each key's length first, and throws an error naming the bad field.
`client.logout()` clears the token and zeroes the key buffers in place; every `client.logout()` clears the token and zeroes the key buffers in place; every
later call on that client throws. later call on that client throws. It does not contact the server, so the token
stays valid there and in any saved snapshot; `await client.logoutOnServer()`
first ends the session on the server (`POST /users/logout`).
The CLI stores the snapshot at the platform-appropriate data directory via The CLI stores the snapshot at the platform-appropriate data directory via
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS, `env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
@@ -420,13 +435,21 @@ you would treat the password itself. A missing file is reported as "not logged
in"; a file that exists but is corrupt is reported as such, naming the bad in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1. field. Both exit with status 1.
`quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the
server call fails (or the file is corrupt), the file is still deleted, the
command says the server session could not be ended, and it exits with status 1.
It does not delete the cache: it prints the account's cache directory and says
it still holds decrypted data (file keys in `metadata.json`, cached originals
and thumbnails), for the user to delete if they want it gone.
### CLI surface ### CLI surface
``` ```
quak [--cache-dir <path>] <command> global: local metadata/content cache location quak [--cache-dir <path>] <command> global: local metadata/content cache location
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
quak whoami print logged-in account as JSON quak whoami print logged-in account as JSON
quak logout delete saved session quak logout end the session, delete it
quak collections [--json] list all collections quak collections [--json] list all collections
quak files --collection <id> [--json] list files in a collection quak files --collection <id> [--json] list files in a collection
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
@@ -438,16 +461,23 @@ quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbn
``` ```
Every command runs on the same cache-backed library. The read commands — Every command runs on the same cache-backed library. The read commands —
`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip `collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
before they answer, so they report current account state rather than whatever `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
the cache last held. `--cache-dir` overrides where the cache lives; without it fresh server round-trip before they answer, so they report current account state
each account gets its own directory under the per-user cache path. rather than whatever the cache last held. If that round-trip fails, the command
prints the error on one line and exits 1. `--cache-dir` overrides where the
cache lives; without it each account gets its own directory under the per-user
cache path.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. `backup-metadata --exif` (alias accepted for backward compatibility but ignored. For a live photo, `get` writes
`--all`) additionally downloads each file to extract full EXIF/IPTC/XMP its image and its video, each named after the title with its own extension, as
metadata. The listing and backup commands support `--json` for machine-readable Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
output. `--out PATH`, the image is written to `PATH` and the video beside it, with
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
refused. `backup-metadata --exif` (alias `--all`) additionally downloads each
file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands
support `--json` for machine-readable output.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
still fails after its retries, the error is logged, each of its files is written still fails after its retries, the error is logged, each of its files is written
@@ -458,7 +488,13 @@ on. The exit code is non-zero if any ML data request failed.
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
distinct from a `failed` repair, and does not affect the exit code; a genuine distinct from a `failed` repair, and does not affect the exit code; a genuine
failure still exits non-zero. failure still exits non-zero. The server accepts a new thumbnail only from the
file's owner and only when it is no larger than the thumbnail size it records
for the file. So a file another account owns, in an album shared with you, is
skipped by both thumbnail helpers without being fetched, and the fixer skips a
file whose recorded thumbnail size is 0 or unknown. Otherwise the fixer lowers
the quality and size of the thumbnail until it fits, and skips the file if even
the smallest does not.
### Backup layout ### Backup layout
@@ -467,22 +503,41 @@ failure still exits non-zero.
``` ```
<dir>/ <dir>/
originals/ originals/
<fileID>.<ext> actual file content (one per unique file) <fileID>.<ext> actual file content (one per unique file,
two for a live photo: see below)
<fileID>.json all decrypted metadata for that file <fileID>.json all decrypted metadata for that file
<fileID>.livephoto.json which of a live photo's two files is which
collections/ collections/
<name>/ <name>/
<title> -> ../../originals/<fileID>.<ext> (symlink) <title> -> ../../originals/<fileID>.<ext> (symlink)
<name>.json collection metadata + file list <name>.json collection metadata + file list
failures.json files that failed and have not yet succeeded
``` ```
`failures.json` records each failed file with the kind of failure, how many
times it has been tried and when it was last tried. A file leaves it once it
succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `originals/`; `quak backup` does not.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two files in one collection the same collections would get the same name, or two symlinks in one collection the same
title (ignoring case in both), each of them gets its ID added: two albums named name (ignoring case in both), each of them gets its ID added: two albums named
`Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG` `Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG`
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a
name stays the same from run to run until such a clash appears or goes away. name stays the same from run to run until such a clash appears or goes away.
A live photo, which Ente stores as one ZIP of its image and its video, is stored
as those two files, which a photo viewer can open: each is
`originals/<fileID>.<ext>` with the extension it has inside the ZIP (for example
`12345.heic` and `12345.mov`), and `<fileID>.livephoto.json` names the two. The
live photo counts as stored only when both files are present and not empty. Its
album folder links both, each named after the title with that file's extension
(`IMG_0001.heic` and `IMG_0001.mov`). A live photo that an earlier version of
quak stored as the ZIP, under the image's name, is replaced by its two files on
the next run, and the ZIP and its link are removed.
Each run removes the symlinks into `originals/` that no longer belong in their Each run removes the symlinks into `originals/` that no longer belong in their
collection's directory, and the directories (and JSON) of collections that were collection's directory, and the directories (and JSON) of collections that were
deleted or renamed. Nothing else in `collections/` is touched: a file or a deleted or renamed. Nothing else in `collections/` is touched: a file or a
@@ -490,17 +545,23 @@ symlink you put there stays, and a directory that still holds one after its
symlinks are removed stays too, with its JSON. symlinks are removed stays too, with its JSON.
Each file is downloaded exactly once regardless of how many collections it Each file is downloaded exactly once regardless of how many collections it
appears in. On subsequent runs, existing originals are skipped. If a download appears in, and written once: straight into `originals/`, with no copy left in
fails, the error is logged and the backup continues with the next file. The exit the cache. An original the cache already held is copied from there instead. On
code is non-zero if any files failed. subsequent runs, existing originals are skipped. If a download fails, the error
is logged and the backup continues with the next file. The exit code is non-zero
if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so it fetches only what the backup stores.
Each original is copied to a temporary file named Each original is written to a temporary file in the same directory, synced to
`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp` in the same directory, synced disk, and renamed into place, so an original is either complete or absent, even
to disk, and renamed into place, so an original is either complete or absent, after a power cut. A downloaded original's temporary file is named
even after a power cut. A run that is killed can leave one of these temporary `.quak-<pid>-<random>.tmp`, one copied from the cache
files behind; the next backup deletes those whose process is no longer running. `.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp`. A run that is killed can leave
Downloads and the content cache use the same scheme with `.quak-<random>.tmp` one of these temporary files behind; the next backup deletes those whose process
names. The rename replaces whatever was at the destination rather than writing is no longer running. The content cache uses the same scheme, and opening a
library deletes the temporary files in the cache whose process is no longer
running, so a download another process has in progress in the same cache is left
alone. The rename replaces whatever was at the destination rather than writing
through it: a symlink there is replaced, not followed, and the new file has the through it: a symlink there is replaced, not followed, and the new file has the
temporary file's permissions, not those of the file it replaced. temporary file's permissions, not those of the file it replaced.
@@ -510,7 +571,12 @@ temporary file's permissions, not those of the file it replaced.
errors errors
- [x] Update the API reference section below to match the current implementation - [x] Update the API reference section below to match the current implementation
- [x] `make docker` green - [x] `make docker` green
- [ ] Tag `v1.0.0` - [x] Store live photos in a form a photo viewer can open
(https://git.eeqj.de/sneak/quak/issues/107): unpacked into the image and
the video
Tagging and releases are decided by sneak alone, and happen only when he
declares one.
Future (desktop client, separate repo): Future (desktop client, separate repo):
@@ -530,10 +596,11 @@ test suite is the canonical, executable documentation — `test/library/` and
### Opening a library ### Opening a library
`Library.open(options)` loads the on-disk cache, starts the background refresh `Library.open(options)` loads the on-disk cache, starts the background refresh
loop, and resolves to a `Library`. On an empty cache it awaits the first refresh loop, and resolves to a `Library`. On an empty cache it awaits the first
so it never opens onto empty data; on an existing cache it returns immediately refresh, so it opens onto the account's data whenever the server is reachable;
and refreshes in the background, so an unreachable server does not block if that refresh fails, it opens with no data and records the error in
opening. `lib.status()`. On an existing cache it returns immediately and refreshes in the
background, so an unreachable server does not block opening.
`LibraryOptions`: `LibraryOptions`:
@@ -598,7 +665,9 @@ An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields, `photo.record()` (newest first). A `Photo` exposes its record fields, `photo.record()`
`PhotoRecord`, and two content methods: `PhotoRecord`, and two content methods:
- `await photo.original(opts?)``{ path, bytes }` — the full-resolution file. - `await photo.original(opts?)``{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and
`videoPath` is its video.
- `await photo.thumbnail(opts?)``{ path, bytes }`. - `await photo.thumbnail(opts?)``{ path, bytes }`.
Both serve from the on-disk content cache when the bytes are present and Both serve from the on-disk content cache when the bytes are present and
@@ -618,7 +687,7 @@ The GUI-facing records hold no key material and no binary, so they survive
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds), - `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
`fileType`, optional `caption` / `width` / `height` / `latitude` / `fileType`, optional `caption` / `width` / `height` / `latitude` /
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath` `longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
once the bytes are cached. once the bytes are cached (for a live photo, `originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and - `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first). `fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`. - `LibrarySnapshot`: `{ albums, photos, takenAt }`.
@@ -644,12 +713,15 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere. a query vector the caller produced elsewhere.
- `await lib.backup(opts?)``BackupResult`. It refreshes, fetches every - `await lib.backup(opts?)``BackupResult`. It refreshes, fetches every
in-scope original (and, with `includeThumbnails`, thumbnails) through the in-scope original not already in the backup (and, with `includeThumbnails`,
content cache, and rebuilds the on-disk backup tree with a durable failure thumbnails) through the content cache, and rebuilds the on-disk backup tree
ledger. `BackupOptions`: `downloadDirectory` (falls back to the one `open()` with a durable failure ledger. A fetched original is written straight into the
was given), `includeOriginals` (default `true`), `includeThumbnails` (default backup's `originals/` and not into the cache, which then counts it as present;
`false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above for the one the cache already held is copied from there. `BackupOptions`:
tree it writes. `downloadDirectory` (falls back to the one `open()` was given),
`includeOriginals` (default `true`), `includeThumbnails` (default `false`),
`onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it
writes.
### Request pools ### Request pools
@@ -666,6 +738,8 @@ Under `cacheDirectory`:
<cacheDirectory>/ <cacheDirectory>/
metadata.json decrypted account state + refresh cursor metadata.json decrypted account state + refresh cursor
originals/<fileID>.<ext> cached full-resolution files originals/<fileID>.<ext> cached full-resolution files
originals/<fileID>.livephoto.json
which of a live photo's two files is which
thumbnails/<fileID>.jpg cached thumbnails thumbnails/<fileID>.jpg cached thumbnails
mldata/ mldata/
<fileID>.json one decrypted ML payload per file <fileID>.json one decrypted ML payload per file
@@ -678,14 +752,21 @@ When `metadata.json` belongs to a different account than the client's,
originals and thumbnails are kept; they are reached only through the files the originals and thumbnails are kept; they are reached only through the files the
current account's records name. current account's records name.
A live photo's original is cached as in the backup: its image and its video,
each `originals/<fileID>.<ext>` with its own extension, and
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
live photo that an earlier version cached as its ZIP is not served, and is
replaced by its two files the next time it is read.
A stored file appears only via an atomic temp-then-rename, so its presence means A stored file appears only via an atomic temp-then-rename, so its presence means
it is complete. Every downloaded original (by `quak get`, the cache, or it is complete. Every downloaded original (by `quak get`, the cache, or
`backup`) whose metadata records a content hash (`FileMetadata.hash`) is hashed `backup`) whose metadata records a content hash (`FileMetadata.hash`) is hashed
as it is written: unkeyed BLAKE2b with a 64-byte output, standard base64. For a as it is written: unkeyed BLAKE2b with a 64-byte output, standard base64. A live
live photo, which is stored as a ZIP, the image and the video are hashed photo arrives as a ZIP and is unpacked as it is written; its image and its video
separately and joined as `<imageHash>:<videoHash>`. A mismatch stores nothing are hashed separately and joined as `<imageHash>:<videoHash>`, and neither is
and fails the download with an error naming the file ID. An original with no stored unless both are complete and match. A mismatch stores nothing and fails
recorded hash, from a very old client, is stored unchecked. the download with an error naming the file ID. An original with no recorded
hash, from a very old client, is stored unchecked.
### Key types by source file ### Key types by source file
@@ -746,8 +827,10 @@ documents:
markdown. Use `make fmt` to format. Use `yarn` not `npm`. markdown. Use `make fmt` to format. Use `yarn` not `npm`.
- **Testing:** vitest. Tests go in `test/` mirroring the `src/` structure. - **Testing:** vitest. Tests go in `test/` mirroring the `src/` structure.
`make test` must complete in under 20 seconds. Use `mkdtempSync` for temporary `make test` must finish in under 60 seconds (the hard cap) and should finish
directories, never manual timestamp paths. in under 20. The 90-second `timeout` in the `test` phase of the `Dockerfile`
is a backstop that catches a hung test, not the time limit. Use `mkdtempSync`
for temporary directories, never manual timestamp paths.
- **Code style:** `const` for everything, `let` if reassignment is needed, never - **Code style:** `const` for everything, `let` if reassignment is needed, never
`var`. Avoid unnecessary comments. No hand-rolled crypto. The `var`. Avoid unnecessary comments. No hand-rolled crypto. The
+95 -1
View File
@@ -14,16 +14,107 @@ pre-1.0
# Next Step # Next Step
Tag v1.0.0. None: every issue still open is done on `next` or `next2` and waits for it to
reach `main`.
Tagging and releases are decided by sneak alone, and happen only when he
declares one.
# Completed Steps # Completed Steps
- 2026-09-23: Live photos are stored as their image and their video (issue 107).
A live photo, which Ente stores as one ZIP, is unpacked as it downloads into
`<fileID>.<ext>` for the image and for the video, each with its extension from
the ZIP, beside `<fileID>.livephoto.json`, which names the two. Both are
checked against the recorded hash and renamed into place only when both are
complete. The backup and the content cache count the live photo as stored only
with both files, the album folder links both, `quak get` writes both, and the
content result gives the video as `videoPath`. A ZIP an earlier version stored
is replaced on the next backup run, and in the cache when the photo is next
read.
- 2026-09-23: Settled the package metadata (issue 6). quak is not published, so
`package.json` is marked `"private": true` and the `files` field is gone.
`engines.node` is `>=22`, the major version `script/bootstrap` and the
`Dockerfile` use. An `exports` map makes `.` and `./package.json` the only
importable paths; `runMetadataBackup`, the thumbnail helpers and their types
stay internal to the CLI.
- 2026-09-23: Brought the README and this file in line with the tree (issue
111). The layout lists `src/library/` and the other source files, the backup
layout names `failures.json` and the optional `thumbnails/`, "Opening a
library" says what happens when the first refresh fails, the Testing section
gives the 60-second hard cap and 20-second target for `make test` and names
the 90-second `timeout` in the `Dockerfile` as the backstop for a hung test,
and "Tag v1.0.0" is no longer listed as the next step.
- 2026-09-23: Tested `quak login` and `backup-metadata --exif` (issue 110).
`loginCommand` takes its login function and its prompts from `CliContext`, and
`bin/quak.ts` passes `Client.login` and the terminal prompts. Tests cover a
login from `QUAK_EMAIL` and `QUAK_PASSWORD` with no prompt, the TOTP prompt, a
failed login, and the saved session's modes, and show that `--exif` and
`--all` each turn on EXIF extraction and that it is off without them.
- 2026-09-23: `quak backup` writes each original once and no longer fills the
cache (issue 106). An original fetched for a backup is written by the download
writer straight into the backup's `originals/`, and the content cache records
it there instead of keeping its own copy; one the cache already held is still
copied. `quak backup` opens its library with the thumbnail and originals
precache off.
- 2026-09-23: `backup-metadata`, `helper list-missing-thumbnails` and
`helper fix-missing-thumbnails` refresh before they answer (issue 100). Each
awaits `lib.fresh()` before reading, so a file added since the cache was
written is included, and a failed refresh prints one line and exits 1 instead
of answering from a stale or empty cache. The README lists them among the
commands that refresh first.
- 2026-09-23: `quak backup` waits for the server refresh and fails when it fails
(issue 99). `lib.backup()` joins a refresh already running or starts one, as
`fresh()` does, and rejects before touching any file when it fails, leaving
`failures.json` as it was, so `quak backup` prints the error as one line and
exits 1 instead of backing up the previous run's file list, or nothing, and
exiting 0.
- 2026-09-23: CLI errors print a message instead of a stack trace (issue 102).
An error a command throws is printed as one `quak: MESSAGE` line on stderr and
the CLI exits 1 once output has drained. The wrapper that does this moved from
`bin/quak.ts` to `src/cli-run.ts`, and `bin/quak.ts` now awaits
`program.parseAsync()`.
- 2026-09-23: Opening a library no longer deletes another process's download in
progress (issue 105). The download writer's temp files are named
`.quak-<pid>-<random>.tmp`, and `removeLeftoverTempFiles`, moved from the
backup into the download module, deletes a `.quak-*.tmp` file only when the
process ID in its name is no longer running. The content cache calls it at
`open()` for `originals/` and `thumbnails/`, the backup as before.
- 2026-09-23: Re-vendored the lint and test setup from the template (issue 96). - 2026-09-23: Re-vendored the lint and test setup from the template (issue 96).
Linting and testing are the `lint` and `test` phases of the `Dockerfile`; Linting and testing are the `lint` and `test` phases of the `Dockerfile`;
`script/lint` and `script/test` each build one with `--no-cache`, and the last `script/lint` and `script/test` each build one with `--no-cache`, and the last
stage compiles and depends on both, so `script/cibuild` is one build. stage compiles and depends on both, so `script/cibuild` is one build.
`Dockerfile.lint`, `CHECK_EPOCH`, `LINT_EPOCH` and the tests that checked them `Dockerfile.lint`, `CHECK_EPOCH`, `LINT_EPOCH` and the tests that checked them
are gone; `REPO_POLICIES.md` is re-copied. are gone; `REPO_POLICIES.md` is re-copied.
- 2026-09-23: Stopped `helper fix-missing-thumbnails` retrying files the server
always refuses (issue 109). Both thumbnail helpers skip a file another account
owns without fetching it. The fixer skips a file whose recorded thumbnail size
is 0 or unknown before downloading it, and otherwise tries smaller encodings
(720 px quality 50 down to 160 px quality 20) until the encrypted thumbnail is
no larger than that size, skipping the file if none fits.
- 2026-09-23: Tested the live-photo hash check's error paths (issue 117). Tests
download a live photo whose ZIP names an unknown compression method, one whose
ZIP has no image entry and one with no video entry, and check that nothing is
stored and the error names the file ID; the unreadable one is not retried.
- 2026-09-23: `quak logout` ends the session on the server (issue 108). It calls
`POST /users/logout` through the new `Client.logoutOnServer()`, then deletes
`session.json` even when that call fails, says so and exits 1. It prints the
account's cache directory and says it still holds decrypted data. The default
cache path is now `defaultCacheDirectory()` in the library, shared with
`Library.open`.
- 2026-09-23: Fixed the backup's per-collection folders (issue 103). Two files - 2026-09-23: Fixed the backup's per-collection folders (issue 103). Two files
in one collection with the same title, and two collections with the same name, in one collection with the same title, and two collections with the same name,
each get their ID added to the name (`IMG_0001 (12345).JPG`, `Trip (10)/`), so each get their ID added to the name (`IMG_0001 (12345).JPG`, `Trip (10)/`), so
@@ -31,6 +122,7 @@ Tag v1.0.0.
`originals/` for files no longer in the collection, and the folders of deleted `originals/` for files no longer in the collection, and the folders of deleted
or renamed collections, leaving anything else in `collections/` alone. The or renamed collections, leaving anything else in `collections/` alone. The
README backup layout states the naming rule. README backup layout states the naming rule.
- 2026-09-23: Checked downloaded originals against their recorded content hash - 2026-09-23: Checked downloaded originals against their recorded content hash
(issue 68). `downloadFile`, which `quak get`, the content cache and backup all (issue 68). `downloadFile`, which `quak get`, the content cache and backup all
use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and
@@ -39,12 +131,14 @@ Tag v1.0.0.
hashed separately as `<imageHash>:<videoHash>`. `decryptFile` reads older hashed separately as `<imageHash>:<videoHash>`. `decryptFile` reads older
clients' `imageHash` and `videoHash` fields for live photos. A file with no clients' `imageHash` and `videoHash` fields for live photos. A file with no
recorded hash is stored unchecked. recorded hash is stored unchecked.
- 2026-09-23: Kept one account's cache from mixing with another's (issue 104). - 2026-09-23: Kept one account's cache from mixing with another's (issue 104).
When `metadata.json` in the cache directory was written for a different, When `metadata.json` in the cache directory was written for a different,
non-zero user ID than the client's, `Library.open` deletes it and `mldata/` non-zero user ID than the client's, `Library.open` deletes it and `mldata/`
and starts empty, so the first refresh enumerates from 0. This only happens and starts empty, so the first refresh enumerates from 0. This only happens
with `--cache-dir` or an explicit `cacheDirectory`; the default path already with `--cache-dir` or an explicit `cacheDirectory`; the default path already
includes the user ID. A test opens one account's cache as another account. includes the user ID. A test opens one account's cache as another account.
- 2026-09-23: `backup-metadata` no longer stops on one failed ML data request - 2026-09-23: `backup-metadata` no longer stops on one failed ML data request
(issue 101). Each request of up to 200 files is tried on its own; a failed one (issue 101). Each request of up to 200 files is tried on its own; a failed one
is logged, its files are written with the reason in `mlDataError`, and the is logged, its files are written with the reason in `mlDataError`, and the
+10 -19
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { stdout, stderr } from "node:process"; import { stdout, stderr } from "node:process";
import { input, password } from "@inquirer/prompts";
import { Command } from "commander"; import { Command } from "commander";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
@@ -18,7 +19,9 @@ import {
listMissingThumbnailsCommand, listMissingThumbnailsCommand,
fixMissingThumbnailsCommand, fixMissingThumbnailsCommand,
} from "../src/cli-commands.js"; } from "../src/cli-commands.js";
import { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js"; import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js"; import { VERSION } from "../src/index.js";
const paths = envPaths("quak", { suffix: "" }); const paths = envPaths("quak", { suffix: "" });
@@ -41,25 +44,13 @@ const context = (): CliContext => ({
sessionDir: paths.data, sessionDir: paths.data,
cacheDir: program.opts<{ cacheDir?: string }>().cacheDir, cacheDir: program.opts<{ cacheDir?: string }>().cacheDir,
loadSession, loadSession,
login: (opts) => Client.login(opts),
prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }),
}); });
// Run a command and exit with its code once stdout/stderr have drained. const run = (command: Promise<number>): Promise<void> =>
// Exiting before the drain can truncate piped output, and the library can keep runCommand(command, stdout, stderr, (code) => process.exit(code));
// the event loop alive after a command returns, so a plain return could hang.
const run = async (command: Promise<number>): Promise<void> => {
process.exitCode = await command;
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
process.exit();
return;
}
let remaining = pending.length;
for (const s of pending) {
s.once("drain", () => {
if (--remaining === 0) process.exit();
});
}
};
program program
.command("login") .command("login")
@@ -73,7 +64,7 @@ program
program program
.command("logout") .command("logout")
.description("Delete the saved session") .description("End the session on the server and delete the saved session")
.action(() => run(logoutCommand(context()))); .action(() => run(logoutCommand(context())));
program program
@@ -169,4 +160,4 @@ helper
); );
await init(); await init();
program.parse(); await program.parseAsync();
+11 -5
View File
@@ -9,17 +9,23 @@
"type": "git", "type": "git",
"url": "https://git.eeqj.de/sneak/quak.git" "url": "https://git.eeqj.de/sneak/quak.git"
}, },
"private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=22"
},
"main": "./dist/src/index.js", "main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts", "types": "./dist/src/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./package.json": "./package.json"
},
"bin": { "bin": {
"quak": "./dist/bin/quak.js" "quak": "./dist/bin/quak.js"
}, },
"files": [
"dist/",
"README.md",
"LICENSE"
],
"scripts": { "scripts": {
"build": "script/build", "build": "script/build",
"quak": "node ./dist/bin/quak.js", "quak": "node ./dist/bin/quak.js",
+107 -80
View File
@@ -1,9 +1,11 @@
// The backup command, rebuilt on the library API (issue #51). // The backup command, rebuilt on the library API (issue #51).
// //
// `lib.backup()` refreshes the library, then, for every file in scope, gets its // `lib.backup()` waits for a completed refresh of the library (a failed one
// original bytes onto disk under `downloadDirectory` and rebuilds the derived // fails the backup before any file is touched), then, for every file in scope,
// views (per-file sidecars, per-collection symlink trees, per-collection JSON) // gets its original bytes onto disk under `downloadDirectory` and rebuilds the
// from the model. The on-disk layout is the historical one, unchanged: // derived views (per-file sidecars, per-collection symlink trees,
// per-collection JSON) from the model. The on-disk layout is the historical
// one:
// //
// <downloadDirectory>/ // <downloadDirectory>/
// originals/<fileID>.<ext> the decrypted bytes // originals/<fileID>.<ext> the decrypted bytes
@@ -12,6 +14,10 @@
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// failures.json durable ledger of unresolved failures // failures.json durable ledger of unresolved failures
// //
// A live photo's original is its image and its video, `<fileID>.<ext>` each
// with its own extension, and `originals/<fileID>.livephoto.json` naming them;
// its album folders link both.
//
// Crash-safety rests on two properties. Bytes are present-means-complete: an // Crash-safety rests on two properties. Bytes are present-means-complete: an
// original appears under `originals/` only via the content layer's atomic // original appears under `originals/` only via the content layer's atomic
// temp-then-rename, so a file that exists is whole and is never re-fetched — an // temp-then-rename, so a file that exists is whole and is never re-fetched — an
@@ -45,8 +51,13 @@ import {
import { copyFile, rename, rm } from "node:fs/promises"; import { copyFile, rename, rm } from "node:fs/promises";
import { basename, dirname, extname, join, relative } from "node:path"; import { basename, dirname, extname, join, relative } from "node:path";
import { fsyncPath } from "./download/index.js"; import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
import { safeExtension, sanitizeFileName } from "./filename.js"; import { sanitizeFileName, withExtension } from "./filename.js";
import {
originalName,
storedOriginal,
writeLivePhoto,
} from "./library/content.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
@@ -95,8 +106,14 @@ export interface BackupLibrary {
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
// Get an original's bytes onto disk through the content cache/pools, // Get an original's bytes onto disk through the content cache/pools,
// returning where they landed (the cache, or a prior backup). // returning where they landed: `destination` when they were fetched now,
original(fileID: number): Promise<{ path: string }>; // otherwise wherever they already were (the cache, or a prior backup). A
// live photo lands as its image and its video, fetched now beside
// `destination`.
original(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>;
} }
@@ -113,12 +130,6 @@ interface FailureEntry {
const LEDGER_VERSION = 1; const LEDGER_VERSION = 1;
// The originals/ filename for a file: `<id><ext>`, the extension taken from the
// title (or `.bin`). Matches the content cache's own naming so a present check
// lines up with what a fetch would write.
const originalName = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`;
// A regular file with content is treated as complete. A zero-byte file is not: // A regular file with content is treated as complete. A zero-byte file is not:
// it is the shape an aborted write leaves and must be re-fetched. // it is the shape an aborted write leaves and must be re-fetched.
const isPresent = (path: string): boolean => { const isPresent = (path: string): boolean => {
@@ -184,34 +195,29 @@ const copyAtomic = async (src: string, dest: string): Promise<void> => {
} }
}; };
// A process-ID check: signal 0 delivers nothing and only reports whether the // Put an original the library returned at `dest` in originals/, where a fresh
// process exists. EPERM means it exists but belongs to another user. // fetch already wrote it. A live photo's image and video go beside `dest`: when
const isRunning = (pid: number): boolean => { // they came from the cache they are copied, after removing whatever was at
try { // `dest` (an earlier version's ZIP of the two). Then the JSON file naming them
process.kill(pid, 0); // is written, which is what makes the live photo count as stored.
return true; const placeOriginal = async (
} catch (err) { file: EnteFile,
return (err as NodeJS.ErrnoException).code === "EPERM"; dest: string,
} got: { path: string; videoPath?: string },
}; ): Promise<void> => {
if (got.videoPath === undefined) {
// Delete the temp files `copyAtomic` leaves behind when a backup is killed await copyAtomic(got.path, dest);
// before its rename. Only files whose process is no longer running are
// removed, so a backup running at the same time keeps its own. A reused
// process ID can only keep a leftover a while longer, never remove a live one.
const removeLeftoverTempFiles = (dir: string): void => {
let names: string[];
try {
names = readdirSync(dir);
} catch {
return; return;
} }
for (const name of names) { const originalsDir = dirname(dest);
const match = /^\.quak-backup-.*-(\d+)-[0-9a-z]*\.tmp$/.exec(name); const path = join(originalsDir, basename(got.path));
if (match && !isRunning(Number(match[1]))) { const videoPath = join(originalsDir, basename(got.videoPath));
rmSync(join(dir, name), { force: true }); if (got.path !== path) {
} await rm(dest, { force: true });
await copyAtomic(got.path, path);
await copyAtomic(got.videoPath, videoPath);
} }
await writeLivePhoto(originalsDir, file.id, { path, videoPath });
}; };
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
@@ -230,43 +236,61 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
symlinkSync(target, linkPath); symlinkSync(target, linkPath);
}; };
// The on-disk names for the entries of one directory, keyed by ID. Each name // The on-disk names for the entries of one directory, in entry order. Each
// is used as is unless another entry would get the same name, ignoring case // name is used as is unless another entry would get the same name, ignoring
// (two names that differ only in case are one entry on a case-insensitive // case (two names that differ only in case are one entry on a case-insensitive
// file system); then every entry sharing it gets ` (<id>)`, before the // file system); then every entry sharing it gets ` (<id>)`, before the
// extension when `beforeExtension` is set. A name with an ID added can match // extension when `beforeExtension` is set. A name with an ID added can match
// another entry's own name (`IMG (6).JPG`), so this repeats until no name is // another entry's own name (`IMG (6).JPG`), so this repeats until no name is
// shared. IDs are stable, so the names are too. // shared. IDs are stable, so the names are too.
const namesByID = ( const uniqueNames = (
entries: { id: number; name: string }[], entries: { id: number; name: string }[],
beforeExtension: boolean, beforeExtension: boolean,
): Map<number, string> => { ): string[] => {
const withID = (id: number, name: string): string => { const withID = (id: number, name: string): string => {
const ext = beforeExtension ? extname(name) : ""; const ext = beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - ext.length); const stem = name.slice(0, name.length - ext.length);
return `${stem} (${id})${ext}`; return `${stem} (${id})${ext}`;
}; };
const names = new Map<number, string>(); const names = entries.map((e) => e.name);
for (const { id, name } of entries) names.set(id, name);
const suffixed = new Set<number>(); const suffixed = new Set<number>();
for (;;) { for (;;) {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const name of names.values()) { for (const name of names) {
const key = name.toLowerCase(); const key = name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
} }
let changed = false; let changed = false;
for (const { id, name } of entries) { for (const [i, { id, name }] of entries.entries()) {
if (suffixed.has(id)) continue; if (suffixed.has(i)) continue;
if (counts.get(name.toLowerCase()) === 1) continue; if (counts.get(name.toLowerCase()) === 1) continue;
names.set(id, withID(id, name)); names[i] = withID(id, name);
suffixed.add(id); suffixed.add(i);
changed = true; changed = true;
} }
if (!changed) return names; if (!changed) return names;
} }
}; };
// The links a file gets in its album's folder: one named after its title, to
// its original if that is stored. A stored live photo gets two, to its image
// and its video, each named after the title with that file's extension.
const linksFor = (
file: EnteFile,
stored: { path: string; videoPath?: string } | undefined,
): { id: number; name: string; file: EnteFile; target?: string }[] => {
const name = sanitizeFileName(file.metadata.title, `file-${file.id}`);
if (stored?.videoPath === undefined) {
return [{ id: file.id, name, file, target: stored?.path }];
}
return [stored.path, stored.videoPath].map((target) => ({
id: file.id,
name: withExtension(name, extname(target)),
file,
target,
}));
};
// Remove the symlinks in the album directory `dir` that point into // Remove the symlinks in the album directory `dir` that point into
// `originalsDir` and are not named in `keep`. Nothing else in the directory // `originalsDir` and are not named in `keep`. Nothing else in the directory
// is touched: anything else there was put there by the user. // is touched: anything else there was put there by the user.
@@ -446,15 +470,21 @@ export const runBackup = async (
// tree; a present file is left as is. // tree; a present file is left as is.
if (includeOriginals) { if (includeOriginals) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
const dest = join(originalsDir, originalName(file)); if (storedOriginal(originalsDir, file) !== undefined) {
if (isPresent(dest)) {
skipped++; skipped++;
continue; continue;
} }
const dest = join(originalsDir, originalName(file));
try { try {
log(`Fetching original ${file.metadata.title} (${fileID})...`); log(`Fetching original ${file.metadata.title} (${fileID})...`);
const { path } = await lib.original(fileID); // A fetched original is written straight to `dest` (a live
await copyAtomic(path, dest); // photo beside it); only one that was already cached elsewhere
// is copied.
await placeOriginal(
file,
dest,
await lib.original(fileID, dest),
);
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
@@ -490,8 +520,7 @@ export const runBackup = async (
// every present original (this repairs stale ones). // every present original (this repairs stale ones).
if (includeOriginals) { if (includeOriginals) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
const orig = join(originalsDir, originalName(file)); if (storedOriginal(originalsDir, file) !== undefined) {
if (isPresent(orig)) {
writeSidecar(join(originalsDir, `${fileID}.json`), file); writeSidecar(join(originalsDir, `${fileID}.json`), file);
} }
} }
@@ -503,19 +532,18 @@ export const runBackup = async (
// an album it skipped. Stale entries are removed before anything is // an album it skipped. Stale entries are removed before anything is
// rebuilt, so on a case-insensitive file system removing an old name can // rebuilt, so on a case-insensitive file system removing an old name can
// never remove the new one. // never remove the new one.
const albumDirNames = namesByID( const dirNames = uniqueNames(
allCollections.map((c) => ({ allCollections.map((c) => ({
id: c.id, id: c.id,
name: sanitizeFileName(c.name, `collection-${c.id}`), name: sanitizeFileName(c.name, `collection-${c.id}`),
})), })),
false, false,
); );
const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]),
);
try { try {
removeStaleAlbumDirs( removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir);
collectionsDir,
new Set(albumDirNames.values()),
originalsDir,
);
} catch (err) { } catch (err) {
log(`FAILED removing old album directories: ${errorMessage(err)}`); log(`FAILED removing old album directories: ${errorMessage(err)}`);
} }
@@ -526,34 +554,33 @@ export const runBackup = async (
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
const files = filesByCollection.get(c.id) ?? []; const files = filesByCollection.get(c.id) ?? [];
const linkNames = namesByID( const links = files.flatMap((f) =>
files.map((f) => ({ linksFor(f, storedOriginal(originalsDir, f)),
id: f.id,
name: sanitizeFileName(f.metadata.title, `file-${f.id}`),
})),
true,
); );
const linkNames = uniqueNames(links, true);
try { try {
removeStaleLinks(colDir, new Set(linkNames.values()), originalsDir); removeStaleLinks(colDir, new Set(linkNames), originalsDir);
} catch (err) { } catch (err) {
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
} }
const metaFiles: { id: number; metadata: EnteFile["metadata"] }[] = []; const metaFiles = files.map((f) => ({
for (const file of files) { id: f.id,
metaFiles.push({ id: file.id, metadata: file.metadata }); metadata: f.metadata,
if (!includeOriginals) continue; }));
const orig = join(originalsDir, originalName(file)); for (const [i, link] of links.entries()) {
if (!isPresent(orig)) continue; if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames.get(file.id)!; const linkName = linkNames[i]!;
const linkPath = join(colDir, linkName);
try { try {
rebuildSymlink(linkPath, relative(colDir, orig)); rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) { } catch (err) {
log( log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
); );
recordFailure(file, c.name, err); recordFailure(link.file, c.name, err);
} }
} }
+98 -25
View File
@@ -2,22 +2,30 @@
// //
// Each command takes its options and a `CliContext` and resolves to the exit // Each command takes its options and a `CliContext` and resolves to the exit
// code; a thrown error is left to the caller. Nothing here calls // code; a thrown error is left to the caller. Nothing here calls
// `process.exit`: `bin/quak.ts` wires these to the command line and exits with // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
// the returned code once output has drained. Output must stay byte-identical // `cli-run.ts` prints a thrown error as one line and exits once output has
// (see `cli-output.ts`). // drained. Output must stay byte-identical (see `cli-output.ts`).
import { input, password as passwordPrompt } from "@inquirer/prompts";
import { import {
copyFileSync, copyFileSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
statSync,
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { extname, join } from "node:path";
import { Client, type ClientSnapshot } from "./client.js"; import {
type Client,
type ClientSnapshot,
type LoginOptions,
} from "./client.js";
import { init } from "./crypto/index.js"; import { init } from "./crypto/index.js";
import { Library, type LibraryClient } from "./library/index.js"; import {
defaultCacheDirectory,
Library,
type LibraryClient,
} from "./library/index.js";
import { import {
fileListRow, fileListRow,
fileListLine, fileListLine,
@@ -25,6 +33,7 @@ import {
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js"; import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
@@ -39,6 +48,12 @@ export interface CliContext {
// Reads the session file into a client, or null when there is none. The // Reads the session file into a client, or null when there is none. The
// CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client. // CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client.
loadSession: (path: string) => Client | null; loadSession: (path: string) => Client | null;
// Used by `login` only. The CLI passes `Client.login` and terminal
// prompts; tests pass fakes.
login: (opts: LoginOptions) => Promise<Client>;
prompt: (message: string) => Promise<string>;
// Like `prompt`, but the answer is masked as it is typed.
promptSecret: (message: string) => Promise<string>;
} }
const sessionPath = (ctx: CliContext): string => const sessionPath = (ctx: CliContext): string =>
@@ -104,24 +119,19 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
precacheOriginals: false, precacheOriginals: false,
}); });
const prompt = async (message: string): Promise<string> => input({ message });
const promptSecret = async (message: string): Promise<string> =>
passwordPrompt({ message, mask: true });
export const loginCommand = async (ctx: CliContext): Promise<number> => { export const loginCommand = async (ctx: CliContext): Promise<number> => {
await init(); await init();
const email = process.env.QUAK_EMAIL ?? (await prompt("Email")); const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
const password = const password =
process.env.QUAK_PASSWORD ?? (await promptSecret("Password")); process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
ctx.stderr.write("Authenticating...\n"); ctx.stderr.write("Authenticating...\n");
try { try {
const client = await Client.login({ const client = await ctx.login({
email, email,
password, password,
totp: async () => prompt("TOTP code: "), totp: async () => ctx.prompt("TOTP code: "),
emailOTP: async () => prompt("Email verification code: "), emailOTP: async () => ctx.prompt("Email verification code: "),
}); });
saveSession(ctx.sessionDir, client.toJSON()); saveSession(ctx.sessionDir, client.toJSON());
@@ -146,14 +156,43 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
return 0; return 0;
}; };
// Ends the session on the server, then deletes the session file even when that
// failed, and exits 1 if it did. The cache is left in place; the user is told
// where it is.
export const logoutCommand = async (ctx: CliContext): Promise<number> => { export const logoutCommand = async (ctx: CliContext): Promise<number> => {
if (existsSync(sessionPath(ctx))) { const path = sessionPath(ctx);
unlinkSync(sessionPath(ctx)); if (!existsSync(path)) {
ctx.stderr.write("Session deleted.\n");
} else {
ctx.stderr.write("No session found.\n"); ctx.stderr.write("No session found.\n");
return 0;
} }
return 0; await init();
let cacheDir = ctx.cacheDir;
let failure: string | undefined;
try {
const client = ctx.loadSession(path);
if (client) {
cacheDir ??= defaultCacheDirectory(client.whoami().userID);
await client.logoutOnServer();
client.logout();
}
} catch (err) {
failure = err instanceof Error ? err.message : String(err);
}
unlinkSync(path);
if (failure === undefined) {
ctx.stderr.write("Session ended on the server.\n");
} else {
ctx.stderr.write(
`Could not end the session on the server: ${failure}\n`,
);
}
ctx.stderr.write("Session deleted.\n");
if (cacheDir !== undefined) {
ctx.stderr.write(
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
);
}
return failure === undefined ? 0 : 1;
}; };
export const collectionsCommand = async ( export const collectionsCommand = async (
@@ -268,8 +307,29 @@ export const getCommand = async (
// Default name is the file's own title, as the pre-library CLI used // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
const outPath = opts.out ?? originalName(file); const outPath = opts.out ?? originalName(file);
copyFileSync(result.path, outPath); if (result.videoPath === undefined) {
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0;
}
// A live photo is written as its image and its video, each named after
// the title with its own extension, as Ente's clients name them. With
// --out, the image goes there and the video beside it.
const imageOut =
opts.out ?? withExtension(outPath, extname(result.path));
const videoOut = withExtension(outPath, extname(result.videoPath));
if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
ctx.stderr.write(
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
);
return 1;
}
copyFileSync(result.path, imageOut);
copyFileSync(result.videoPath, videoOut);
ctx.stderr.write(
`${result.bytes} bytes -> ${imageOut}\n` +
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -323,6 +383,9 @@ export const backupMetadataCommand = async (
if (!client) return 1; if (!client) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so the dump holds current account state, not what the
// cache last held; a failed refresh throws.
await lib.fresh();
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, { const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all, exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"), onProgress: (msg) => ctx.stderr.write(msg + "\n"),
@@ -343,10 +406,14 @@ export const backupCommand = async (
if (!client) return 1; if (!client) return 1;
ctx.stderr.write("Starting backup...\n"); ctx.stderr.write("Starting backup...\n");
// The precache is off: the backup fetches what it needs, and must not
// also fill the cache with every thumbnail and the recent originals.
const lib = await Library.open({ const lib = await Library.open({
client, client,
downloadDirectory: dir, downloadDirectory: dir,
cacheDirectory: ctx.cacheDir, cacheDirectory: ctx.cacheDir,
precacheThumbnails: false,
precacheOriginals: false,
}); });
try { try {
const result = await lib.backup({ const result = await lib.backup({
@@ -389,6 +456,9 @@ export const listMissingThumbnailsCommand = async (
if (!client) return 1; if (!client) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh();
const missing = await listMissingThumbnails(lib, client, (msg) => { const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n"); if (!opts.json) ctx.stderr.write(msg + "\n");
}); });
@@ -424,6 +494,9 @@ export const fixMissingThumbnailsCommand = async (
if (!client) return 1; if (!client) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are found;
// a failed refresh throws.
await lib.fresh();
let fileIDs: number[]; let fileIDs: number[];
if (opts.file && opts.file.length > 0) { if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite); fileIDs = opts.file.map(Number).filter(Number.isFinite);
@@ -462,7 +535,7 @@ export const fixMissingThumbnailsCommand = async (
ctx.stderr.write(` Skipped: ${skipped}\n`); ctx.stderr.write(` Skipped: ${skipped}\n`);
ctx.stderr.write(` Failed: ${failed}\n`); ctx.stderr.write(` Failed: ${failed}\n`);
if (skipped > 0) { if (skipped > 0) {
ctx.stderr.write("\nSkipped (unsupported format):\n"); ctx.stderr.write("\nSkipped:\n");
for (const r of results.filter((r) => r.status === "skipped")) { for (const r of results.filter((r) => r.status === "skipped")) {
ctx.stderr.write( ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`, ` ${r.fileID}\t${r.title}\t${r.reason}\n`,
+36
View File
@@ -0,0 +1,36 @@
// Runs one CLI command for `bin/quak.ts` and exits with its code.
import type { Writable } from "node:stream";
// Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is printed as one `quak: MESSAGE` line, without
// the stack trace, and exits 1.
export const run = async (
command: Promise<number>,
stdout: Writable,
stderr: Writable,
exit: (code: number) => void,
): Promise<void> => {
let code: number;
try {
code = await command;
} catch (err) {
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
}
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
exit(code);
return;
}
let remaining = pending.length;
for (const s of pending) {
s.once("drain", () => {
if (--remaining === 0) exit(code);
});
}
};
+8
View File
@@ -217,6 +217,14 @@ export class Client {
}; };
} }
// Ends this client's session on the server (`POST /users/logout`), so the
// token stops working everywhere, including in any saved copy of it. This
// client is left as it was; call `logout()` to clear it.
async logoutOnServer(): Promise<void> {
this.assertLoggedIn();
await this.api.postJSON("/users/logout", {});
}
// Zeroes the key buffers in place, so any copy of the reference held // Zeroes the key buffers in place, so any copy of the reference held
// elsewhere is wiped too. Every method checks `assertLoggedIn` before // elsewhere is wiped too. Every method checks `assertLoggedIn` before
// touching the keys, so nothing decrypts with the zeroed keys. // touching the keys, so nothing decrypts with the zeroed keys.
+256 -130
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readdirSync, rmSync } from "node:fs";
import { open, rename, rm } from "node:fs/promises"; import { open, rename, rm } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -15,14 +16,18 @@ import {
streamTagFinal, streamTagFinal,
} from "../crypto/index.js"; } from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js"; import { TruncatedStreamError } from "../errors.js";
import { sanitizeFileName } from "../filename.js"; import { safeExtension, sanitizeFileName, withExtension } from "../filename.js";
import { withRetry } from "../retry.js"; import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
export interface DownloadResult { export interface DownloadResult {
// Where the file was written. A live photo is written as two files, its
// image here and its video at `videoPath` (see `decryptLivePhoto`).
path: string; path: string;
// The decrypted length; for a live photo, that of the ZIP it arrives as.
bytesWritten: number; bytesWritten: number;
videoPath?: string;
} }
// Fired as decrypted plaintext accumulates, with the running total of // Fired as decrypted plaintext accumulates, with the running total of
@@ -44,9 +49,9 @@ const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
// new: a body cut short still decrypts and authenticates up to its last whole // new: a body cut short still decrypts and authenticates up to its last whole
// chunk, so the absence of TAG_FINAL is the sole evidence it was cut short, and // chunk, so the absence of TAG_FINAL is the sole evidence it was cut short, and
// this throws rather than let a caller keep a short file. The sink has already // this throws rather than let a caller keep a short file. The sink has already
// seen those chunks by then; the caller (`decryptToTemp`) stages them in a temp // seen those chunks by then; the callers (`decryptToTemp`, `decryptLivePhoto`)
// file that is renamed into place only on a clean return, so a throw leaves // stage them in temp files that are renamed into place only on a clean return,
// nothing on disk. // so a throw leaves nothing on disk.
const streamDecrypt = async ( const streamDecrypt = async (
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
header: Uint8Array, header: Uint8Array,
@@ -180,6 +185,45 @@ export const fsyncPath = async (path: string): Promise<void> => {
} }
}; };
// A process-ID check: signal 0 delivers nothing and only reports whether the
// process exists. EPERM means it exists but belongs to another user.
const isRunning = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
};
// Delete the temp files a killed process left in `dir`: the writer's
// `.quak-<pid>-<random>.tmp` and the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
// longer running are removed, so another process writing into the same
// directory keeps its own. A reused process ID can only keep a leftover a while
// longer, never remove a live one.
export const removeLeftoverTempFiles = (dir: string): void => {
let names: string[];
try {
names = readdirSync(dir);
} catch {
return;
}
for (const name of names) {
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
if (match && !isRunning(Number(match[1]))) {
rmSync(join(dir, name), { force: true });
}
}
};
// A new temp file name in `dir`. The random suffix keeps concurrent downloads
// of the same destination from stepping on each other's temporary file; the
// process ID lets `removeLeftoverTempFiles` tell a leftover from a write in
// progress.
const tempPathIn = (dir: string): string =>
join(dir, `.quak-${process.pid}-${randomBytes(16).toString("hex")}.tmp`);
// Stage a write to `destination` atomically and durably, then rename it into // Stage a write to `destination` atomically and durably, then rename it into
// place. `fill` writes the contents into the open temp file handle — either the // place. `fill` writes the contents into the open temp file handle — either the
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt // whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
@@ -204,9 +248,7 @@ const stageAtomic = async (
fill: (handle: FileHandle) => Promise<void>, fill: (handle: FileHandle) => Promise<void>,
): Promise<void> => { ): Promise<void> => {
const dir = dirname(destination); const dir = dirname(destination);
// The random suffix keeps concurrent downloads of the same destination const tmpPath = tempPathIn(dir);
// from stepping on each other's temporary file.
const tmpPath = join(dir, `.quak-${randomUUID()}.tmp`);
try { try {
const handle = await open(tmpPath, "w"); const handle = await open(tmpPath, "w");
try { try {
@@ -241,81 +283,14 @@ export const writeAtomic = async (
): Promise<void> => ): Promise<void> =>
stageAtomic(destination, (handle) => handle.writeFile(plaintext)); stageAtomic(destination, (handle) => handle.writeFile(plaintext));
// Hashes an original's bytes as they are decrypted, for comparison with the // Refuse an original whose bytes do not hash to what its uploader recorded.
// hash its uploader recorded. // The error is not retried.
interface ContentHasher { const checkHash = (file: EnteFile, actual: string): void => {
update: (plaintext: Uint8Array) => void; if (actual !== file.metadata.hash) {
digest: () => string; throw new Error(
} `download: file ${file.id}: content hash ${actual} does not match the hash its uploader recorded, ${file.metadata.hash}`,
);
const fileHasher = (): ContentHasher => { }
const state = chunkHashInit();
return {
update: (plaintext) => chunkHashUpdate(state, plaintext),
digest: () => chunkHashFinal(state),
};
};
// A live photo is stored as a ZIP of its image and its video, and its recorded
// hash is `<imageHash>:<videoHash>`, each over that part's own bytes. Like the
// upstream client's decoder, this takes the first entries whose names start
// with `image` and `video`.
//
// The ZIP is chosen by its uploader and may expand enormously, so entries are
// hashed as they decompress and never held. fflate's `Unzip` inflates each
// push in one piece, and deflate expands at most about 1000-fold, so the ZIP
// is pushed in 4 KiB slices to keep each decompressed piece near 4 MiB, one
// plaintext chunk. Every entry is started, even one that is not hashed,
// because fflate keeps an unstarted entry's data in memory.
const livePhotoHasher = (fileID: number): ContentHasher => {
const sliceSize = 4096;
const fail = (message: string, cause?: unknown): Error =>
new Error(`download: file ${fileID}: ${message}`, { cause });
const claimed = new Set<string>();
const hashes = new Map<string, string>();
const unzip = new Unzip((entry) => {
const part = ["image", "video"].find((p) => entry.name.startsWith(p));
const target =
part === undefined || claimed.has(part)
? undefined
: { part, state: chunkHashInit() };
if (target !== undefined) claimed.add(target.part);
entry.ondata = (err, data, final) => {
if (err) throw err;
if (target === undefined) return;
chunkHashUpdate(target.state, data);
if (final) hashes.set(target.part, chunkHashFinal(target.state));
};
entry.start();
});
unzip.register(UnzipInflate);
// fflate reports a bad ZIP by throwing, sometimes a TypeError, which the
// retry would take for a network failure; a bad ZIP is never retried.
const push = (data: Uint8Array, final: boolean): void => {
try {
unzip.push(data, final);
} catch (err) {
throw fail("live photo is not a readable ZIP", err);
}
};
return {
update: (plaintext) => {
for (let i = 0; i < plaintext.length; i += sliceSize) {
push(plaintext.subarray(i, i + sliceSize), false);
}
},
digest: () => {
push(new Uint8Array(0), true);
const image = hashes.get("image");
const video = hashes.get("video");
if (image === undefined || video === undefined) {
throw fail(
"live photo ZIP does not hold both an image and a video",
);
}
return `${image}:${video}`;
},
};
}; };
// Decrypt `stream` straight to `destination`, one plaintext chunk at a time, // Decrypt `stream` straight to `destination`, one plaintext chunk at a time,
@@ -326,9 +301,8 @@ const livePhotoHasher = (fileID: number): ContentHasher => {
// Returns the plaintext length written. // Returns the plaintext length written.
// //
// `original` is the file whose original this is (none for a thumbnail, which // `original` is the file whose original this is (none for a thumbnail, which
// has no recorded hash). When its metadata has a hash, the decrypted bytes // has no recorded hash). When its metadata has a hash, the decrypted bytes are
// must match it or nothing is stored. Both a plain file and a live photo's // hashed as they stream and must match it, or nothing is stored.
// parts are hashed as they stream. The mismatch error is not retried.
const decryptToTemp = async ( const decryptToTemp = async (
destination: string, destination: string,
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
@@ -337,44 +311,173 @@ const decryptToTemp = async (
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
original?: EnteFile, original?: EnteFile,
): Promise<number> => { ): Promise<number> => {
const expected = original?.metadata.hash; const hash =
const hasher = original?.metadata.hash === undefined ? undefined : chunkHashInit();
original === undefined || expected === undefined
? undefined
: original.metadata.fileType === "livePhoto"
? livePhotoHasher(original.id)
: fileHasher();
let bytesWritten = 0; let bytesWritten = 0;
await stageAtomic(destination, async (handle) => {
bytesWritten = await streamDecrypt(
stream,
header,
key,
async (plaintext) => {
if (hash !== undefined) chunkHashUpdate(hash, plaintext);
await handle.write(plaintext);
},
onProgress,
);
if (original !== undefined && hash !== undefined) {
checkHash(original, chunkHashFinal(hash));
}
});
return bytesWritten;
};
// One of the two parts of a live photo being unpacked: the ZIP entry whose
// name starts with `kind`, written to its own temp file.
interface LivePhotoPart {
kind: "image" | "video";
tmpPath: string;
handle: FileHandle;
hash: ReturnType<typeof chunkHashInit>;
// Decompressed bytes not yet written.
pending: Uint8Array[];
// The entry's extension, set once all of the entry has been read.
ext?: string;
}
const openPart = async (
kind: "image" | "video",
dir: string,
): Promise<LivePhotoPart> => {
const tmpPath = tempPathIn(dir);
const handle = await open(tmpPath, "w");
return { kind, tmpPath, handle, hash: chunkHashInit(), pending: [] };
};
// A live photo arrives as a ZIP of its image and its video. Ente's clients
// name the entries `image.<ext>` and `video.<ext>`, and like the upstream
// client's decoder this takes the first entries whose names start with `image`
// and `video`. It is written unpacked: each part is named `destination` with
// the extension replaced by its own entry's, and the two must differ ignoring
// case. When the file records a hash, `<imageHash>:<videoHash>` must match it,
// each over that part's own bytes. Only then is whatever was at `destination`
// removed and the image, then the video, renamed into place; on any failure
// neither is stored.
//
// The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held. fflate's `Unzip` inflates each
// push in one piece before `push` returns, and deflate expands at most about
// 1000-fold, so the ZIP is pushed in 4 KiB slices, keeping each decompressed
// piece near 4 MiB, one plaintext chunk, and each piece is written before the
// next slice is pushed. Every entry is started, even one that is not kept,
// because fflate keeps an unstarted entry's data in memory.
const decryptLivePhoto = async (
destination: string,
stream: ReadableStream<Uint8Array>,
header: Uint8Array,
key: Uint8Array,
onProgress: ProgressCallback | undefined,
file: EnteFile,
): Promise<DownloadResult> => {
const sliceSize = 4096;
const dir = dirname(destination);
const fail = (message: string, cause?: unknown): Error =>
new Error(`download: file ${file.id}: ${message}`, { cause });
const parts: LivePhotoPart[] = [];
try { try {
await stageAtomic(destination, async (handle) => { const image = await openPart("image", dir);
bytesWritten = await streamDecrypt( parts.push(image);
stream, const video = await openPart("video", dir);
header, parts.push(video);
key,
async (plaintext) => { const claimed = new Set<LivePhotoPart>();
hasher?.update(plaintext); const unzip = new Unzip((entry) => {
await handle.write(plaintext); const part = parts.find(
}, (p) => !claimed.has(p) && entry.name.startsWith(p.kind),
onProgress,
); );
if (original === undefined || hasher === undefined) return; if (part !== undefined) claimed.add(part);
const actual = hasher.digest(); entry.ondata = (err, data, final) => {
if (actual !== expected) { if (err) throw err;
throw new Error( if (part === undefined) return;
`download: file ${original.id}: content hash ${actual} does not match the hash its uploader recorded, ${expected}`, chunkHashUpdate(part.hash, data);
); part.pending.push(data);
} if (final) part.ext = safeExtension(entry.name);
};
entry.start();
}); });
unzip.register(UnzipInflate);
const push = async (
data: Uint8Array,
final: boolean,
): Promise<void> => {
// fflate reports a bad ZIP by throwing, sometimes a TypeError,
// which the retry would take for a network failure; a bad ZIP is
// never retried.
try {
unzip.push(data, final);
} catch (err) {
throw fail("live photo is not a readable ZIP", err);
}
for (const part of parts) {
for (const piece of part.pending)
await part.handle.write(piece);
part.pending = [];
}
};
const bytesWritten = await streamDecrypt(
stream,
header,
key,
async (plaintext) => {
for (let i = 0; i < plaintext.length; i += sliceSize) {
await push(plaintext.subarray(i, i + sliceSize), false);
}
},
onProgress,
);
await push(new Uint8Array(0), true);
if (image.ext === undefined || video.ext === undefined) {
throw fail(
"live photo ZIP does not hold both an image and a video",
);
}
if (file.metadata.hash !== undefined) {
checkHash(
file,
`${chunkHashFinal(image.hash)}:${chunkHashFinal(video.hash)}`,
);
}
if (image.ext.toLowerCase() === video.ext.toLowerCase()) {
throw fail(
`live photo's image and video have the same extension, ${video.ext}`,
);
}
const path = withExtension(destination, image.ext);
const videoPath = withExtension(destination, video.ext);
for (const part of parts) {
await part.handle.sync();
await part.handle.close();
}
await rm(destination, { force: true });
await rename(image.tmpPath, path);
try {
await rename(video.tmpPath, videoPath);
} catch (err) {
await rm(path, { force: true }).catch(() => undefined);
throw err;
}
await fsyncPath(dir);
return { path, bytesWritten, videoPath };
} catch (err) { } catch (err) {
// Cancel the body so its connection is closed now rather than held // Best-effort cleanup, as in `stageAtomic`.
// until the stream is garbage collected. A backup run carries on past for (const part of parts) {
// a failed file, so without this every failure would hold a socket. await part.handle.close().catch(() => undefined);
// This covers every failure, including a temp file that cannot be await rm(part.tmpPath, { force: true }).catch(() => undefined);
// opened and a header that is rejected before the body is read. }
await stream.cancel(err).catch(() => undefined);
throw err; throw err;
} }
return bytesWritten;
}; };
// Fetch a stream and decrypt it to `destination`, retrying the whole sequence. // Fetch a stream and decrypt it to `destination`, retrying the whole sequence.
@@ -405,19 +508,44 @@ const fetchAndDecrypt = async (
destination: string, destination: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
original?: EnteFile, original?: EnteFile,
): Promise<number> => ): Promise<DownloadResult> =>
withRetry(async () => { withRetry(async () => {
const stream = await openStream(); const stream = await openStream();
return decryptToTemp( try {
destination, if (original?.metadata.fileType === "livePhoto") {
stream, return await decryptLivePhoto(
header, destination,
key, stream,
onProgress, header,
original, key,
); onProgress,
original,
);
}
const bytesWritten = await decryptToTemp(
destination,
stream,
header,
key,
onProgress,
original,
);
return { path: destination, bytesWritten };
} catch (err) {
// Cancel the body so its connection is closed now rather than held
// until the stream is garbage collected. A backup run carries on
// past a failed file, so without this every failure would hold a
// socket. This covers every failure, including a temp file that
// cannot be opened and a header that is rejected before the body
// is read.
await stream.cancel(err).catch(() => undefined);
throw err;
}
}, api.getRetryOptions()); }, api.getRetryOptions());
// Write `file`'s original to `outPath`. A live photo is written as its image
// and its video beside `outPath` instead, and whatever was at `outPath` is
// removed (see `decryptLivePhoto`).
export const downloadFile = async ( export const downloadFile = async (
api: ApiClient, api: ApiClient,
file: EnteFile, file: EnteFile,
@@ -429,7 +557,7 @@ export const downloadFile = async (
const resolvedPath = const resolvedPath =
outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`); outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
const bytesWritten = await fetchAndDecrypt( return fetchAndDecrypt(
api, api,
() => api.getFileStream(file.id, { retry: false }), () => api.getFileStream(file.id, { retry: false }),
header, header,
@@ -438,7 +566,6 @@ export const downloadFile = async (
onProgress, onProgress,
file, file,
); );
return { path: resolvedPath, bytesWritten };
}; };
export const downloadThumbnail = async ( export const downloadThumbnail = async (
@@ -451,7 +578,7 @@ export const downloadThumbnail = async (
outPath ?? outPath ??
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`; `thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
const bytesWritten = await fetchAndDecrypt( return fetchAndDecrypt(
api, api,
() => api.getThumbnailStream(file.id, { retry: false }), () => api.getThumbnailStream(file.id, { retry: false }),
header, header,
@@ -459,5 +586,4 @@ export const downloadThumbnail = async (
resolvedPath, resolvedPath,
onProgress, onProgress,
); );
return { path: resolvedPath, bytesWritten };
}; };
+4
View File
@@ -35,3 +35,7 @@ export const safeExtension = (title: string): string => {
const ext = extname(title); const ext = extname(title);
return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin"; return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin";
}; };
// `name` with its extension, if it has one, replaced by `ext` (".mov").
export const withExtension = (name: string, ext: string): string =>
name.slice(0, name.length - extname(name).length) + ext;
+241 -65
View File
@@ -1,11 +1,13 @@
// The on-disk content and thumbnail cache keyed by fileID (issue #46). // The on-disk content and thumbnail cache keyed by fileID (issue #46).
// //
// Layout under `cacheDirectory`: `originals/<fileID>.<ext>` and // Layout under `cacheDirectory`: `originals/<fileID>.<ext>` and
// `thumbnails/<fileID>.<ext>`, flat directories at 0700 with files at 0600. // `thumbnails/<fileID>.<ext>`, flat directories at 0700 with files at 0600. A
// Content appears only by the streaming atomic writer's rename (the download // live photo's original is two files, its image and its video, with
// layer, #40), so a file that exists is whole — "present means complete". The // `originals/<fileID>.livephoto.json` naming them. Content appears only by the
// directory listing taken at `open()` is the record of what is cached, and the // streaming atomic writer's rename (the download layer, #40), so a file that
// orphan temp files a crashed write may have left are reaped there. // exists is whole — "present means complete". The directory listing taken at
// `open()` is the record of what is cached, and the orphan temp files a crashed
// write may have left are reaped there.
// //
// A fetch goes through the shared request pools (#45): the content pool for // A fetch goes through the shared request pools (#45): the content pool for
// originals, the thumbnail pool for thumbnails. The pool limits concurrency, // originals, the thumbnail pool for thumbnails. The pool limits concurrency,
@@ -22,7 +24,7 @@
// does; thumbnails have none. On top of that this module refuses to record a // does; thumbnails have none. On top of that this module refuses to record a
// stored file that came out empty. // stored file that came out empty.
import { existsSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { import {
chmod, chmod,
mkdir, mkdir,
@@ -32,13 +34,15 @@ import {
statfs, statfs,
utimes, utimes,
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, extname, join } from "node:path"; import { basename, dirname, extname, join } from "node:path";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import { import {
downloadFile, downloadFile,
downloadThumbnail, downloadThumbnail,
type ProgressCallback, type ProgressCallback,
removeLeftoverTempFiles,
writeAtomic,
} from "../download/index.js"; } from "../download/index.js";
import { safeExtension } from "../filename.js"; import { safeExtension } from "../filename.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -46,8 +50,6 @@ import type { Priority, RequestPools } from "./pools.js";
const DIR_MODE = 0o700; const DIR_MODE = 0o700;
const FILE_MODE = 0o600; const FILE_MODE = 0o600;
const TEMP_PREFIX = ".quak-";
const TEMP_SUFFIX = ".tmp";
const GIB = 1024 * 1024 * 1024; const GIB = 1024 * 1024 * 1024;
// Owner ruling (#36): bound the originals cache at 100 GiB, but back off when // Owner ruling (#36): bound the originals cache at 100 GiB, but back off when
// the volume has under 50 GiB free so the cache never crowds the disk. // the volume has under 50 GiB free so the cache never crowds the disk.
@@ -78,6 +80,8 @@ const poolPriorityOf = (priority: ThumbnailPriority): Priority =>
export interface ContentResult { export interface ContentResult {
path: string; path: string;
bytes: number; bytes: number;
// A live photo's video. `path` and `bytes` are then its image's.
videoPath?: string;
} }
// Progress for a single `original`/`thumbnail` call. A present file emits one // Progress for a single `original`/`thumbnail` call. A present file emits one
@@ -128,11 +132,14 @@ export interface ThumbnailsAPI {
// stand-in so the cache logic runs with no crypto and no network. Pool routing, // stand-in so the cache logic runs with no crypto and no network. Pool routing,
// dedup, present-checks and integrity live in the cache, not here. // dedup, present-checks and integrity live in the cache, not here.
export interface ContentSource { export interface ContentSource {
// Writes the original at `destination`. A live photo is written beside it
// as its image and its video instead, and their paths are returned, as
// `downloadFile` does.
original(args: { original(args: {
file: EnteFile; file: EnteFile;
destination: string; destination: string;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
}): Promise<{ bytesWritten: number }>; }): Promise<{ bytesWritten: number; path?: string; videoPath?: string }>;
thumbnail(args: { thumbnail(args: {
file: EnteFile; file: EnteFile;
destination: string; destination: string;
@@ -210,7 +217,9 @@ class AbortDrop extends Error {
} }
} }
const originalName = (file: EnteFile): string => // The originals/ name for a file: `<fileID><ext>`, the extension taken from the
// title (or `.bin`). A backup names its originals the same way.
export const originalName = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`; `${file.id}${safeExtension(file.metadata.title)}`;
// The fileID a cache filename encodes, or undefined when the name is not one // The fileID a cache filename encodes, or undefined when the name is not one
@@ -232,6 +241,73 @@ const fileSize = (path: string): number | undefined => {
} }
}; };
// Whether `path` is a regular file with content. A zero-byte file is the shape
// an aborted write leaves, so it does not count.
const hasContent = (path: string | undefined): boolean =>
path !== undefined && (fileSize(path) ?? 0) > 0;
// A live photo's image and video are named with the extensions from inside its
// ZIP, so their names alone do not say which is which. Wherever the cache or a
// backup stores one, this JSON file beside them names both.
const livePhotoFileName = (fileID: number): string =>
`${fileID}.livephoto.json`;
// The image and video that the live photo's JSON file in `dir` names, or
// undefined when there is none. Only names of the form the cache writes are
// taken, so the file cannot point outside `dir`.
const readLivePhoto = (
dir: string,
fileID: number,
): { path: string; videoPath: string } | undefined => {
const valid = (name: unknown): name is string =>
typeof name === "string" && name === `${fileID}${safeExtension(name)}`;
try {
const { image, video } = JSON.parse(
readFileSync(join(dir, livePhotoFileName(fileID)), "utf-8"),
);
if (valid(image) && valid(video)) {
return { path: join(dir, image), videoPath: join(dir, video) };
}
} catch {
// No such file, or not one the cache wrote.
}
return undefined;
};
// Write the JSON file naming a live photo's image and video, both in `dir`.
export const writeLivePhoto = (
dir: string,
fileID: number,
stored: { path: string; videoPath: string },
): Promise<void> =>
writeAtomic(
join(dir, livePhotoFileName(fileID)),
new TextEncoder().encode(
JSON.stringify({
image: basename(stored.path),
video: basename(stored.videoPath),
}),
),
);
// The original of `file` as the cache or a backup stored it in `dir`, when all
// of it is there: `<fileID><ext>`, or a live photo's image and video.
export const storedOriginal = (
dir: string,
file: EnteFile,
): { path: string; videoPath?: string } | undefined => {
if (file.metadata.fileType !== "livePhoto") {
const path = join(dir, originalName(file));
return hasContent(path) ? { path } : undefined;
}
const stored = readLivePhoto(dir, file.id);
return stored !== undefined &&
hasContent(stored.path) &&
hasContent(stored.videoPath)
? stored
: undefined;
};
export class ContentCache implements PhotoContent, ThumbnailsAPI { export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools; private readonly pools: RequestPools;
private readonly source: ContentSource; private readonly source: ContentSource;
@@ -239,10 +315,17 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly getFile: (fileID: number) => EnteFile | undefined; private readonly getFile: (fileID: number) => EnteFile | undefined;
private readonly originalsDir: string; private readonly originalsDir: string;
private readonly thumbnailsDir: string; private readonly thumbnailsDir: string;
// fileID -> absolute path of the cached bytes, seeded from the directory // fileID -> absolute path of the cached bytes, and for a live photo's
// listing at open() and extended as fetches store new files. // original its video's, seeded from the directory listing at open() and
private readonly originals = new Map<number, string>(); // extended as fetches store new files.
private readonly thumbnails = new Map<number, string>(); private readonly originals = new Map<
number,
{ path: string; videoPath?: string }
>();
private readonly thumbnails = new Map<
number,
{ path: string; videoPath?: string }
>();
private readonly maxOriginalsBytes: number; private readonly maxOriginalsBytes: number;
private readonly freeBelowBytes: number; private readonly freeBelowBytes: number;
private readonly isPinned: (fileID: number) => boolean; private readonly isPinned: (fileID: number) => boolean;
@@ -302,9 +385,9 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
pathsFor(fileID: number): CachedPaths { pathsFor(fileID: number): CachedPaths {
const out: CachedPaths = {}; const out: CachedPaths = {};
const original = this.originals.get(fileID); const original = this.originals.get(fileID);
if (original !== undefined) out.originalPath = original; if (original !== undefined) out.originalPath = original.path;
const thumbnail = this.thumbnails.get(fileID); const thumbnail = this.thumbnails.get(fileID);
if (thumbnail !== undefined) out.thumbnailPath = thumbnail; if (thumbnail !== undefined) out.thumbnailPath = thumbnail.path;
return out; return out;
} }
@@ -322,6 +405,27 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
} }
// Get an original for a backup. One not present anywhere is written
// straight to `destination` and recorded there, so no second copy lands
// in the cache; one already present is returned where it is.
async backupOriginal(
fileID: number,
destination: string,
): Promise<ContentResult> {
const result = await this.acquire(
fileID,
"original",
"on-demand",
undefined,
{ destination },
);
return {
path: result.path,
bytes: result.bytes,
videoPath: result.videoPath,
};
}
async ensure(args: EnsureOptions): Promise<EnsureResult[]> { async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureThumbnails(args); return this.ensureThumbnails(args);
} }
@@ -418,51 +522,72 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
? { status: "skipped", bytes: result.bytes } ? { status: "skipped", bytes: result.bytes }
: { status: "done", bytes: result.bytes }, : { status: "done", bytes: result.bytes },
); );
return { path: result.path, bytes: result.bytes }; return {
path: result.path,
bytes: result.bytes,
videoPath: result.videoPath,
};
} }
// The core: return the cached path if present, else fetch through the pool, // The core: return the cached path if present, else fetch through the pool,
// store, and return it. `cached` distinguishes a present hit (no network, // store, and return it. `cached` distinguishes a present hit (no network,
// no download event) from a fresh fetch. // no download event) from a fresh fetch. A fetched original is stored at
// `opts.destination` when given, instead of in `originalsDir`. A live
// photo's original is present only with its video, and is returned with
// it.
private async acquire( private async acquire(
fileID: number, fileID: number,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
opts?: { onByte?: ProgressCallback }, opts?: { onByte?: ProgressCallback; destination?: string },
): Promise<{ path: string; bytes: number; cached: boolean }> { ): Promise<{
path: string;
bytes: number;
videoPath?: string;
cached: boolean;
}> {
const file = this.getFile(fileID); const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`); if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto";
const known = kind === "original" ? this.originals : this.thumbnails; const known = kind === "original" ? this.originals : this.thumbnails;
const cached = known.get(fileID); const cached = known.get(fileID);
if (cached !== undefined) { if (cached !== undefined) {
const size = fileSize(cached); const size = fileSize(cached.path);
if (size !== undefined && size > 0) { if (
size !== undefined &&
size > 0 &&
(!isLivePhoto || hasContent(cached.videoPath))
) {
// Returning an original's path is a use: bump its mtime so LRU // Returning an original's path is a use: bump its mtime so LRU
// order reflects it and survives a restart with no ledger. // order reflects it and survives a restart with no ledger.
if ( if (
kind === "original" && kind === "original" &&
dirname(cached) === this.originalsDir dirname(cached.path) === this.originalsDir
) )
await this.touch(cached); await this.touch(cached.path);
return { path: cached, bytes: size, cached: true }; return { ...cached, bytes: size, cached: true };
} }
// A recorded file that has since gone re-fetches below. // A recorded file that has since gone, or a live photo an earlier
// version stored as one ZIP, re-fetches below.
known.delete(fileID); known.delete(fileID);
} }
// An original a backup already stored counts as present. // An original a backup already stored counts as present.
if (kind === "original" && this.downloadDirectory !== undefined) { if (kind === "original" && this.downloadDirectory !== undefined) {
const backupPath = join( const stored = storedOriginal(
this.downloadDirectory, join(this.downloadDirectory, "originals"),
"originals", file,
originalName(file),
); );
const size = fileSize(backupPath); if (stored !== undefined) {
if (size !== undefined && size > 0) { this.originals.set(fileID, stored);
this.originals.set(fileID, backupPath); return {
return { path: backupPath, bytes: size, cached: true }; ...stored,
bytes: fileSize(stored.path) ?? 0,
cached: true,
};
} }
} }
@@ -470,7 +595,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
kind === "original" ? this.originalsDir : this.thumbnailsDir; kind === "original" ? this.originalsDir : this.thumbnailsDir;
const dest = const dest =
kind === "original" kind === "original"
? join(dir, originalName(file)) ? (opts?.destination ?? join(dir, originalName(file)))
: join(dir, `${fileID}${THUMBNAIL_EXT}`); : join(dir, `${fileID}${THUMBNAIL_EXT}`);
const pool = const pool =
kind === "original" ? this.pools.content : this.pools.thumbnails; kind === "original" ? this.pools.content : this.pools.thumbnails;
@@ -491,21 +616,39 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
? this.beginOriginalWrite(fileID) ? this.beginOriginalWrite(fileID)
: null; : null;
try { try {
await this.download(file, dest, kind, opts?.onByte); const stored = await this.download(
await chmod(dest, FILE_MODE); file,
const size = (await stat(dest)).size; dest,
if (size === 0) { kind,
throw new Error( opts?.onByte,
`content cache: ${kind} ${fileID} stored empty`, );
); for (const path of [stored.path, stored.videoPath]) {
if (path === undefined) continue;
await chmod(path, FILE_MODE);
if ((await stat(path)).size === 0) {
throw new Error(
`content cache: ${kind} ${fileID} stored empty`,
);
}
} }
known.set(fileID, dest); // A backup records its own live photos.
if (
stored.videoPath !== undefined &&
opts?.destination === undefined
) {
await writeLivePhoto(dir, fileID, {
path: stored.path,
videoPath: stored.videoPath,
});
}
known.set(fileID, stored);
// A fresh original may have crossed the limit; make room by // A fresh original may have crossed the limit; make room by
// evicting least-recently-used originals. An over-budget // evicting least-recently-used originals. An over-budget
// fetch keeps the file it returns, and no overlapping // fetch keeps the file it returns, and no overlapping
// sibling is evicted. Thumbnails are never bounded. // sibling is evicted. Thumbnails are never bounded.
if (write) await this.enforceOriginalsLimit(write); if (write) await this.enforceOriginalsLimit(write);
return { path: dest, bytes: size, cached: false }; const size = (await stat(stored.path)).size;
return { ...stored, bytes: size, cached: false };
} finally { } finally {
if (write) this.inFlightOriginals.delete(write); if (write) this.inFlightOriginals.delete(write);
} }
@@ -514,18 +657,24 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
); );
} }
// Fetch into `destination`, returning where the bytes landed: there, or
// for a live photo, its image and video beside it.
private async download( private async download(
file: EnteFile, file: EnteFile,
destination: string, destination: string,
kind: Kind, kind: Kind,
onProgress: ProgressCallback | undefined, onProgress: ProgressCallback | undefined,
): Promise<number> { ): Promise<{ path: string; videoPath?: string }> {
const args = { file, destination, onProgress }; const args = { file, destination, onProgress };
const result = if (kind === "thumbnail") {
kind === "original" await this.source.thumbnail(args);
? await this.source.original(args) return { path: destination };
: await this.source.thumbnail(args); }
return result.bytesWritten; const result = await this.source.original(args);
return {
path: result.path ?? destination,
videoPath: result.videoPath,
};
} }
// Best-effort bump of a file's mtime to now; a failed touch must never fail // Best-effort bump of a file's mtime to now; a failed touch must never fail
@@ -536,13 +685,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
} }
// Every stored original that lives under `originalsDir` (a backup-directory // Every stored original that lives under `originalsDir` (a backup-directory
// hit recorded in the map is excluded), with its size and mtime. Entries // hit recorded in the map is excluded), with its size and mtime; a live
// whose file has vanished are dropped from the map. Backups and thumbnails // photo's size includes its video. Entries whose file has vanished are
// are never counted. // dropped from the map. Backups and thumbnails are never counted.
private async measureOriginals(): Promise<{ private async measureOriginals(): Promise<{
entries: { entries: {
fileID: number; fileID: number;
path: string; path: string;
videoPath?: string;
size: number; size: number;
mtimeMs: number; mtimeMs: number;
}[]; }[];
@@ -551,21 +701,28 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const entries: { const entries: {
fileID: number; fileID: number;
path: string; path: string;
videoPath?: string;
size: number; size: number;
mtimeMs: number; mtimeMs: number;
}[] = []; }[] = [];
let used = 0; let used = 0;
for (const [fileID, path] of this.originals) { for (const [fileID, { path, videoPath }] of this.originals) {
if (dirname(path) !== this.originalsDir) continue; if (dirname(path) !== this.originalsDir) continue;
try { try {
const s = await stat(path); const s = await stat(path);
const size =
s.size +
(videoPath === undefined
? 0
: (await stat(videoPath)).size);
entries.push({ entries.push({
fileID, fileID,
path, path,
size: s.size, videoPath,
size,
mtimeMs: s.mtimeMs, mtimeMs: s.mtimeMs,
}); });
used += s.size; used += size;
} catch { } catch {
this.originals.delete(fileID); this.originals.delete(fileID);
} }
@@ -629,6 +786,18 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
for (const e of evictable) { for (const e of evictable) {
if (remaining <= limit) break; if (remaining <= limit) break;
await rm(e.path, { force: true }); await rm(e.path, { force: true });
// A live photo goes whole: its video and the JSON file
// naming the two go with its image.
if (e.videoPath !== undefined) {
await rm(e.videoPath, { force: true });
await rm(
join(
this.originalsDir,
livePhotoFileName(e.fileID),
),
{ force: true },
);
}
this.originals.delete(e.fileID); this.originals.delete(e.fileID);
remaining -= e.size; remaining -= e.size;
} }
@@ -653,23 +822,30 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await chmod(dir, DIR_MODE); await chmod(dir, DIR_MODE);
} }
private async scan(dir: string, into: Map<number, string>): Promise<void> { private async scan(
dir: string,
into: Map<number, { path: string; videoPath?: string }>,
): Promise<void> {
// Another process sharing this cache may still be writing its temp
// files, so only those whose process has exited are removed.
removeLeftoverTempFiles(dir);
let entries: string[]; let entries: string[];
try { try {
entries = await readdir(dir); entries = await readdir(dir);
} catch { } catch {
return; return;
} }
const names = new Set(entries);
for (const name of entries) { for (const name of entries) {
if (name.startsWith(TEMP_PREFIX) && name.endsWith(TEMP_SUFFIX)) {
await rm(join(dir, name), { force: true }).catch(
() => undefined,
);
continue;
}
const id = fileIDFromName(name); const id = fileIDFromName(name);
const path = join(dir, name); const path = join(dir, name);
if (id !== undefined && existsSync(path)) into.set(id, path); if (id === undefined || !existsSync(path)) continue;
// A live photo's image and video are one entry, as the JSON file
// beside them names them.
const livePhoto = names.has(livePhotoFileName(id))
? readLivePhoto(dir, id)
: undefined;
into.set(id, livePhoto ?? { path });
} }
} }
} }
+16 -9
View File
@@ -98,6 +98,11 @@ export {
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3; export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
// The account's cache directory when `cacheDirectory` is not given: the
// env-paths cache directory plus the user id, so each account has its own.
export const defaultCacheDirectory = (userID: number): string =>
join(envPaths("quak", { suffix: "" }).cache, String(userID));
// Project a metadata store into by-id records, filling each record's cache // Project a metadata store into by-id records, filling each record's cache
// paths from the content cache when one is given. Shared by the live read // paths from the content cache when one is given. Shared by the live read
// projection and the precache's initial seeding at open(). // projection and the precache's initial seeding at open().
@@ -344,8 +349,7 @@ export class Library {
static async open(opts: LibraryOptions): Promise<Library> { static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami(); const { userID } = opts.client.whoami();
const cacheDirectory = const cacheDirectory =
opts.cacheDirectory ?? opts.cacheDirectory ?? defaultCacheDirectory(userID);
join(envPaths("quak", { suffix: "" }).cache, String(userID));
const metadataPath = join(cacheDirectory, "metadata.json"); const metadataPath = join(cacheDirectory, "metadata.json");
let store = await MetadataStore.load(metadataPath); let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache. // A cache directory given explicitly can hold another account's cache.
@@ -534,11 +538,13 @@ export class Library {
} }
// Back up every in-scope file to `downloadDirectory` in the historical // Back up every in-scope file to `downloadDirectory` in the historical
// on-disk layout, with a durable failure ledger (issue #51). Refreshes // on-disk layout, with a durable failure ledger (issue #51). Waits for a
// first, fetches pending originals (and optional thumbnails) through the // completed refresh first, as `fresh()` does, joining one already running,
// content cache and pools, then rebuilds the derived symlink/JSON views // and rejects before touching any file when it fails. Then fetches pending
// from the model. Throws before any network work when no download directory // originals (and optional thumbnails) through the content cache and pools,
// is available or no content cache backs the originals it must fetch. // and rebuilds the derived symlink/JSON views from the model. Throws before
// any network work when no download directory is available or no content
// cache backs the originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
const downloadDirectory = const downloadDirectory =
opts?.downloadDirectory ?? this.downloadDirectory; opts?.downloadDirectory ?? this.downloadDirectory;
@@ -562,10 +568,11 @@ export class Library {
const cache = this.cache; const cache = this.cache;
return runBackup( return runBackup(
{ {
refresh: () => this.runRefresh(), refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(), listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id), listFiles: (id) => this.store.listFiles(id),
original: (fileID) => cache!.original(fileID), original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
}, },
{ ...opts, downloadDirectory }, { ...opts, downloadDirectory },
+2 -1
View File
@@ -80,7 +80,8 @@ export class Photo {
} }
// Fetch and cache the full-resolution original, returning its on-disk path // Fetch and cache the full-resolution original, returning its on-disk path
// and byte length. Served from the cache (or the backup download directory) // and byte length; for a live photo, its image's, and its video's path as
// `videoPath`. Served from the cache (or the backup download directory)
// when already present, otherwise fetched through the content pool. // when already present, otherwise fetched through the content pool.
async original(opts?: ContentOptions): Promise<ContentResult> { async original(opts?: ContentOptions): Promise<ContentResult> {
return this.contentOrThrow().original(this.rec.fileID, opts); return this.contentOrThrow().original(this.rec.fileID, opts);
+1
View File
@@ -42,6 +42,7 @@ export interface PhotoRecord {
isArchived: boolean; isArchived: boolean;
isHidden: boolean; isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here. // Local cache paths, set once a later phase caches the bytes; unset here.
// A live photo's `originalPath` is its image.
thumbnailPath?: string; thumbnailPath?: string;
originalPath?: string; originalPath?: string;
} }
+4 -3
View File
@@ -128,7 +128,8 @@ export const extractImageMetadata = (
// Read a file's original bytes through the library's content cache and extract // Read a file's original bytes through the library's content cache and extract
// its embedded image metadata. The bytes come from `photo.original()` — the // its embedded image metadata. The bytes come from `photo.original()` — the
// same on-disk cache the rest of the library fills — rather than a fresh // same on-disk cache the rest of the library fills — rather than a fresh
// per-call download to a throwaway temp file. // per-call download to a throwaway temp file. For a live photo, its `path` is
// the image.
const extractExif = async ( const extractExif = async (
photo: Photo, photo: Photo,
): Promise<Record<string, unknown> | undefined> => { ): Promise<Record<string, unknown> | undefined> => {
@@ -140,8 +141,8 @@ const extractExif = async (
// Dump every decrypted metadata layer the account holds into a directory tree // Dump every decrypted metadata layer the account holds into a directory tree
// of plain JSON: account, per-collection, and per-file records including the // of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and (by default) the ML data. Collections // private and public magic metadata and (by default) the ML data. Collections
// and files are enumerated from the library's cache rather than a fresh server // and files are enumerated from the library's cache, which the caller refreshes
// scan. Returns how many ML data requests failed; their files are still // first. Returns how many ML data requests failed; their files are still
// written, with `mlDataError` in place of `mlData`. // written, with `mlDataError` in place of `mlData`.
export const runMetadataBackup = async ( export const runMetadataBackup = async (
lib: Library, lib: Library,
+88 -30
View File
@@ -7,8 +7,21 @@ import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js"; import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
const THUMB_MAX_DIMENSION = 720; // The server refuses a thumbnail larger than the one it already records for the
const THUMB_JPEG_QUALITY = 50; // file (`thumbnail.size`, the encrypted size), so these encodings are tried
// from largest to smallest and the first that fits is uploaded.
const THUMB_ENCODINGS = [
{ maxDimension: 720, quality: 50 },
{ maxDimension: 720, quality: 30 },
{ maxDimension: 480, quality: 30 },
{ maxDimension: 320, quality: 20 },
{ maxDimension: 160, quality: 20 },
];
// The server accepts a new thumbnail only from the file's owner, so files other
// people own in albums shared with this account are never checked or repaired.
const NOT_OWNED_REASON =
"owned by another account (only the owner can replace its thumbnail)";
export interface MissingThumbnailInfo { export interface MissingThumbnailInfo {
fileID: number; fileID: number;
@@ -19,11 +32,12 @@ export interface MissingThumbnailInfo {
// Three outcomes, not two. "fixed": a thumbnail was generated and uploaded. // Three outcomes, not two. "fixed": a thumbnail was generated and uploaded.
// "failed": something went wrong (download, encode, upload) and the file still // "failed": something went wrong (download, encode, upload) and the file still
// has no thumbnail. "skipped": the file is a format this helper cannot // has no thumbnail. "skipped": the server would refuse any thumbnail for the
// regenerate — a video, or an image that is not a baseline JPEG. Skipped is a // file or this helper cannot regenerate it — a file another account owns, a
// deliberate, expected outcome, not an error (issue #17): the repair path is // recorded thumbnail size nothing fits within, a video, or an image that is
// JPEG-only because `jpeg-js` is, and a PNG or HEIC is left for a format-aware // not a baseline JPEG. Skipped is a deliberate, expected outcome, not an error
// tool rather than reported as a failure. // (issue #17): the repair path is JPEG-only because `jpeg-js` is, and a PNG or
// HEIC is left for a format-aware tool rather than reported as a failure.
export type ThumbnailFixStatus = "fixed" | "skipped" | "failed"; export type ThumbnailFixStatus = "fixed" | "skipped" | "failed";
export interface ThumbnailFixResult { export interface ThumbnailFixResult {
@@ -45,6 +59,7 @@ export type ProgressCallback = (message: string) => void;
// exists, so it is logged and the file is left unreported. That distinction is // exists, so it is logged and the file is left unreported. That distinction is
// what stops `fix-missing-thumbnails` from regenerating and uploading over // what stops `fix-missing-thumbnails` from regenerating and uploading over
// thumbnails that were fine all along while the CDN was briefly returning 500s. // thumbnails that were fine all along while the CDN was briefly returning 500s.
// Files another account owns are logged as skipped and not checked.
export const listMissingThumbnails = async ( export const listMissingThumbnails = async (
lib: Library, lib: Library,
client: Client, client: Client,
@@ -52,6 +67,7 @@ export const listMissingThumbnails = async (
): Promise<MissingThumbnailInfo[]> => { ): Promise<MissingThumbnailInfo[]> => {
const log = onProgress ?? (() => {}); const log = onProgress ?? (() => {});
const api = client.getApiClient(); const api = client.getApiClient();
const { userID } = client.whoami();
const missing: MissingThumbnailInfo[] = []; const missing: MissingThumbnailInfo[] = [];
const seen = new Set<number>(); const seen = new Set<number>();
@@ -60,6 +76,13 @@ export const listMissingThumbnails = async (
for (const photo of album.photos.list()) { for (const photo of album.photos.list()) {
if (seen.has(photo.fileID)) continue; if (seen.has(photo.fileID)) continue;
seen.add(photo.fileID); seen.add(photo.fileID);
const file = lib.getFile(album.collectionID, photo.fileID);
if (file && file.ownerID !== userID) {
log(
`[${album.name}] Skipping ${photo.title}: ${NOT_OWNED_REASON}`,
);
continue;
}
try { try {
const stream = await api.getThumbnailStream(photo.fileID); const stream = await api.getThumbnailStream(photo.fileID);
const reader = stream.getReader(); const reader = stream.getReader();
@@ -135,17 +158,13 @@ const resizeRGBA = (
return dst; return dst;
}; };
const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => { const generateThumbnail = (
const decoded = jpeg.decode(fileBytes, { decoded: { data: Uint8Array; width: number; height: number },
useTArray: true, maxDimension: number,
formatAsRGBA: true, quality: number,
}); ): Uint8Array => {
const { width: srcW, height: srcH } = decoded; const { width: srcW, height: srcH } = decoded;
const scale = Math.min( const scale = Math.min(maxDimension / srcW, maxDimension / srcH, 1);
THUMB_MAX_DIMENSION / srcW,
THUMB_MAX_DIMENSION / srcH,
1,
);
const dstW = Math.round(srcW * scale); const dstW = Math.round(srcW * scale);
const dstH = Math.round(srcH * scale); const dstH = Math.round(srcH * scale);
@@ -158,7 +177,7 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
const encoded = jpeg.encode( const encoded = jpeg.encode(
{ data: pixels, width: dstW, height: dstH }, { data: pixels, width: dstW, height: dstH },
THUMB_JPEG_QUALITY, quality,
); );
return new Uint8Array(encoded.data); return new Uint8Array(encoded.data);
}; };
@@ -171,14 +190,35 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
const isJpeg = (bytes: Uint8Array): boolean => const isJpeg = (bytes: Uint8Array): boolean =>
bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8; bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8;
// The reason a file cannot have a JPEG thumbnail regenerated for it from its // The reason a file cannot have a JPEG thumbnail regenerated for it, known from
// metadata alone, before any bytes are fetched, or undefined when it might. A // its record alone before any bytes are fetched, or undefined when it might. A
// non-image (video, live photo) is unsupported outright; a still image still // still image still has to be checked against its actual bytes once
// has to be checked against its actual bytes once downloaded. // downloaded.
const unsupportedByType = (file: EnteFile): string | undefined => { const reasonToSkip = (file: EnteFile, userID: number): string | undefined => {
if (file.ownerID !== userID) {
return NOT_OWNED_REASON;
}
if (file.metadata.fileType !== "image") { if (file.metadata.fileType !== "image") {
return `unsupported file type: ${file.metadata.fileType} (only JPEG images can be regenerated)`; return `unsupported file type: ${file.metadata.fileType} (only JPEG images can be regenerated)`;
} }
if (!file.thumbnail.size) {
return `recorded thumbnail size is ${file.thumbnail.size ?? "unknown"} (the server refuses a thumbnail larger than the one it records)`;
}
return undefined;
};
// Encrypt the largest encoding of the decoded image whose ciphertext is no
// larger than `maxSize`, or return undefined when even the smallest is larger.
const encryptThumbnailWithin = (
decoded: { data: Uint8Array; width: number; height: number },
key: Uint8Array,
maxSize: number,
): { header: Uint8Array; ciphertext: Uint8Array } | undefined => {
for (const { maxDimension, quality } of THUMB_ENCODINGS) {
const thumbJpeg = generateThumbnail(decoded, maxDimension, quality);
const encrypted = encryptBlob(thumbJpeg, key);
if (encrypted.ciphertext.length <= maxSize) return encrypted;
}
return undefined; return undefined;
}; };
@@ -197,6 +237,7 @@ export const fixMissingThumbnails = async (
const log = onProgress ?? (() => {}); const log = onProgress ?? (() => {});
const results: ThumbnailFixResult[] = []; const results: ThumbnailFixResult[] = [];
const api = client.getApiClient(); const api = client.getApiClient();
const { userID } = client.whoami();
// Resolve each requested fileID to its file record and owning album by // Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds // enumerating the library, each file taken from the first album that holds
@@ -237,18 +278,19 @@ export const fixMissingThumbnails = async (
const { file, collectionName } = entry; const { file, collectionName } = entry;
const title = file.metadata.title; const title = file.metadata.title;
const typeReason = unsupportedByType(file); const skipReason = reasonToSkip(file, userID);
if (typeReason) { if (skipReason) {
log(`[${collectionName}] Skipping ${title}: ${typeReason}`); log(`[${collectionName}] Skipping ${title}: ${skipReason}`);
results.push({ results.push({
fileID, fileID,
title, title,
collection: collectionName, collection: collectionName,
status: "skipped", status: "skipped",
reason: typeReason, reason: skipReason,
}); });
continue; continue;
} }
const maxSize = file.thumbnail.size!;
try { try {
const photo = lib.photos.byID({ fileID }); const photo = lib.photos.byID({ fileID });
@@ -277,12 +319,28 @@ export const fixMissingThumbnails = async (
} }
log(`[${collectionName}] Generating thumbnail for ${title}...`); log(`[${collectionName}] Generating thumbnail for ${title}...`);
const thumbJpeg = generateThumbnail(fileBytes); const decoded = jpeg.decode(fileBytes, {
useTArray: true,
formatAsRGBA: true,
});
const fitting = encryptThumbnailWithin(decoded, file.key, maxSize);
if (!fitting) {
const reason = `no thumbnail encoding fits the recorded thumbnail size of ${maxSize} bytes`;
log(`[${collectionName}] Skipping ${title}: ${reason}`);
results.push({
fileID,
title,
collection: collectionName,
status: "skipped",
reason,
});
continue;
}
const { header, ciphertext } = fitting;
log( log(
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`, `[${collectionName}] Uploading thumbnail (${ciphertext.length} bytes)...`,
); );
const { header, ciphertext } = encryptBlob(thumbJpeg, file.key);
const md5 = createHash("md5").update(ciphertext).digest("base64"); const md5 = createHash("md5").update(ciphertext).digest("base64");
const { objectKey, url } = await api.getUploadURL( const { objectKey, url } = await api.getUploadURL(
ciphertext.length, ciphertext.length,
+332 -23
View File
@@ -53,6 +53,13 @@ import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
// `open` and `rename` are wrapped to record, in order, every fsync and rename, // `open` and `rename` are wrapped to record, in order, every fsync and rename,
// so a test can pin the sequence "fsync the temp file, rename, fsync the // so a test can pin the sequence "fsync the temp file, rename, fsync the
@@ -562,11 +569,39 @@ describe("lib.backup", () => {
lib.close(); lib.close();
}); });
it("fsyncs a copied original before the rename and its directory after", async () => { it("fetches each original once and writes it only into the backup", async () => {
// A backup of a 500 GB account must write 500 GB, not a copy in the
// cache as well: an original fetched for the backup goes straight
// into its originals/, and the cache records it there.
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.downloaded).toBe(3);
expect(source.originalCalls).toBe(3);
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
const stored = readdirSync(join(outDir, "originals")).filter(
(name) => !name.endsWith(".json"),
);
expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]);
// The cache counts the backup's copy as present: reading the
// original afterwards fetches nothing and answers with that copy.
const read = await lib.photos.byID({ fileID: 100 })!.original();
expect(read.path).toBe(join(outDir, "originals", "100.jpg"));
expect(source.originalCalls).toBe(3);
await lib.close();
});
it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
const lib = await openLibrary(stubSource()); const lib = await openLibrary(stubSource());
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const originals = join(outDir, "originals"); const originals = join(outDir, "originals");
const dest = join(originals, "100.jpg"); const dest = join(originals, "100.jpg");
// Only an original already in the cache is copied into the backup;
// one fetched for the backup is written there by the download writer.
await lib.photos.byID({ fileID: 100 })!.original();
fsEvents.length = 0; fsEvents.length = 0;
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
@@ -625,6 +660,115 @@ describe("lib.backup", () => {
}); });
}); });
// Every refresh fails, as with an expired session or no network.
class FailingClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
throw new Error("HTTP 401 from server");
}
}
// Holds its refresh open until `release()` is called, then reports a third
// album, so a backup can be started while that refresh is still running.
class HeldClient extends MockClient {
release!: () => void;
private held = new Promise<void>((resolve) => {
this.release = resolve;
});
override async collectionsSince(): Promise<CollectionsPage> {
await this.held;
return {
collections: [collection(3, "Later")],
deleted: [],
cursor: 2,
};
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
if (args.collectionID !== 3) return super.filesSince(args);
return { files: [file(300, 3, "late.jpg")], deleted: [], cursor: 2 };
}
}
// Fill the library cache on disk, so the next open starts its refresh in the
// background instead of waiting for it.
const fillCache = async (): Promise<void> => {
const lib = await openLibrary(stubSource());
await lib.close();
};
describe("the refresh before a backup", () => {
it("waits for a refresh already running and backs up what it found", async () => {
await fillCache();
const client = new HeldClient();
const lib = await openLibrary(stubSource(), client);
const outDir = join(root, "backup");
const backup = lib.backup({ downloadDirectory: outDir });
client.release();
const result = await backup;
expect(result.totalFiles).toBe(4);
expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true);
await lib.close();
});
it("fails before any download when the refresh fails, leaving failures.json as it was", async () => {
await fillCache();
const outDir = join(root, "backup");
seedLedger(outDir, 100, "beach.jpg");
const ledgerPath = join(outDir, "failures.json");
const ledgerBefore = readFileSync(ledgerPath, "utf-8");
const source = stubSource();
const lib = await openLibrary(source, new FailingClient());
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
"HTTP 401 from server",
);
expect(source.originalCalls).toBe(0);
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
expect(existsSync(join(outDir, "originals"))).toBe(false);
await lib.close();
});
it("fails when the refresh fails on an empty cache, instead of backing up nothing", async () => {
const source = stubSource();
const lib = await openLibrary(source, new FailingClient());
const outDir = join(root, "backup");
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
"HTTP 401 from server",
);
expect(source.originalCalls).toBe(0);
expect(existsSync(outDir)).toBe(false);
await lib.close();
});
});
// Every entry under collections/, one level of directories deep, with each
// symlink's target.
const tree = (outDir: string): string[] => {
const lines: string[] = [];
const list = (dir: string, prefix: string): void => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isSymbolicLink()) {
lines.push(`${prefix}${name} -> ${readlinkSync(path)}`);
} else if (st.isDirectory() && prefix === "") {
lines.push(`${name}/`);
list(path, `${name}/`);
} else {
lines.push(`${prefix}${name}`);
}
}
};
list(join(outDir, "collections"), "");
return lines;
};
// The album folders under collections/, driven through `runBackup` with a // The album folders under collections/, driven through `runBackup` with a
// stand-in library whose albums a test changes between runs. // stand-in library whose albums a test changes between runs.
describe("backup album folders", () => { describe("backup album folders", () => {
@@ -648,28 +792,6 @@ describe("backup album folders", () => {
}, },
}); });
// Every entry under collections/, one level of directories deep, with each
// symlink's target.
const tree = (outDir: string): string[] => {
const lines: string[] = [];
const list = (dir: string, prefix: string): void => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isSymbolicLink()) {
lines.push(`${prefix}${name} -> ${readlinkSync(path)}`);
} else if (st.isDirectory() && prefix === "") {
lines.push(`${name}/`);
list(path, `${name}/`);
} else {
lines.push(`${prefix}${name}`);
}
}
};
list(join(outDir, "collections"), "");
return lines;
};
const albumID = (outDir: string, jsonName: string): number => const albumID = (outDir: string, jsonName: string): number =>
JSON.parse(readFileSync(join(outDir, "collections", jsonName), "utf-8")) JSON.parse(readFileSync(join(outDir, "collections", jsonName), "utf-8"))
.id; .id;
@@ -939,3 +1061,190 @@ describe("backup album folders", () => {
).toBe(json); ).toBe(json);
}); });
}); });
// A live photo, which Ente stores as one ZIP, is backed up as its image and
// its video, which a photo viewer can open, beside a JSON file naming them,
// and its album folder links both. These tests download a live photo ZIP
// through the real download layer (test/live-photo.ts).
describe("backup of live photos", () => {
// An account of one album, Trip (10), holding `files`.
class TripClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async collectionsSince(): Promise<CollectionsPage> {
return {
collections: [collection(10, "Trip")],
deleted: [],
cursor: 1,
};
}
override async filesSince(): Promise<FilesPage> {
return { files: this.files, deleted: [], cursor: 1 };
}
}
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
openLibrary(cdnSource(bodies), new TripClient(files));
// What an earlier version stored for live photo 500: the ZIP under the
// image's name, and its link.
const earlierZIP = (outDir: string): void => {
mkdirSync(join(outDir, "originals"), { recursive: true });
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip());
symlinkSync(
"../../originals/500.HEIC",
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
);
};
const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"];
const linked = [
"Trip/",
"Trip/IMG_0500.heic -> ../../originals/500.heic",
"Trip/IMG_0500.mov -> ../../originals/500.mov",
"Trip.json",
];
it("stores a live photo as its image and its video and links both", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
const originals = join(outDir, "originals");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(originals).sort()).toEqual(stored);
expect(readFileSync(join(originals, "500.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(originals, "500.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(tree(outDir)).toEqual(linked);
// Both parts are there, so the next run fetches nothing.
const second = await lib.backup({ downloadDirectory: outDir });
expect(second).toMatchObject({ downloaded: 0, skipped: 1, failed: 0 });
await lib.close();
});
it("gives both links of each live photo their own names when titles clash", async () => {
const a = await asLivePhoto(file(500, 10, "IMG_0001.HEIC"));
const b = await asLivePhoto(file(501, 10, "IMG_0001.HEIC"));
const lib = await open(
[a.file, b.file],
new Map([
[500, a.body],
[501, b.body],
]),
);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(tree(outDir)).toEqual([
"Trip/",
"Trip/IMG_0001 (500).heic -> ../../originals/500.heic",
"Trip/IMG_0001 (500).mov -> ../../originals/500.mov",
"Trip/IMG_0001 (501).heic -> ../../originals/501.heic",
"Trip/IMG_0001 (501).mov -> ../../originals/501.mov",
"Trip.json",
]);
await lib.close();
});
it("replaces the ZIP an earlier version stored, and its link", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
livePhotoZip(),
"not:the recorded hash",
);
const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]);
expect(tree(outDir)).toEqual([
"Trip/",
"Trip/IMG_0500.HEIC -> ../../originals/500.HEIC",
"Trip.json",
]);
await lib.close();
});
it("copies both parts of a live photo the cache already holds", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const cached = await lib.photos.byID({ fileID: 500 })!.original();
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(tree(outDir)).toEqual(linked);
// The cache keeps its own copy.
expect(existsSync(cached.videoPath!)).toBe(true);
await lib.close();
});
it("serves a live photo the backup stored to a library reading the backup", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
await lib.close();
// Another cache over the same backup, whose server has nothing.
const reader = await Library.open({
client: new TripClient([live]),
cacheDirectory: join(root, "other-cache"),
downloadDirectory: outDir,
contentSource: cdnSource(new Map()),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const read = await reader.photos.byID({ fileID: 500 })!.original();
expect(read).toEqual({
path: join(outDir, "originals", "500.heic"),
videoPath: join(outDir, "originals", "500.mov"),
bytes: IMAGE.length,
});
await reader.close();
});
});
+483 -24
View File
@@ -12,7 +12,9 @@
import { import {
existsSync, existsSync,
mkdirSync,
mkdtempSync, mkdtempSync,
readdirSync,
readFileSync, readFileSync,
rmSync, rmSync,
statSync, statSync,
@@ -20,11 +22,22 @@ import {
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { PassThrough } from "node:stream";
import * as jpegJs from "jpeg-js";
import {
describe,
it,
expect,
vi,
beforeAll,
beforeEach,
afterEach,
} from "vitest";
import { import {
type CliContext, type CliContext,
saveSession, saveSession,
loginCommand,
whoamiCommand, whoamiCommand,
logoutCommand, logoutCommand,
collectionsCommand, collectionsCommand,
@@ -32,13 +45,25 @@ import {
getCommand, getCommand,
getThumbCommand, getThumbCommand,
backupCommand, backupCommand,
backupMetadataCommand,
listMissingThumbnailsCommand, listMissingThumbnailsCommand,
fixMissingThumbnailsCommand,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot } from "../../src/client.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { init } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 42; const USER_ID = 42;
@@ -81,8 +106,26 @@ const FILES: Record<number, EnteFile[]> = {
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's // An original is 7 bytes and a thumbnail 3. `failID` makes that file's
// original fail; `emptyThumbID` makes the server report that file's // original fail; `emptyThumbID` makes the server report that file's
// thumbnail as empty. // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => { // the collection's updationTime as the server does, and `refreshError` makes
// listing collections fail with that message.
const fakeClient = (
opts: {
failID?: number;
emptyThumbID?: number;
withNewFile?: boolean;
refreshError?: string;
} = {},
) => {
const collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS;
const files = opts.withNewFile
? {
...FILES,
1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
}
: FILES;
const source: ContentSource = { const source: ContentSource = {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
if (f.id === opts.failID) throw new Error("HTTP 500 from server"); if (f.id === opts.failID) throw new Error("HTTP 500 from server");
@@ -96,18 +139,19 @@ const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
}; };
const fake = { const fake = {
whoami: () => ({ email: "cli@example.com", userID: USER_ID }), whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
collectionsSince: async () => ({ collectionsSince: async () => {
collections: COLLECTIONS, if (opts.refreshError) throw new Error(opts.refreshError);
deleted: [], return { collections, deleted: [], cursor: 1 };
cursor: 1, },
}),
filesSince: async (args: { collectionID: number }) => ({ filesSince: async (args: { collectionID: number }) => ({
files: FILES[args.collectionID] ?? [], files: files[args.collectionID] ?? [],
deleted: [], deleted: [],
cursor: 1, cursor: 1,
}), }),
contentSource: () => source, contentSource: () => source,
getApiClient: () => ({ getApiClient: () => ({
// The ML data request of `backup-metadata`: no file has any.
postJSON: async () => ({ data: [] }),
getThumbnailStream: async (fileID: number) => getThumbnailStream: async (fileID: number) =>
new ReadableStream<Uint8Array>({ new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
@@ -141,6 +185,15 @@ const context = (client: Client | null = fakeClient()): CliContext => ({
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: () => client, loadSession: () => client,
login: async () => {
throw new Error("login not expected");
},
prompt: async () => {
throw new Error("prompt not expected");
},
promptSecret: async () => {
throw new Error("prompt not expected");
},
}); });
beforeAll(async () => { beforeAll(async () => {
@@ -176,19 +229,6 @@ describe("session file", () => {
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
}); });
it("is removed by logout", async () => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
expect(await logoutCommand(ctx)).toBe(0);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
expect(stderr.text).toBe("Session deleted.\n");
});
it("logout without a session says so and exits 0", async () => {
expect(await logoutCommand(context())).toBe(0);
expect(stderr.text).toBe("No session found.\n");
});
it("a missing session exits 1 with 'Not logged in'", async () => { it("a missing session exits 1 with 'Not logged in'", async () => {
const ctx = { ...context(), loadSession }; const ctx = { ...context(), loadSession };
expect(await whoamiCommand(ctx)).toBe(1); expect(await whoamiCommand(ctx)).toBe(1);
@@ -211,6 +251,184 @@ describe("session file", () => {
}); });
}); });
// The login function is a fake that hands back a client whose snapshot is
// `snapshot`; each prompt is recorded and answered with "123456".
describe("login", () => {
const snapshot: ClientSnapshot = {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: "a",
secretKey: "b",
publicKey: "c",
};
const loggedIn = {
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
toJSON: () => snapshot,
} as unknown as Client;
let prompts: string[];
const loginContext = (
login: (opts: LoginOptions) => Promise<Client>,
): CliContext => ({
...context(),
login,
prompt: async (message) => {
prompts.push(message);
return "123456";
},
promptSecret: async (message) => {
prompts.push(message);
return "123456";
},
});
beforeEach(() => {
prompts = [];
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
vi.stubEnv("QUAK_PASSWORD", "hunter2");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("takes the email and password from the environment without a prompt", async () => {
const calls: LoginOptions[] = [];
const ctx = loginContext(async (opts) => {
calls.push(opts);
return loggedIn;
});
expect(await loginCommand(ctx)).toBe(0);
expect(prompts).toEqual([]);
expect(calls).toHaveLength(1);
expect(calls[0]!.email).toBe("cli@example.com");
expect(calls[0]!.password).toBe("hunter2");
const path = join(ctx.sessionDir, "session.json");
expect(stderr.text).toBe(
"Authenticating...\n" +
`Logged in as cli@example.com (user ${USER_ID})\n` +
`Session saved to ${path}\n`,
);
});
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
const ctx = loginContext(async () => loggedIn);
expect(await loginCommand(ctx)).toBe(0);
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
const path = join(ctx.sessionDir, "session.json");
expect(statSync(path).mode & 0o777).toBe(0o600);
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
});
it("asks for the TOTP code when the account needs one", async () => {
let code: string | undefined;
const ctx = loginContext(async (opts) => {
code = await opts.totp!();
return loggedIn;
});
expect(await loginCommand(ctx)).toBe(0);
expect(prompts).toEqual(["TOTP code: "]);
expect(code).toBe("123456");
});
it("a failed login exits 1, says why and writes no session", async () => {
const ctx = loginContext(async () => {
throw new Error("HTTP 401 from server");
});
expect(await loginCommand(ctx)).toBe(1);
expect(stderr.text).toBe(
"Authenticating...\nLogin failed: HTTP 401 from server\n",
);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
});
});
// These use a real client read from the session file, over a fake API that
// records each request and answers with `status`.
describe("logout", () => {
const snapshot: ClientSnapshot = {
email: "cli@example.com",
userID: USER_ID,
token: "saved-token",
masterKey: toBase64(new Uint8Array(32)),
secretKey: toBase64(new Uint8Array(32)),
publicKey: toBase64(new Uint8Array(32)),
};
const requests: Request[] = [];
const logoutContext = (status: number): CliContext => ({
...context(),
loadSession: (path) =>
loadSession(path, {
fetch: async (url, init) => {
requests.push(new Request(url, init));
return new Response(JSON.stringify({}), {
status,
headers: { "content-type": "application/json" },
});
},
}),
});
beforeEach(() => {
requests.length = 0;
});
it("ends the session on the server, then deletes the file", async () => {
const ctx = logoutContext(200);
saveSession(ctx.sessionDir, snapshot);
expect(await logoutCommand(ctx)).toBe(0);
expect(requests).toHaveLength(1);
expect(requests[0]!.method).toBe("POST");
expect(new URL(requests[0]!.url).pathname).toBe("/users/logout");
expect(requests[0]!.headers.get("X-Auth-Token")).toBe("saved-token");
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
expect(stderr.text).toBe(
"Session ended on the server.\n" +
"Session deleted.\n" +
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
);
});
it("still deletes the file when the server call fails, and says so", async () => {
const ctx = logoutContext(500);
saveSession(ctx.sessionDir, snapshot);
expect(await logoutCommand(ctx)).toBe(1);
expect(requests).toHaveLength(1);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
expect(stderr.text).toBe(
"Could not end the session on the server: HTTP 500\n" +
"Session deleted.\n" +
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
);
});
it("names the account's default cache directory without --cache-dir", async () => {
const ctx = { ...logoutContext(200), cacheDir: undefined };
saveSession(ctx.sessionDir, snapshot);
expect(await logoutCommand(ctx)).toBe(0);
expect(stderr.text).toContain(
`Cache directory ${defaultCacheDirectory(USER_ID)} still holds decrypted data`,
);
});
it("without a session says so, calls nothing and exits 0", async () => {
expect(await logoutCommand(logoutContext(200))).toBe(0);
expect(requests).toHaveLength(0);
expect(stderr.text).toBe("No session found.\n");
});
});
describe("whoami", () => { describe("whoami", () => {
it("prints the account as one line of JSON", async () => { it("prints the account as one line of JSON", async () => {
expect(await whoamiCommand(context())).toBe(0); expect(await whoamiCommand(context())).toBe(0);
@@ -323,6 +541,120 @@ describe("get and get-thumb", () => {
}); });
}); });
// A live photo, which Ente stores as one ZIP, is written as its image and its
// video, which a photo viewer can open. The account here is Vacation holding
// one live photo, 300, downloaded through the real download layer
// (test/live-photo.ts).
describe("a live photo", () => {
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
const { file: live, body } = await asLivePhoto(
file(300, 1, "IMG_0300.HEIC"),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO),
);
const fake = {
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
collectionsSince: async () => ({
collections: [collection(1, "Vacation")],
deleted: [],
cursor: 1,
}),
filesSince: async () => ({ files: [live], deleted: [], cursor: 1 }),
contentSource: () => cdnSource(new Map([[300, body]])),
// The ML data request of `backup-metadata`: no file has any.
getApiClient: () => ({ postJSON: async () => ({ data: [] }) }),
};
return fake as unknown as Client;
};
it("get writes its image and video, named after the title with their own extensions", async () => {
const ctx = context(await livePhotoClient());
const dir = join(root, "cwd");
mkdirSync(dir);
const previous = process.cwd();
process.chdir(dir);
try {
expect(await getCommand(ctx, "300", {})).toBe(0);
} finally {
process.chdir(previous);
}
expect(readdirSync(dir).sort()).toEqual([
"IMG_0300.heic",
"IMG_0300.mov",
]);
expect(readFileSync(join(dir, "IMG_0300.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(dir, "IMG_0300.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(stderr.text).toBe(
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
);
});
it("get --out writes the image there and the video beside it", async () => {
const out = join(root, "photo.jpg");
const video = join(root, "photo.mov");
expect(
await getCommand(context(await livePhotoClient()), "300", { out }),
).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe(
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
);
});
it("get exits 1 and writes nothing when --out has the video's extension", async () => {
const out = join(root, "photo.MOV");
expect(
await getCommand(context(await livePhotoClient()), "300", { out }),
).toBe(1);
expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe(
`File 300 is a live photo, and its video would also be written to ${out}\n`,
);
});
it("backup-metadata --exif reads its image", async () => {
// A 4x4 JPEG, whose size can only come from reading the image; the
// ZIP and the video are not JPEGs.
const jpeg = jpegJs.encode(
{ data: new Uint8Array(4 * 4 * 4), width: 4, height: 4 },
50,
).data;
const dir = join(root, "dump");
expect(
await backupMetadataCommand(
context(await livePhotoClient(new Uint8Array(jpeg))),
dir,
{ exif: true },
),
).toBe(0);
const record = JSON.parse(
readFileSync(
join(dir, "collections", "1-Vacation", "300.json"),
"utf-8",
),
);
expect(record.imageMetadata).toMatchObject({
format: "jpeg",
width: 4,
height: 4,
});
});
});
describe("backup", () => { describe("backup", () => {
it("exits 0 and prints a summary when every file is saved", async () => { it("exits 0 and prints a summary when every file is saved", async () => {
const dir = join(root, "backup"); const dir = join(root, "backup");
@@ -337,6 +669,16 @@ describe("backup", () => {
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
}); });
// The backup opens its library with the precache off: it fetches the
// originals it needs into the backup, and must not also fetch every
// thumbnail in the account, or keep originals, in the per-user cache.
it("leaves nothing in the cache's originals and thumbnails", async () => {
const dir = join(root, "backup");
expect(await backupCommand(context(), dir, {})).toBe(0);
expect(readdirSync(join(root, "cache", "thumbnails"))).toEqual([]);
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
});
it("exits 1 and lists the file when one download fails", async () => { it("exits 1 and lists the file when one download fails", async () => {
const ctx = context(fakeClient({ failID: 101 })); const ctx = context(fakeClient({ failID: 101 }));
expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1); expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1);
@@ -363,6 +705,34 @@ describe("backup", () => {
expect(result.errors[0].fileID).toBe(101); expect(result.errors[0].fileID).toBe(101);
expect(stderr.text).toBe("Starting backup...\n"); expect(stderr.text).toBe("Starting backup...\n");
}); });
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 401 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
const runStderr = new PassThrough();
let runText = "";
runStderr.on("data", (chunk: Buffer) => {
runText += chunk.toString();
});
const code = await new Promise<number>((resolve) => {
void run(
backupCommand(context(client), dir, {}),
new PassThrough(),
runStderr,
resolve,
);
});
expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 401 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(join(dir, "originals"))).toBe(false);
});
}); });
describe("helper list-missing-thumbnails", () => { describe("helper list-missing-thumbnails", () => {
@@ -395,3 +765,92 @@ describe("helper list-missing-thumbnails", () => {
expect(stderr.text).toBe(""); expect(stderr.text).toBe("");
}); });
}); });
describe("backup-metadata --exif", () => {
// Runs the command and returns what it printed to stderr.
const backupMetadata = async (opts: { exif?: boolean; all?: boolean }) => {
expect(
await backupMetadataCommand(context(), join(root, "dump"), opts),
).toBe(0);
return stderr.text;
};
it("--exif extracts EXIF", async () => {
expect(await backupMetadata({ exif: true })).toContain(
"[beach.jpg] Extracting EXIF...\n",
);
});
it("--all extracts EXIF", async () => {
expect(await backupMetadata({ all: true })).toContain(
"[beach.jpg] Extracting EXIF...\n",
);
});
it("without either flag extracts no EXIF", async () => {
expect(await backupMetadata({})).not.toContain("Extracting EXIF");
});
});
// Each test first runs `collections` so the cache holds the account as it was,
// then changes the server under it.
describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => {
expect(await collectionsCommand(context(), {})).toBe(0);
stdout.text = "";
stderr.text = "";
});
it("backup-metadata writes a file added since the cache was written", async () => {
const ctx = context(fakeClient({ withNewFile: true }));
const dir = join(root, "dump");
expect(await backupMetadataCommand(ctx, dir, {})).toBe(0);
expect(
existsSync(join(dir, "collections", "1-Vacation", "102.json")),
).toBe(true);
});
it("list-missing-thumbnails checks a file added since the cache was written", async () => {
const ctx = context(
fakeClient({ withNewFile: true, emptyThumbID: 102 }),
);
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0);
expect(stdout.text).toBe(
"102\tnew.jpg\tVacation\tempty thumbnail (0 bytes)\n",
);
});
it("fix-missing-thumbnails finds a file added since the cache was written", async () => {
const ctx = context(fakeClient({ withNewFile: true }));
expect(
await fixMissingThumbnailsCommand(ctx, {
file: ["102"],
json: true,
}),
).toBe(0);
// Found, then skipped because the server records no thumbnail size
// for it; a file missing from the cache would fail as not found.
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 102, title: "new.jpg", status: "skipped" },
]);
});
// `run` in `cli-run.ts` prints a thrown error as one line and exits 1.
it("all three throw when the refresh fails", async () => {
const ctx = context(
fakeClient({ refreshError: "HTTP 503 from server" }),
);
const dir = join(root, "dump");
await expect(backupMetadataCommand(ctx, dir, {})).rejects.toThrow(
"HTTP 503 from server",
);
expect(existsSync(dir)).toBe(false);
await expect(listMissingThumbnailsCommand(ctx, {})).rejects.toThrow(
"HTTP 503 from server",
);
await expect(
fixMissingThumbnailsCommand(ctx, { file: ["100"] }),
).rejects.toThrow("HTTP 503 from server");
expect(stdout.text).toBe("");
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Tests for `run` in `src/cli-run.ts`, which every CLI command goes through.
*/
import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest";
import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so
// nothing is left waiting to drain.
const collector = (): { stream: PassThrough; text: () => string } => {
const stream = new PassThrough();
const chunks: string[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk.toString()));
return { stream, text: () => chunks.join("") };
};
const runToExit = async (
command: Promise<number>,
): Promise<{ code: number; stdout: string; stderr: string }> => {
const stdout = collector();
const stderr = collector();
const code = await new Promise<number>((resolve) => {
void run(command, stdout.stream, stderr.stream, resolve);
});
return { code, stdout: stdout.text(), stderr: stderr.text() };
};
describe("run", () => {
it("exits with the code the command returns", async () => {
const result = await runToExit(Promise.resolve(3));
expect(result).toEqual({ code: 3, stdout: "", stderr: "" });
});
it("prints a thrown error as one line without a stack trace and exits 1", async () => {
const failing = async (): Promise<number> => {
throw new Error(
"ENOTDIR: not a directory, mkdir '/dev/null/x/originals'",
);
};
const result = await runToExit(failing());
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toBe(
"quak: ENOTDIR: not a directory, mkdir '/dev/null/x/originals'\n",
);
});
it("prints a thrown value that is not an Error", async () => {
const result = await runToExit(Promise.reject("offline"));
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: offline\n",
});
});
});
+193 -42
View File
@@ -55,6 +55,7 @@ import {
readFileSync, readFileSync,
rmSync, rmSync,
mkdtempSync, mkdtempSync,
statSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -81,6 +82,7 @@ import {
writeAtomic, writeAtomic,
} from "../../src/download/index.js"; } from "../../src/download/index.js";
import type { EnteFile, FileMetadata } from "../../src/model/types.js"; import type { EnteFile, FileMetadata } from "../../src/model/types.js";
import { IMAGE, livePhotoHash, livePhotoZip, VIDEO } from "../live-photo.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test helpers // Test helpers
@@ -106,6 +108,8 @@ import type { EnteFile, FileMetadata } from "../../src/model/types.js";
const renameHook = vi.hoisted(() => ({ const renameHook = vi.hoisted(() => ({
calls: [] as { from: string; to: string; sourceExisted: boolean }[], calls: [] as { from: string; to: string; sourceExisted: boolean }[],
failWith: null as Error | null, failWith: null as Error | null,
// When set, only a rename to this path fails.
failTo: null as string | null,
})); }));
/** /**
@@ -181,7 +185,10 @@ vi.mock("node:fs/promises", async (importOriginal) => {
sourceExisted: sourceExists(from), sourceExisted: sourceExists(from),
}); });
durabilityHook.events.push(`rename:${to}`); durabilityHook.events.push(`rename:${to}`);
if (renameHook.failWith !== null) { if (
renameHook.failWith !== null &&
(renameHook.failTo === null || renameHook.failTo === to)
) {
throw renameHook.failWith; throw renameHook.failWith;
} }
await actual.rename(from, to); await actual.rename(from, to);
@@ -216,6 +223,7 @@ beforeEach(() => {
hashHook.lengths.length = 0; hashHook.lengths.length = 0;
renameHook.calls.length = 0; renameHook.calls.length = 0;
renameHook.failWith = null; renameHook.failWith = null;
renameHook.failTo = null;
durabilityHook.events.length = 0; durabilityHook.events.length = 0;
writeHook.writes.length = 0; writeHook.writes.length = 0;
}); });
@@ -1560,7 +1568,13 @@ describe("writeAtomic", () => {
// directory so that new entry is on disk too. Do the directory fsync // directory so that new entry is on disk too. Do the directory fsync
// before the rename, or skip it, and a crash can lose the rename. // before the rename, or skip it, and a crash can lose the rename.
expect(durabilityHook.events).toHaveLength(3); expect(durabilityHook.events).toHaveLength(3);
expect(durabilityHook.events[0]).toMatch(/^sync:w:.*\.tmp$/); // The temp name carries this process's ID, so a library opening the
// same cache can tell a write in progress from a leftover.
const tempSync = durabilityHook.events[0]!;
expect(tempSync.startsWith(`sync:w:${dir}/`)).toBe(true);
expect(tempSync.slice(`sync:w:${dir}/`.length)).toMatch(
new RegExp(`^\\.quak-${process.pid}-[0-9a-f]{32}\\.tmp$`),
);
expect(durabilityHook.events[1]).toBe(`rename:${dest}`); expect(durabilityHook.events[1]).toBe(`rename:${dest}`);
expect(durabilityHook.events[2]).toBe(`sync:r:${dir}`); expect(durabilityHook.events[2]).toBe(`sync:r:${dir}`);
}); });
@@ -1636,38 +1650,35 @@ describe.each(entryPoints)("$name progress", ({ name, download }) => {
}); });
}); });
describe("downloadFile content hash", () => { // Node's own BLAKE2b-512 is the reference, so the tests below do not depend on
// Node's own BLAKE2b-512 is the reference, so these tests do not depend // the code under test to compute what they expect.
// on the code under test to compute what they expect. const blake2b = (bytes: Uint8Array): string =>
const blake2b = (bytes: Uint8Array): string => createHash("blake2b512").update(bytes).digest("base64");
createHash("blake2b512").update(bytes).digest("base64");
// Serve `plaintext` encrypted as file 999 with the given metadata. Four // Serve `plaintext` encrypted as file 999 with the given metadata, to be
// responses are scripted so a retried mismatch would show in `requests`. // written to `f.bin` in a fresh directory. Four responses are scripted so a
const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => { // retried failure would show in `requests`.
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => {
const { header, ciphertext } = encryptFileBody(plaintext, key); const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const file = buildMockEnteFile(key, header, header); const { header, ciphertext } = encryptFileBody(plaintext, key);
file.metadata = { ...file.metadata, ...metadata }; const file = buildMockEnteFile(key, header, header);
const body = { kind: "body", bytes: ciphertext } as const; file.metadata = { ...file.metadata, ...metadata };
const { fetch, requests } = scriptedCdnFetch(body, body, body, body); const body = { kind: "body", bytes: ciphertext } as const;
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } }); const { fetch, requests } = scriptedCdnFetch(body, body, body, body);
const dir = mkdtempSync(join(testDir, "hash-")); const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } });
const outPath = join(dir, "f.bin"); const dir = mkdtempSync(join(testDir, "hash-"));
return { const outPath = join(dir, "f.bin");
run: () => downloadFile(api, file, outPath), return {
dir, run: () => downloadFile(api, file, outPath),
outPath, api,
requests, file,
}; dir,
outPath,
requests,
}; };
};
const livePhotoZip = zipSync({ describe("downloadFile content hash", () => {
"image.heic": patternBytes(500, 81),
"video.mov": patternBytes(900, 82),
});
const livePhotoHash = `${blake2b(patternBytes(500, 81))}:${blake2b(patternBytes(900, 82))}`;
it("stores a file whose hash matches", async () => { it("stores a file whose hash matches", async () => {
const plaintext = patternBytes(700, 80); const plaintext = patternBytes(700, 80);
const t = setup(plaintext, { hash: blake2b(plaintext) }); const t = setup(plaintext, { hash: blake2b(plaintext) });
@@ -1700,17 +1711,18 @@ describe("downloadFile content hash", () => {
}); });
it("stores a live photo whose image and video hashes match", async () => { it("stores a live photo whose image and video hashes match", async () => {
const t = setup(livePhotoZip, { const t = setup(livePhotoZip(), {
fileType: "livePhoto", fileType: "livePhoto",
hash: livePhotoHash, hash: livePhotoHash(),
}); });
await t.run(); await t.run();
expectSameBytes(readFileSync(t.outPath), livePhotoZip); expect(readFileSync(join(t.dir, "f.heic"))).toEqual(Buffer.from(IMAGE));
expect(readFileSync(join(t.dir, "f.mov"))).toEqual(Buffer.from(VIDEO));
}); });
it("hashes a large live photo entry as it decompresses, never whole", async () => { it("writes a large live photo entry as it decompresses, never whole", async () => {
// 64 MiB of zeros deflates to a few kilobytes, the shape of a ZIP // 64 MiB of zeros deflates to a few kilobytes, the shape of a ZIP
// that would exhaust memory if expanded whole. // that would exhaust memory if expanded whole.
const image = new Uint8Array(64 * 1024 * 1024); const image = new Uint8Array(64 * 1024 * 1024);
@@ -1718,30 +1730,169 @@ describe("downloadFile content hash", () => {
const zip = zipSync({ "image.heic": image, "video.mov": video }); const zip = zipSync({ "image.heic": image, "video.mov": video });
const t = setup(zip, { const t = setup(zip, {
fileType: "livePhoto", fileType: "livePhoto",
hash: `${blake2b(image)}:${blake2b(video)}`, hash: livePhotoHash(image, video),
}); });
await t.run(); await t.run();
expectSameBytes(readFileSync(t.outPath), zip); expect(statSync(join(t.dir, "f.heic")).size).toBe(image.length);
expectSameBytes(readFileSync(join(t.dir, "f.mov")), video);
const hashed = hashHook.lengths.reduce((a, b) => a + b, 0); const hashed = hashHook.lengths.reduce((a, b) => a + b, 0);
expect(hashed).toBe(image.length + video.length); expect(hashed).toBe(image.length + video.length);
expect(Math.max(...hashHook.lengths)).toBeLessThanOrEqual( expect(Math.max(...hashHook.lengths)).toBeLessThanOrEqual(
2 * STREAM_CHUNK_SIZE, 2 * STREAM_CHUNK_SIZE,
); );
const written = writeHook.writes.filter((w) => w.path.endsWith(".tmp"));
expect(Math.max(...written.map((w) => w.length))).toBeLessThanOrEqual(
2 * STREAM_CHUNK_SIZE,
);
}); });
it("rejects a live photo whose hash does not match", async () => { it("rejects a live photo whose hash does not match, keeping what was there", async () => {
// The whole ZIP's hash is not the recorded one: each part is hashed. // The whole ZIP's hash is not the recorded one: each part is hashed.
const t = setup(livePhotoZip, { const zip = livePhotoZip();
fileType: "livePhoto", const t = setup(zip, { fileType: "livePhoto", hash: blake2b(zip) });
hash: blake2b(livePhotoZip), writeFileSync(t.outPath, "an earlier download");
});
await expect(t.run()).rejects.toThrow( await expect(t.run()).rejects.toThrow(
/file 999: content hash .* does not match/, /file 999: content hash .* does not match/,
); );
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(readFileSync(t.outPath, "utf-8")).toBe("an earlier download");
});
it("rejects a live photo that is not a readable ZIP and does not retry", async () => {
// Bytes 8-9 of a ZIP entry's local header name its compression
// method; 99 is one no reader knows, so the entry cannot be read.
const zip = livePhotoZip();
zip[8] = 99;
zip[9] = 0;
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo is not a readable ZIP/,
);
expect(readdirSync(t.dir)).toEqual([]);
expect(t.requests()).toBe(1);
});
it("rejects a live photo ZIP with no image entry", async () => {
const zip = livePhotoZip({ "video.mov": VIDEO });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
);
expect(readdirSync(t.dir)).toEqual([]);
});
it("rejects a live photo ZIP with no video entry", async () => {
const zip = livePhotoZip({ "image.heic": IMAGE });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
);
expect(readdirSync(t.dir)).toEqual([]); expect(readdirSync(t.dir)).toEqual([]);
}); });
}); });
// ---------------------------------------------------------------------------
// Live photos
//
// A live photo arrives as a ZIP of its image and its video. It is written as
// those two files, which a photo viewer can open, each named after the
// destination with its own extension from the ZIP, the way Ente's clients name
// them when they save one.
// ---------------------------------------------------------------------------
describe("downloadFile live photos", () => {
const livePhoto = { fileType: "livePhoto", hash: livePhotoHash() } as const;
it("names the image and the video after the title when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "IMG_1234.HEIC",
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
// `bytesWritten` is the length of the decrypted ZIP.
expect(result).toEqual({
path: "IMG_1234.heic",
videoPath: "IMG_1234.mov",
bytesWritten: livePhotoZip().length,
});
expect(readdirSync(t.dir).sort()).toEqual([
"IMG_1234.heic",
"IMG_1234.mov",
]);
});
it("gives each part its own extension from the ZIP, letters and digits only", async () => {
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
const t = setup(zip, livePhoto);
const result = await downloadFile(t.api, t.file, join(t.dir, "f.HEIC"));
expect(result.path).toBe(join(t.dir, "f.JPG"));
expect(result.videoPath).toBe(join(t.dir, "f.bin"));
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
});
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto);
await t.run();
expect(
renameHook.calls.map((c) => [
dirname(c.from),
c.to,
c.sourceExisted,
]),
).toEqual([
[t.dir, join(t.dir, "f.heic"), true],
[t.dir, join(t.dir, "f.mov"), true],
]);
});
it("stores neither part when the video cannot be renamed into place", async () => {
const t = setup(livePhotoZip(), livePhoto);
renameHook.failWith = new Error("simulated rename failure");
renameHook.failTo = join(t.dir, "f.mov");
await expect(t.run()).rejects.toThrow("simulated rename failure");
expect(readdirSync(t.dir)).toEqual([]);
});
it("refuses an image and a video with the same extension, storing nothing", async () => {
// On a file system that ignores case, the two would be one file.
const zip = livePhotoZip({ "image.mov": IMAGE, "video.MOV": VIDEO });
const t = setup(zip, livePhoto);
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
/file 999: live photo's image and video have the same extension/,
);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(t.requests()).toBe(1);
});
});
+151 -2
View File
@@ -31,8 +31,11 @@ import {
existsSync, existsSync,
writeFileSync, writeFileSync,
mkdirSync, mkdirSync,
readdirSync,
readFileSync,
statSync, statSync,
} from "node:fs"; } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -43,6 +46,13 @@ import {
} from "../../src/library/content.js"; } from "../../src/library/content.js";
import { RequestPools } from "../../src/library/pools.js"; import { RequestPools } from "../../src/library/pools.js";
import type { EnteFile } from "../../src/model/types.js"; import type { EnteFile } from "../../src/model/types.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({ const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({
id, id,
@@ -161,15 +171,28 @@ describe("ContentCache.open", () => {
expect(statSync(thumbnails).mode & 0o777).toBe(0o700); expect(statSync(thumbnails).mode & 0o777).toBe(0o700);
}); });
it("reaps orphan temp files but keeps complete content", async () => { it("removes temp files of an exited process, keeping those of a running one and complete content", async () => {
const originals = join(cacheDir, "originals"); const originals = join(cacheDir, "originals");
const thumbnails = join(cacheDir, "thumbnails"); const thumbnails = join(cacheDir, "thumbnails");
mkdirSync(originals, { recursive: true }); mkdirSync(originals, { recursive: true });
mkdirSync(thumbnails, { recursive: true }); mkdirSync(thumbnails, { recursive: true });
const orphan = join(originals, ".quak-abc123.tmp"); // A child that has already exited: its process ID is not running.
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const orphan = join(originals, `.quak-${exitedPID}-abc123.tmp`);
const orphanThumb = join(thumbnails, `.quak-${exitedPID}-abc456.tmp`);
// This test's own process stands in for another process still
// downloading into the same cache.
const inProgress = join(originals, `.quak-${process.pid}-def123.tmp`);
const inProgressThumb = join(
thumbnails,
`.quak-${process.pid}-def456.tmp`,
);
const complete = join(originals, "1.jpg"); const complete = join(originals, "1.jpg");
const thumb = join(thumbnails, "2.jpg"); const thumb = join(thumbnails, "2.jpg");
writeFileSync(orphan, "half-written"); writeFileSync(orphan, "half-written");
writeFileSync(orphanThumb, "half-written");
writeFileSync(inProgress, "half-written");
writeFileSync(inProgressThumb, "half-written");
writeFileSync(complete, "whole"); writeFileSync(complete, "whole");
writeFileSync(thumb, "whole-thumb"); writeFileSync(thumb, "whole-thumb");
@@ -177,8 +200,12 @@ describe("ContentCache.open", () => {
await cache.open(); await cache.open();
expect(existsSync(orphan)).toBe(false); expect(existsSync(orphan)).toBe(false);
expect(existsSync(orphanThumb)).toBe(false);
expect(existsSync(inProgress)).toBe(true);
expect(existsSync(inProgressThumb)).toBe(true);
expect(existsSync(complete)).toBe(true); expect(existsSync(complete)).toBe(true);
expect(existsSync(thumb)).toBe(true); expect(existsSync(thumb)).toBe(true);
expect(cache.pathsFor(1)).toEqual({ originalPath: complete });
}); });
it("records already-cached files so their paths appear in pathsFor", async () => { it("records already-cached files so their paths appear in pathsFor", async () => {
@@ -440,3 +467,125 @@ describe("ContentCache.ensureThumbnails", () => {
expect(results[1]?.error).toMatch(/unknown file/i); expect(results[1]?.error).toMatch(/unknown file/i);
}); });
}); });
// A live photo's original is two files, its image and its video, which a
// photo viewer can open, and a JSON file naming them: the two are named with
// the extensions from inside the ZIP, so the names alone do not say which is
// which. These tests download a live photo ZIP through the real download
// layer (test/live-photo.ts).
describe("ContentCache live photos", () => {
const originals = (): string => join(cacheDir, "originals");
// A cache over the stand-in server, which holds `bodies` by file ID.
const cacheOf = (
files: EnteFile[],
bodies: Map<number, Uint8Array>,
): ContentCache => buildCache({ files, source: cdnSource(bodies) }).cache;
it("stores a live photo as its image and its video and a JSON file naming them", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
const result = await cache.original(5);
expect(result).toEqual({
path: join(originals(), "5.heic"),
videoPath: join(originals(), "5.mov"),
bytes: IMAGE.length,
});
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
expect(statSync(result.videoPath!).mode & 0o777).toBe(0o600);
expect(
JSON.parse(
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
),
).toEqual({ image: "5.heic", video: "5.mov" });
expect(cache.pathsFor(5)).toEqual({ originalPath: result.path });
});
it("serves a stored live photo from disk after the cache is opened again", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const first = cacheOf([live], new Map([[5, body]]));
await first.open();
const stored = await first.original(5);
// This server has nothing, so a fetch would fail.
const second = cacheOf([live], new Map());
await second.open();
const events: string[] = [];
const served = await second.original(5, {
onProgress: (e) => events.push(e.status),
});
expect(served).toEqual(stored);
expect(events).toEqual(["skipped"]);
});
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
const result = await cache.original(5);
expect(result.videoPath).toBe(join(originals(), "5.mov"));
expect(readdirSync(originals()).sort()).toEqual([
"5.heic",
"5.livephoto.json",
"5.mov",
]);
});
it("evicts a live photo's image, video and JSON file together", async () => {
const a = await asLivePhoto(file(5, "a.HEIC"));
const b = await asLivePhoto(file(6, "b.HEIC"));
const size = IMAGE.length + VIDEO.length;
const cache = new ContentCache({
pools: new RequestPools(),
source: cdnSource(
new Map([
[5, a.body],
[6, b.body],
]),
),
cacheDirectory: cacheDir,
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
// Room for one live photo, on a disk with plenty free.
cacheOriginalsMaxBytes: size,
freeBelowBytes: 0,
statfs: async () => ({ bsize: 1, bavail: 1e12 }),
});
await cache.open();
await cache.original(5);
await cache.original(6);
expect(readdirSync(originals()).sort()).toEqual([
"6.heic",
"6.livephoto.json",
"6.mov",
]);
expect(cache.originalsStatus().usedBytes).toBe(size);
});
it("stores nothing when a live photo does not match its recorded hash", async () => {
const { file: live, body } = await asLivePhoto(
file(5, "IMG_5.HEIC"),
livePhotoZip(),
"not:the recorded hash",
);
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
await expect(cache.original(5)).rejects.toThrow(
/file 5: content hash .* does not match/,
);
expect(readdirSync(originals())).toEqual([]);
expect(cache.pathsFor(5)).toEqual({});
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Live photo fixtures for the download, content cache, backup and CLI tests.
*
* Ente stores a live photo as one ZIP holding its image and its video, which
* Ente's clients name `image.<ext>` and `video.<ext>`. The ZIP is built here
* with fflate from small fixed bytes and encrypted the way the server serves a
* file under 4 MiB, as one secretstream chunk. `cdnSource` serves it to the
* real download layer, so a test checks what quak stores for a real one.
*/
import { createHash } from "node:crypto";
import { zipSync } from "fflate";
import { ApiClient } from "../src/api/client.js";
import { encryptBlob, init, toBase64 } from "../src/crypto/index.js";
import {
makeDownloadContentSource,
type ContentSource,
} from "../src/library/content.js";
import type { EnteFile } from "../src/model/types.js";
export const IMAGE = new TextEncoder().encode("the still image");
export const VIDEO = new TextEncoder().encode("the few seconds of video");
// A live photo's ZIP; by default the entries an iPhone's live photo gets.
export const livePhotoZip = (
entries: Record<string, Uint8Array> = {
"image.heic": IMAGE,
"video.mov": VIDEO,
},
): Uint8Array => zipSync(entries);
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
// the image and of the video, each in standard base64, joined by a colon.
export const livePhotoHash = (image = IMAGE, video = VIDEO): string =>
`${blake2b(image)}:${blake2b(video)}`;
// `file` as a live photo whose original is `zip` and whose recorded hash is
// `hash`, and `body`, what the server serves for it: `zip` encrypted under the
// file's key and header.
export const asLivePhoto = async (
file: EnteFile,
zip = livePhotoZip(),
hash = livePhotoHash(),
): Promise<{ file: EnteFile; body: Uint8Array }> => {
await init();
const key = new Uint8Array(32).fill(file.id & 0xff);
const { header, ciphertext } = encryptBlob(zip, key);
return {
file: {
...file,
key,
metadata: { ...file.metadata, fileType: "livePhoto", hash },
file: { decryptionHeader: toBase64(header) },
},
body: ciphertext,
};
};
// A content source that downloads through the real download layer from a
// stand-in server, which serves `bodies` by file ID and a 404 for any other.
export const cdnSource = (bodies: Map<number, Uint8Array>): ContentSource =>
makeDownloadContentSource(
new ApiClient({
fetch: (async (url: string | URL) => {
const fileID = new URL(String(url)).searchParams.get("fileID");
const body = bodies.get(Number(fileID));
return body === undefined
? new Response("not found", { status: 404 })
: new Response(body);
}) as typeof globalThis.fetch,
retry: { attempts: 1 },
}),
);
+143
View File
@@ -215,6 +215,9 @@ const buildThumbMock = async (opts?: {
thumbnail: { thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)), decryptionHeader: toBase64(sodium.randombytes_buf(24)),
}, },
// The encrypted size of the thumbnail the server records; large
// enough here that the default encoding fits.
info: { thumbSize: 1_000_000 },
updationTime: TEST_TIME, updationTime: TEST_TIME,
}; };
}; };
@@ -446,6 +449,32 @@ const openLib = (client: Client): Promise<Library> =>
precacheOriginals: false, precacheOriginals: false,
}); });
/** The mock's raw record for one file, for a test to change before login. */
const rawFile = (m: ThumbMockState, fileID: number): Record<string, unknown> =>
m.filesByCollection[1]!.find((f) => f.id === fileID)!;
/** Replace the original the mock serves for one file. */
const replaceOriginal = (
m: ThumbMockState,
fileID: number,
body: Uint8Array,
): void => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(
m.fileKeys[fileID]!,
);
m.fileCiphertexts[fileID] =
sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
body,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
rawFile(m, fileID).file = { decryptionHeader: toBase64(push.header) };
};
const isOriginalDownload = (url: string): boolean =>
url.includes("files.ente.io") || url.includes("/files/download/");
const login = (fetch: typeof globalThis.fetch, retry?: RetryOptions) => const login = (fetch: typeof globalThis.fetch, retry?: RetryOptions) =>
Client.login({ Client.login({
email: TEST_EMAIL, email: TEST_EMAIL,
@@ -581,6 +610,33 @@ describe("listMissingThumbnails", () => {
// Should still be 2, not 4 (each file checked only once) // Should still be 2, not 4 (each file checked only once)
expect(missing.length).toBe(2); expect(missing.length).toBe(2);
}); });
it("skips a file another account owns without fetching its thumbnail", async () => {
const otherMock = await buildThumbMock();
rawFile(otherMock, 102).ownerID = 7;
const logs: string[] = [];
const counted = countingFetch(
buildThumbFetch(otherMock),
(url) => url.includes("thumbnails.ente.io") && url.includes("102"),
);
const client = await login(counted.fetch);
const lib = await openLib(client);
const missing = await listMissingThumbnails(lib, client, (msg) =>
logs.push(msg),
);
lib.close();
expect(missing.map((m) => m.fileID)).toEqual([101]);
expect(counted.matched()).toBe(0);
expect(
logs.some(
(l) =>
l.includes("Skipping file-102.jpg") &&
l.includes("another account"),
),
).toBe(true);
});
}); });
describe("fixMissingThumbnails", () => { describe("fixMissingThumbnails", () => {
@@ -688,6 +744,93 @@ describe("fixMissingThumbnails", () => {
expect(fixMock.uploadedThumbnails.length).toBe(1); expect(fixMock.uploadedThumbnails.length).toBe(1);
expect(fixMock.uploadedThumbnails[0]!.fileID).toBe(101); expect(fixMock.uploadedThumbnails[0]!.fileID).toBe(101);
}); });
it("skips a file another account owns without downloading it", async () => {
// The server accepts a thumbnail only from the file's owner.
const fixMock = await buildThumbMock();
rawFile(fixMock, 101).ownerID = 7;
const counted = countingFetch(
buildThumbFetch(fixMock),
isOriginalDownload,
);
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("skipped");
expect(results[0]!.reason).toContain("another account");
expect(counted.matched()).toBe(0);
expect(fixMock.uploadedThumbnails.length).toBe(0);
});
it("skips a file whose recorded thumbnail size is 0 without downloading it", async () => {
// The server refuses a thumbnail larger than the one it records, and
// no thumbnail is 0 bytes.
const fixMock = await buildThumbMock();
rawFile(fixMock, 101).info = { thumbSize: 0 };
const counted = countingFetch(
buildThumbFetch(fixMock),
isOriginalDownload,
);
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("skipped");
expect(results[0]!.reason).toContain("recorded thumbnail size is 0");
expect(counted.matched()).toBe(0);
expect(fixMock.uploadedThumbnails.length).toBe(0);
});
it("re-encodes smaller until the thumbnail fits the recorded size", async () => {
// A noisy 400x300 JPEG, which the default encoding (quality 50, not
// resized because it is under 720 px) cannot compress below the size
// recorded here: one byte less than that encoding's ciphertext.
const fixMock = await buildThumbMock();
const w = 400;
const h = 300;
const noisy = new Uint8Array(
jpegJs.encode(
{
data: sodium.randombytes_buf(w * h * 4),
width: w,
height: h,
},
90,
).data,
);
replaceOriginal(fixMock, 101, noisy);
const decoded = jpegJs.decode(noisy, {
useTArray: true,
formatAsRGBA: true,
});
const defaultSize =
jpegJs.encode(decoded, 50).data.length +
sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
const recordedSize = defaultSize - 1;
rawFile(fixMock, 101).info = { thumbSize: recordedSize };
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("fixed");
const upload = fixMock.uploadedThumbnails[0]!;
expect(upload.ciphertext.length).toBeLessThanOrEqual(recordedSize);
const decrypted = decryptBlob(
upload.ciphertext,
fromBase64(upload.decryptionHeader),
fixMock.fileKeys[101]!,
);
expect(decrypted[0]).toBe(0xff);
expect(decrypted[1]).toBe(0xd8);
});
}); });
describe("Client.getApiClient", () => { describe("Client.getApiClient", () => {