check / check (push) Canceled after 0s
The old text served as the list of what a reader needs covered. The new text leads with what quak is, what it is for and how to use it. Every command, option, exit code and file layout follows in reference sections, then the library reference, then design, entrypoints and development. Every statement was checked against the code on next; the places where the old text disagreed with it are listed in the pull request. No sentence runs past about 30 words, every table is now a list, and no issue links or project history remain in text meant for users. Model: fable-5-1
1733 lines
86 KiB
Markdown
1733 lines
86 KiB
Markdown
# quak
|
|
|
|
quak is a WTFPL-licensed TypeScript library and command-line tool by
|
|
[@sneak](https://sneak.berlin) for reading and backing up an
|
|
[Ente](https://ente.io) photo account.
|
|
|
|
Ente is a photo service that encrypts photos end to end. The server never sees a
|
|
photo, its name or its date unencrypted. Only a client that holds the account's
|
|
keys can read them. quak is such a client, built for scripts and for jobs that
|
|
run unattended. It logs in with the account's email and password, derives the
|
|
keys, and keeps a decrypted copy of the account's listing in a local cache.
|
|
|
|
With the command line you can:
|
|
|
|
- list the account's albums and the files in them;
|
|
- download and decrypt one file, or its thumbnail;
|
|
- back up every file in the account into a directory tree that a photo viewer
|
|
can open, and keep that backup current from cron;
|
|
- write everything Ente records about the account, its albums and its files as
|
|
JSON;
|
|
- find files whose thumbnails are missing on the server, and repair them.
|
|
|
|
The library underneath does the same work for a program of your own. It opens a
|
|
cache-backed view of the account and answers reads from the cache, without
|
|
touching the network.
|
|
|
|
A backup run never prompts and never stops on one bad file. It skips files that
|
|
are already on disk, records each failure in a file, and tries the failed files
|
|
again on the next run. Every download is decrypted on its way to disk and
|
|
checked against the content hash Ente records for the file.
|
|
|
|
## Getting Started
|
|
|
|
The commands below need `git`, Node 22 or later and `yarn`.
|
|
|
|
```bash
|
|
git clone https://git.eeqj.de/sneak/quak.git
|
|
cd quak
|
|
yarn install
|
|
yarn build
|
|
|
|
# Log in. quak asks for the email and the password, and for a code when
|
|
# the account needs one.
|
|
yarn quak login
|
|
|
|
# List the account's collections. Ente calls an album a collection.
|
|
yarn quak collections
|
|
|
|
# List every file in the account.
|
|
yarn quak files
|
|
|
|
# List the files in one collection.
|
|
yarn quak files 12345
|
|
|
|
# Download and decrypt one file.
|
|
yarn quak get 67890 --out ./photo.jpg
|
|
|
|
# Back up every file in the account.
|
|
yarn quak backup ./my-backup
|
|
```
|
|
|
|
`yarn quak` runs the command line that `yarn build` wrote to `dist/`. To run
|
|
quak from cron, build it as one binary instead; see "Running the backup from
|
|
cron".
|
|
|
|
### Using the library
|
|
|
|
The library keeps a local copy of the account's listing, the cache. It brings
|
|
the cache up to date with the server in rounds called refreshes.
|
|
|
|
```ts
|
|
import { Client, Library } from "quak";
|
|
|
|
// Log in once. The client holds the account's keys in memory.
|
|
const client = await Client.login({
|
|
email: "you@example.com",
|
|
password: "your-password",
|
|
});
|
|
|
|
// Open the cache-backed library. On an empty cache this waits for one
|
|
// refresh. On an existing cache it returns at once and refreshes in the
|
|
// background.
|
|
const lib = await Library.open({ client });
|
|
|
|
// Default reads answer from the cache and never touch the network.
|
|
for (const album of lib.albums.list()) {
|
|
console.log(album.collectionID, album.name);
|
|
for (const photo of album.photos.list()) {
|
|
console.log(` ${photo.title} [${photo.fileType}]`);
|
|
}
|
|
}
|
|
|
|
// A fresh read waits for a refresh first.
|
|
const { albums } = await lib.fresh();
|
|
console.log(`${albums.list().length} albums as of now`);
|
|
|
|
// Fetch one photo's original. The bytes land in the cache.
|
|
const photo = lib.photos.byID({ fileID: 12345 });
|
|
if (photo) {
|
|
const { path } = await photo.original();
|
|
console.log(`original at ${path}`);
|
|
}
|
|
|
|
await lib.close();
|
|
```
|
|
|
|
The `Client` under the library handles login, saved sessions and the raw
|
|
requests. The "Library reference" below covers both.
|
|
|
|
## Running the backup from cron
|
|
|
|
`quak backup` never prompts, so cron can run it. `make install` builds quak as
|
|
one binary and copies it to `~/bin/quak`. Besides what Getting Started needs,
|
|
`make install` needs [nix](https://nixos.org/download). It bundles the built
|
|
`dist/` with bun, a JavaScript runtime that can bundle a program into one
|
|
executable, and it runs bun through `nix-shell -p bun`.
|
|
|
|
Log in once with `~/bin/quak`, as the user the cron job will run as:
|
|
|
|
```bash
|
|
~/bin/quak login
|
|
```
|
|
|
|
The session is saved in that user's data directory (see "The session file").
|
|
Then add the backup to that user's crontab with `crontab -e`. These two lines
|
|
back up the account to `~/photos-backup` at 03:30 every night and append all
|
|
output to `~/quak-backup.log`. Sunday's run adds `--verify`:
|
|
|
|
```
|
|
30 3 * * 1-6 $HOME/bin/quak backup $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
|
|
30 3 * * 0 $HOME/bin/quak backup --verify $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
|
|
```
|
|
|
|
A run with `--verify` does all a plain run does. It also hashes each original
|
|
already in the backup and compares the hash with the content hash Ente records
|
|
for the file. It replaces any original that does not match. Sunday's run is the
|
|
`--verify` run, rather than a second job that night, because two backups of one
|
|
directory cannot run at once. The second one exits 2 without doing anything.
|
|
|
|
Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab
|
|
names quak by its full path. quak finds the saved session through `HOME`, which
|
|
cron sets from the password file. On Linux it also reads `XDG_DATA_HOME`: the
|
|
session is in `$XDG_DATA_HOME/quak`, or in `~/.local/share/quak` when that
|
|
variable is not set. Cron does not set `XDG_DATA_HOME`. If your login shell sets
|
|
it, set it at the top of the crontab too, as a full path, because cron does not
|
|
expand variables there:
|
|
|
|
```
|
|
XDG_DATA_HOME=/home/you/.data
|
|
```
|
|
|
|
On macOS the session is in `~/Library/Application Support/quak`, and only `HOME`
|
|
matters.
|
|
|
|
A backup that ends with failures exits 1, and its last line says how many files
|
|
and albums failed. The next run fetches each missing original again and rebuilds
|
|
the album links. The exit codes are listed under "Exit codes", and the backup's
|
|
files under "The backup directory".
|
|
|
|
## Command reference
|
|
|
|
Every command has the form `quak [options] <command> [arguments]`. The options
|
|
below belong to quak itself, and go before or after the command's name.
|
|
|
|
### Global options
|
|
|
|
- `--cache-dir <path>`: where the local cache lives. Without it, each account
|
|
gets its own directory under the per-user cache directory (see "The cache").
|
|
- `-v`, `--verbose`: log more on stderr. `-v` adds debug lines, and `-vv` adds
|
|
trace lines too (see "What quak prints").
|
|
- `-q`, `--quiet`: print nothing on stderr but the reason a failing command
|
|
exits. `-q` with `-v` is refused with one line,
|
|
`error: option '-q, --quiet' cannot be used with option '-v, --verbose'`, and
|
|
exit status 1.
|
|
- `--version`: print the version and exit (see "Version").
|
|
- `--help`: print the usage and exit.
|
|
|
|
### The commands
|
|
|
|
```
|
|
quak login
|
|
quak whoami
|
|
quak logout
|
|
quak collections [--json]
|
|
quak files [collectionID] [--json]
|
|
quak get <fileID> [--out <path>]
|
|
quak get-thumb <fileID> [--out <path>]
|
|
quak backup <dir> [--json] [--verify]
|
|
quak backup-metadata <dir> [--exif]
|
|
quak helper list-missing-thumbnails [--json]
|
|
quak helper fix-missing-thumbnails [--file <id>...] [--json]
|
|
```
|
|
|
|
Every command but `login` needs a saved session. Every command but `login`,
|
|
`whoami` and `logout` opens the cache-backed library. How each one gets current
|
|
data from the server is described under "How a command gets current data".
|
|
|
|
#### `quak login`
|
|
|
|
Logs in to an account and saves the session. It reads the email from
|
|
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
|
set, it asks on the terminal. The password is not shown as it is typed.
|
|
|
|
When the account has a second factor, quak asks for the code. It asks
|
|
`TOTP code: ` for a time-based code from an authenticator app, and
|
|
`Email verification code: ` for a code sent by email. An account that signs in
|
|
with a passkey cannot log in with quak.
|
|
|
|
On success it logs `Logged in as <email> (user <id>)` and
|
|
`Session saved to <path>`. On failure it logs `Login failed: <reason>` and exits
|
|
|
|
1. `login` is the only command that prompts.
|
|
|
|
#### `quak whoami`
|
|
|
|
Prints the saved session's account on stdout as one line of JSON:
|
|
`{"email":"you@example.com","userID":12345}`.
|
|
|
|
#### `quak logout`
|
|
|
|
Ends the session on the server and deletes the session file. Ending it on the
|
|
server makes the saved token stop working everywhere, even in a copy of the
|
|
file. With no session file, the command logs `No session found.` and exits 0.
|
|
|
|
When the server call fails, or the session file is corrupt, the file is still
|
|
deleted. The command then logs
|
|
`Could not end the session on the server: <reason>` and exits 1.
|
|
|
|
`logout` does not delete the cache. When it knows the cache directory, from
|
|
`--cache-dir` or from a session file it could read, it warns that the directory
|
|
still holds decrypted data. Delete that directory yourself if you want the data
|
|
gone.
|
|
|
|
#### `quak collections [--json]`
|
|
|
|
Lists every collection in the account, in the server's order, after one request
|
|
to the server.
|
|
|
|
Without `--json`, each line holds the collection's ID, its type and its name,
|
|
separated by tabs. A collection another account owns gets ` (shared)` after its
|
|
name. A collection's type is one of `album`, `folder`, `favorites`,
|
|
`uncategorized` and `unknown`.
|
|
|
|
With `--json`, the output is a JSON array with one object per collection. Each
|
|
object holds `id`, `name`, `type`, `ownerID`, `isShared` and `updationTime`.
|
|
Update times are in microseconds, as Ente records them.
|
|
|
|
#### `quak files [collectionID] [--json]`
|
|
|
|
With a collection ID, lists the files in that collection, in the server's order.
|
|
Without one, lists every file in the account once, collection by collection. A
|
|
file that is in several collections is listed under the first collection that
|
|
holds it.
|
|
|
|
Without `--json`, each line holds the file's ID, its type and its title,
|
|
separated by tabs. A file's type is one of `image`, `video`, `livePhoto` and
|
|
`unknown`. With `--json`, each object holds `id`, `title`, `fileType`,
|
|
`creationTime` in microseconds and `collectionID`.
|
|
|
|
A collection ID that does not exist gives `Collection <id> not found` and exit
|
|
status 1.
|
|
|
|
#### `quak get <fileID> [--out <path>]`
|
|
|
|
Downloads and decrypts one file's original, the full-size file as uploaded.
|
|
Without `--out`, it writes the file to the working directory under the file's
|
|
title, with unsafe characters replaced (see "File names from the server"). With
|
|
`--out`, it writes to that path as given. It logs `<bytes> bytes -> <path>`. The
|
|
original is kept in the cache as well.
|
|
|
|
A file ID that no collection holds gives `File <id> not found` and exit status
|
|
|
|
1. `--collection <id>` is accepted and ignored.
|
|
|
|
A live photo is a still image and a short video that Ente stores as one file.
|
|
`get` writes both, each named after the title with its own extension, as Ente's
|
|
own clients name them: `IMG_0001.heic` and `IMG_0001.mov`. With `--out <path>`,
|
|
the image goes to `<path>` and the video beside it, with the same name and the
|
|
video's extension. A `<path>` that already ends in the video's extension is
|
|
refused, because both files would land on it.
|
|
|
|
#### `quak get-thumb <fileID> [--out <path>]`
|
|
|
|
Downloads and decrypts one file's thumbnail, the small JPEG Ente shows in a
|
|
grid. It works as `get` does. Without `--out`, the name is `thumb_` followed by
|
|
the title.
|
|
|
|
#### `quak backup <dir> [--json] [--verify]`
|
|
|
|
Backs up every file in the account to `<dir>`. The layout is described under
|
|
"The backup directory". It downloads only the originals that are not yet in the
|
|
backup, and it goes on past a file that fails. A file is downloaded once,
|
|
however many collections hold it. An original the cache already holds is copied
|
|
from there instead.
|
|
|
|
Progress goes to stderr: one line per original fetched, and a `FAILED` line for
|
|
each file or album that failed. The run ends with a summary of counts and a
|
|
`Failed files:` list. With `--json`, the summary and the list are replaced by
|
|
one JSON object on stdout. Its fields are `totalFiles`, `downloaded`, `skipped`,
|
|
`verified`, `mismatched`, `unchecked`, `failed`, `errors`, `undecryptableAlbums`
|
|
and `failedAlbums`.
|
|
|
|
With `--verify`, the run also hashes each original already in the backup and
|
|
replaces any whose bytes do not match the content hash Ente records. "The backup
|
|
directory" describes what it checks and what it changes.
|
|
|
|
A backup refreshes the library as it starts and not again while it runs. Each
|
|
request it sends retries for longer than other commands' requests do (see
|
|
"Retries and timeouts").
|
|
|
|
The exit code is 0 when every original is in the backup and nothing failed. It
|
|
is 1 when files or albums failed, 2 when another backup of `<dir>` is running,
|
|
and 3 when there is no usable session (see "Exit codes").
|
|
|
|
#### `quak backup-metadata <dir> [--exif]`
|
|
|
|
Writes everything Ente records about the account, its collections and its files
|
|
as JSON files under `<dir>`. The layout is described under "The metadata dump
|
|
directory". Photo content is not written. With `--exif`, or its alias `--all`,
|
|
the command also fetches each file's original and records the image's own
|
|
metadata from it (see "The JSON beside each original").
|
|
|
|
The command fetches the machine-learning data Ente holds for each file, in
|
|
requests of up to 200 files. When a request fails, each of its files is written
|
|
with the reason in `mlDataError` instead of `mlData`, and the dump goes on. So
|
|
is a file whose data does not decrypt, unzip or parse. When any file was written
|
|
with `mlDataError`, the last line says how many and the exit status is 1.
|
|
|
|
#### `quak helper list-missing-thumbnails [--json]`
|
|
|
|
Finds the files whose thumbnail is missing on the server. It downloads every
|
|
thumbnail in the account, one collection at a time, newest collection first, so
|
|
on a large account it takes a while. A thumbnail counts as missing when the
|
|
server answers with an empty body or with HTTP 404. Any other failure is logged
|
|
as `Could not check <title>: <reason> (not reported as missing)`, and the file
|
|
is left out of the list.
|
|
|
|
The server lets only a file's owner replace its thumbnail. A file another
|
|
account owns, in a collection shared with you, is skipped and not checked.
|
|
|
|
Without `--json`, the command logs `<n> file(s) with missing thumbnails:` and
|
|
prints one line per file on stdout. Each line holds the file's ID, its title,
|
|
its collection and the reason, separated by tabs. With nothing missing it logs
|
|
`No missing thumbnails found.` With `--json`, the output is a JSON array; each
|
|
object holds `fileID`, `title`, `collection` and `reason`.
|
|
|
|
#### `quak helper fix-missing-thumbnails [--file <id>...] [--json]`
|
|
|
|
Makes a new thumbnail for each file that lacks one, and uploads it. Without
|
|
`--file`, it first finds the files as `list-missing-thumbnails` does. With
|
|
`--file`, it works on the files named and checks nothing else.
|
|
|
|
For each file, the helper downloads the original, makes a JPEG thumbnail from
|
|
it, encrypts the thumbnail under the file's own key and uploads it. The server
|
|
refuses a thumbnail larger than the one it already records for the file. The
|
|
helper therefore tries five encodings in turn and uploads the first that fits.
|
|
They run from 720 pixels on the long side at JPEG quality 50 down to 160 pixels
|
|
at quality 20.
|
|
|
|
Each file ends in one of three states:
|
|
|
|
- `fixed`: a thumbnail was uploaded.
|
|
- `skipped`: the helper cannot make a thumbnail for the file, or the server
|
|
would refuse it. A skipped file does not affect the exit status. This covers:
|
|
- a file another account owns;
|
|
- a video or a live photo;
|
|
- an image that is not a JPEG;
|
|
- a file whose recorded thumbnail size is 0 or unknown;
|
|
- a file for which even the smallest encoding is too large.
|
|
- `failed`: the download, the encoding or the upload failed, or an ID given with
|
|
`--file` is held by no collection.
|
|
|
|
Without `--json`, the command ends with counts of fixed, skipped and failed
|
|
files, a `Skipped:` list and a `Failed files:` list. With `--json`, one JSON
|
|
array on stdout replaces them; each object holds `fileID`, `title`,
|
|
`collection`, `status` and, for a skipped or failed file, `reason`. When any
|
|
file failed, the exit status is 1. When nothing needs fixing, the command logs
|
|
`No missing thumbnails found.` and exits 0, and with `--json` it then writes no
|
|
JSON.
|
|
|
|
One bad `--file` ID refuses the whole command before anything is fetched (see
|
|
"IDs").
|
|
|
|
### How a command gets current data
|
|
|
|
Every command that reads the account answers for the account as it is now, not
|
|
for whatever the cache last held. Each one asks the server for exactly what it
|
|
needs:
|
|
|
|
- `collections` asks for the collection list alone, in one request. It fetches
|
|
no collection's files and changes nothing in the cache.
|
|
- `files` with a collection ID asks for the same list, then for that one
|
|
collection's files. It changes nothing in the cache either.
|
|
- `get`, `get-thumb` and `fix-missing-thumbnails --file` look the file up in the
|
|
cache after a refresh. On an empty cache, they ask for the collection list
|
|
instead, then for one collection's files at a time, and stop at the collection
|
|
that holds the file. `fix-missing-thumbnails --file` stops once it has found
|
|
every file named. These searches store nothing in the cache. The original or
|
|
thumbnail fetched is kept in the cache.
|
|
- `files` without a collection ID, `backup`, `backup-metadata` and the two
|
|
thumbnail helpers without `--file` wait for a refresh.
|
|
|
|
A refresh asks the server which collections changed since the last refresh and
|
|
fetches the files of each one, one collection after another. On an empty cache
|
|
that is every collection in the account, which on a large account takes a while,
|
|
and nothing is stored until all of them are in. Meanwhile the command logs a
|
|
line for each collection fetched, such as
|
|
`Fetched the files of 3 of 120 collections`.
|
|
|
|
When a refresh or a search fails, the command logs the error on one line and
|
|
exits 1, or 3 when the server no longer accepts the saved session. A collection
|
|
or file that quak cannot decrypt does not fail a command: the command answers
|
|
from what did decrypt (see "Default reads and fresh reads").
|
|
|
|
### IDs
|
|
|
|
A collection ID or file ID given to `files`, `get`, `get-thumb` or
|
|
`fix-missing-thumbnails --file` is written in decimal digits and is at
|
|
most 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
|
|
before any request is sent. The command logs `Invalid file ID: <arg>` or
|
|
`Invalid collection ID: <arg>` and exits 1.
|
|
|
|
### What quak prints
|
|
|
|
stdout holds only a command's output: the listing, the JSON, or nothing. Every
|
|
other line goes to stderr, and every stderr line is a log line at one of six
|
|
levels. From the quietest, the levels are fatal, error, warning, info, debug and
|
|
trace. A run prints the lines at its own level and at every quieter one. The
|
|
level is info unless `-v`, `-vv` or `-q` says otherwise.
|
|
|
|
- Fatal: why a command exits non-zero. Every command that exits non-zero logs at
|
|
least one fatal line. A command that exits 1 because files or albums failed
|
|
logs it last, saying how many failed and where to look.
|
|
- Error: something that failed without ending the run. These are the `FAILED`
|
|
lines of `backup` and `fix-missing-thumbnails`, their `Failed files:` lists,
|
|
the `ML data` lines of `backup-metadata`, and `Could not check <title>` from
|
|
the thumbnail helpers.
|
|
- Warning: something that did not fail but needs your attention. After `logout`,
|
|
that the cache directory still holds decrypted data.
|
|
- Info: everything else quak prints by default, such as a refresh's progress,
|
|
each original a backup fetches, and the summaries.
|
|
- Debug, with `-v`: each retry of a request, with why the attempt failed and how
|
|
long quak waits. Also the start and end of each refresh and of each fetch of
|
|
machine-learning data, with the error of one that failed.
|
|
- Trace, with `-vv`: each request quak sends, as its method, its URL and the
|
|
HTTP status of the answer or the error it failed with. The URL of a thumbnail
|
|
upload is logged without its query, which holds the upload's signature.
|
|
|
|
`-q` lowers the level to fatal: a run that exits 0 prints nothing on stderr, and
|
|
one that exits non-zero prints only its fatal lines. The prompts of `login` are
|
|
not log lines, and `-q` leaves them alone.
|
|
|
|
With `--json`, a command's JSON on stdout takes the place of its summary on
|
|
stderr. For `backup`, that is the lines from `--- Backup complete ---` to the
|
|
end of the `Failed files:` list. For `list-missing-thumbnails`, it is
|
|
`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`. For
|
|
`fix-missing-thumbnails`, it is the lines from `--- Done ---` to the end of the
|
|
`Failed files:` list. Every other line is logged as without `--json`.
|
|
|
|
### Exit codes
|
|
|
|
- `0`: the command succeeded. For `backup`, every original is in the backup and
|
|
no file or album failed.
|
|
- `1`: the command failed. For `backup`, the run finished with files in
|
|
`failures.json` or with albums it could not decrypt or write, and its last
|
|
line says how many. For any command, an error that stopped it is printed as
|
|
one line, `quak: <message>` (see "Error messages"). The next backup run tries
|
|
again.
|
|
- `2`: another backup of the same directory is running. This run sent no request
|
|
and changed nothing. Only `backup` exits 2.
|
|
- `3`: there is no usable session. None is saved, the saved one is corrupt, or
|
|
the server no longer accepts it. Run `quak login` as the user the command runs
|
|
as.
|
|
|
|
### Error messages
|
|
|
|
An error that stops a command is printed as one line, `quak: <message>`, with no
|
|
stack trace. `<message>` is the error's own message, followed by the message of
|
|
each error that caused it, separated by `: `.
|
|
|
|
Under Node, as `yarn quak` runs quak, `fetch` reports every failure below HTTP
|
|
as just `fetch failed` and gives the reason as the cause. A run that could not
|
|
reach the server therefore prints, for example,
|
|
`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. When the server has
|
|
several addresses and none could be reached, the line gives the reason for each
|
|
one, separated by `, `.
|
|
|
|
The `~/bin/quak` that `make install` builds runs under bun. Bun gives its own
|
|
wording for such a failure, with no reason after it. A refused connection, for
|
|
example, prints
|
|
`quak: Unable to connect. Is the computer able to access the url?`.
|
|
|
|
A file whose download failed that way goes into the backup's `failures.json` as
|
|
a transient failure, with that error. So does each file whose machine-learning
|
|
data could not be fetched that way, with an error starting `ML data: `.
|
|
|
|
## Files quak writes
|
|
|
|
### The session file
|
|
|
|
`quak login` writes the session to `session.json` in quak's data directory. On
|
|
Linux that is `$XDG_DATA_HOME/quak/session.json`, or
|
|
`~/.local/share/quak/session.json` when the variable is not set. On macOS it is
|
|
`~/Library/Application Support/quak/session.json`. The file is readable by its
|
|
owner only (mode `0600`), in a directory only its owner can enter (mode `0700`).
|
|
|
|
The file holds the account's email, its user ID, the session token and the
|
|
account's three keys, in base64 and unencrypted. Treat it as you would treat the
|
|
password. A missing file means "not logged in". A file that exists but cannot be
|
|
read back is corrupt, and the command says which field is wrong.
|
|
|
|
Both cases exit 3, and so does a session the server no longer accepts: the
|
|
command then prints
|
|
`quak: the saved session is no longer valid; run "quak login"`. `quak backup`
|
|
meets a rejected session on the refresh it starts with, before it writes
|
|
anything to the backup directory but its lock. A session that stops working
|
|
partway through a backup fails each remaining file into `failures.json` instead.
|
|
That run exits 1, and the next run stops at its refresh with status 3.
|
|
|
|
### The cache
|
|
|
|
Each account has its own cache directory. Without `--cache-dir`, it is
|
|
`quak/<userID>` under the per-user cache directory: on Linux
|
|
`$XDG_CACHE_HOME/quak/<userID>`, or `~/.cache/quak/<userID>` when the variable
|
|
is not set, and on macOS `~/Library/Caches/quak/<userID>`. The directory is
|
|
readable by its owner only (mode `0700`), because it holds decrypted data, file
|
|
keys included.
|
|
|
|
```
|
|
<cacheDirectory>/
|
|
metadata.json the decrypted collection and file records,
|
|
the refresh cursor, and the list of what
|
|
did not decrypt
|
|
originals/<fileID>.<ext> cached originals
|
|
originals/<fileID>.livephoto.json
|
|
which of a live photo's two files is which
|
|
thumbnails/<fileID>.jpg cached thumbnails
|
|
mldata/
|
|
<fileID>.json one decrypted machine-learning payload per
|
|
file
|
|
clip.f32, clip.json the packed CLIP index and its list of IDs
|
|
fetched.json per-file fetch bookkeeping
|
|
```
|
|
|
|
When `metadata.json` belongs to another account than the one logged in, opening
|
|
the library deletes it and `mldata/` and starts from an empty cache. Cached
|
|
originals and thumbnails are kept. They are reached only through the records of
|
|
the current account, so another account's files are never served.
|
|
|
|
A file appears in the cache only once it is complete: each one is written to a
|
|
temporary file and renamed into place. Every original whose metadata records a
|
|
content hash is hashed as it is written, and one that does not match is not
|
|
stored. The hash is Ente's: unkeyed BLAKE2b with a 64-byte output, in standard
|
|
base64. A live photo arrives as a ZIP of its image and its video and is unpacked
|
|
as it is written. Its two files are hashed separately and compared as
|
|
`<imageHash>:<videoHash>`, and neither is stored unless both match. A live photo
|
|
ZIP that holds a second image or video, or whose two files come to more than 20
|
|
times its size plus 16 MiB, is refused. An original with no recorded hash,
|
|
uploaded by a very old client, is stored unchecked.
|
|
|
|
The originals in the cache are bounded (see "Opening a library"). Thumbnails are
|
|
not.
|
|
|
|
### The backup directory
|
|
|
|
`quak backup <dir>` produces this tree:
|
|
|
|
```
|
|
<dir>/
|
|
YYYY/YYYY-MM/YYYY-MM-DD/
|
|
YYYY-MM-DD.<fileID>.<ext> the original, at its save path (one per
|
|
file, two for a live photo)
|
|
YYYY-MM-DD.<fileID>.json what Ente records about the file, plus
|
|
its machine-learning data, its keywords
|
|
and its image metadata
|
|
YYYY-MM-DD.<fileID>.livephoto.json
|
|
which of a live photo's two files is which
|
|
collections/
|
|
<name>/
|
|
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
|
|
a symlink per file
|
|
<name>.json the collection's metadata and file list
|
|
account.json the account's email and user ID
|
|
backup.lock the lock a running backup holds
|
|
failures.json the files that failed and have not yet
|
|
succeeded
|
|
```
|
|
|
|
#### Save paths
|
|
|
|
Each original is stored once, at its save path:
|
|
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under `<dir>`. The date is
|
|
the day the photo was taken, in the time zone of the machine running quak. That
|
|
is the date set in Ente when the date was edited, and otherwise the file's
|
|
creation time. The extension is the one in the file's title, with its case kept.
|
|
It is `.bin` when the title has no extension, when the extension holds anything
|
|
but letters and digits, or when it is longer than 16 characters.
|
|
|
|
When the date or the time zone changes, the next run stores the original at its
|
|
new path and leaves the old copy where it is.
|
|
|
|
The library gives the same path as `photo.savePath` and writes it with
|
|
`photo.download()` (see "A photo's content").
|
|
|
|
#### The JSON beside each original
|
|
|
|
The JSON file beside an original holds the file's `id`, `collectionID`,
|
|
`ownerID`, `metadata` and `updationTime`, and its `magicMetadata` and
|
|
`pubMagicMetadata` when it has them. `metadata` is the file's own metadata as
|
|
uploaded: its title, its type, its times, its coordinates and its content hash.
|
|
The magic metadata is Ente's name for the fields its clients add later, such as
|
|
an edited name, an edited date or a caption. Update times are in microseconds,
|
|
as Ente records them.
|
|
|
|
The file also holds the machine-learning data Ente computed for the file, as
|
|
`mlData`. Ente's apps detect faces in each photo and compute a CLIP embedding
|
|
for it, a list of 512 numbers that describes what the photo shows. `mlData`
|
|
holds everything but that embedding. In its place the file holds `keywords`,
|
|
derived from the embedding as described under "Keywords". A file with no
|
|
machine-learning data has neither field. A file whose data holds no usable
|
|
embedding has no `keywords`.
|
|
|
|
The backup waits for the library's fetch of machine-learning data to finish
|
|
before it writes the JSON files. A file whose data could not be fetched, or
|
|
whose data arrived but did not decrypt, unzip or parse, gets the reason in
|
|
`mlDataError` instead of `mlData`. Such a file counts as failed and goes into
|
|
`failures.json` with an error starting `ML data: `. Nothing is cached for it, so
|
|
the next run asks for its data again. The JSON files are rewritten on every run,
|
|
so data that arrived since the last run appears.
|
|
|
|
For an image, the file also holds `imageMetadata`: what the original itself
|
|
records. That is its `format`, `width` and `height` for a JPEG, its EXIF tags as
|
|
`exif`, and its XMP packet as `xmp`. EXIF and XMP are the two blocks of metadata
|
|
a camera or an editor writes inside an image file. For a live photo, these come
|
|
from its image. An original with none of them gets `{}`. A video gets no
|
|
`imageMetadata`, because reading a whole video to look for tags is not worth it.
|
|
When the original cannot be read, the reason is in `imageMetadataError` instead,
|
|
and neither the file nor the run fails.
|
|
|
|
EXIF is read with [exifreader](https://github.com/mattiasw/ExifReader) from any
|
|
image format it reads, such as JPEG, HEIC, AVIF, PNG and WebP. `exif` holds each
|
|
tag by name, with its `value`, `description` and `computed` value. An EXIF block
|
|
that exifreader finds but reads no tag from is kept in base64 as `exifRaw`, with
|
|
the reason in `exifError`.
|
|
|
|
The original is read for this when the run stores it, or when the JSON beside it
|
|
has neither `imageMetadata` nor `imageMetadataError`. Otherwise the two fields
|
|
are copied from the JSON being rewritten, so a run does not read every stored
|
|
original again.
|
|
|
|
#### Keywords
|
|
|
|
The Discover section of Ente's photos app sorts photos into 16 categories. quak
|
|
derives a file's keywords the way the app sorts it. The categories are:
|
|
|
|
- Identity (no videos)
|
|
- Screenshots (no videos)
|
|
- QR Codes (no videos)
|
|
- Receipts
|
|
- Notes
|
|
- Memes
|
|
- Visiting Cards (no videos)
|
|
- Babies
|
|
- Pets
|
|
- Selfies
|
|
- Wallpapers (no videos)
|
|
- Food
|
|
- Celebrations
|
|
- Sunset
|
|
- Hills
|
|
- Greenery
|
|
|
|
Each category has a query, and a text embedding of that query from Ente's CLIP
|
|
text model. A file's score for a category is the cosine similarity of the file's
|
|
embedding and the category's. The file falls in the category when its score
|
|
reaches the category's minimum score. The keywords are the titles of the
|
|
categories the file falls in, highest score first. A video never falls in a
|
|
category marked "no videos", as in the app.
|
|
|
|
#### Collection directories
|
|
|
|
Each collection gets a directory under `collections/`, named after the
|
|
collection, and a JSON file beside it, `<name>.json`. The directory holds one
|
|
symlink per file, named after the file's title, pointing at the original's save
|
|
path. A live photo gets two symlinks, one to its image and one to its video,
|
|
each named after the title with that file's extension.
|
|
|
|
The JSON file holds the collection's `id`, `name`, `type`, `ownerID`, `isShared`
|
|
and `updationTime`, its `magicMetadata`, `pubMagicMetadata` and
|
|
`sharedMagicMetadata` when it has them, and `files`, with each file's `id` and
|
|
`metadata`.
|
|
|
|
Names come from the server, so unsafe characters in them are replaced (see "File
|
|
names from the server"). When two collections would get the same directory name,
|
|
ignoring case, each gets its ID added: two albums named `Trip` become
|
|
`Trip (10)/` and `Trip (11)/`. Two files in one collection whose titles match,
|
|
ignoring case, get their IDs added before the extension: `IMG_0001 (12345).JPG`
|
|
and `IMG_0001 (12346).JPG`. A collection whose directory name would be another
|
|
collection's JSON name gets its ID added as well. A name too long for a file
|
|
name, which most file systems limit to 255 bytes, gets its ID added too. It is
|
|
then cut short before the ID, just far enough to fit. IDs never change, so a
|
|
name stays the same from run to run until such a clash appears or goes away.
|
|
|
|
Each run removes from a collection's directory the symlinks into the date
|
|
folders that no longer belong there. It removes the directory and JSON of a
|
|
collection that was deleted or renamed, once the directory holds nothing else.
|
|
Nothing else under `collections/` is touched: a file or symlink you put there
|
|
stays, and a directory that still holds one stays too, with its JSON.
|
|
|
|
A collection whose directory or JSON cannot be written, such as one whose
|
|
directory name is taken by a file you put there, does not stop the run. The run
|
|
logs `FAILED album <name>: <reason>`, lists the collection's ID in
|
|
`failedAlbums`, and exits 1. The other collections and `failures.json` are still
|
|
written.
|
|
|
|
#### What quak cannot decrypt
|
|
|
|
A collection or file that quak cannot decrypt is left out of the library (see
|
|
"Default reads and fresh reads"). The backup treats the two cases this way:
|
|
|
|
- A file quak cannot decrypt goes into `failures.json` with an error starting
|
|
`could not be decrypted:` and the reason. When a copy of the file in another
|
|
collection decrypted, it does not. The original is then stored from that copy,
|
|
and the collection where it does not decrypt neither links nor lists it.
|
|
- A collection quak cannot decrypt has no name quak can read and no files it can
|
|
list. The run logs `FAILED album <id>: could not be decrypted: <reason>` and
|
|
lists the ID in `undecryptableAlbums`.
|
|
|
|
Either one keeps the exit code at 1, run after run, until the server sends a
|
|
copy that decrypts or deletes the record. The next run then drops it. A
|
|
collection that stops decrypting loses its directory and JSON as a deleted one
|
|
does, and a file that stops decrypting loses its symlink in that collection. The
|
|
originals stay at their save paths.
|
|
|
|
#### Live photos
|
|
|
|
Ente stores a live photo as one ZIP of its image and its video. The backup
|
|
stores the two files, which a photo viewer can open: each is
|
|
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP, such as
|
|
`2026-03-01.12345.heic` and `2026-03-01.12345.mov`. The file
|
|
`YYYY-MM-DD.<fileID>.livephoto.json` beside them names which is which. The live
|
|
photo counts as stored only when both files are present and not empty.
|
|
|
|
#### `failures.json`
|
|
|
|
`failures.json` records each file that failed and has not succeeded since. Each
|
|
entry holds the file's ID and title, the kind of failure, how many runs have
|
|
tried it, when it was last tried, and the error. The kind is `transient` for a
|
|
network or server problem, `permanent` for a refusal or a local file-system or
|
|
decryption error, and `unknown` otherwise.
|
|
|
|
A file leaves the list once it succeeds, or once it is no longer in the library
|
|
or in the backup's scope. The file is deleted when the list is empty.
|
|
|
|
#### `backup.lock`
|
|
|
|
`backup.lock` keeps two backups of the same directory from running at once, such
|
|
as a cron run that starts while the previous one is still going. A backup
|
|
creates `<dir>` if it is missing and takes the lock before its refresh, so a
|
|
refused run sends no request. It removes the lock when it ends, whether it
|
|
succeeded or failed.
|
|
|
|
The lock is a directory that
|
|
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
|
|
and keeps touching every 5 seconds while the backup runs. A second backup of the
|
|
directory fails at once: it prints `quak: another backup of <dir> is running`
|
|
and exits 2. A run stopped with Ctrl-C or `kill` removes the lock as it exits. A
|
|
run that cannot, such as one killed with SIGKILL or cut off by a crash, leaves
|
|
it behind. Once the lock has gone 10 seconds untouched, the next run takes it
|
|
over, so nobody has to remove it by hand.
|
|
|
|
A running backup loses its lock when someone removes `backup.lock`. It also
|
|
loses it when another backup takes it over, because this one left it untouched
|
|
for 10 seconds, as a stopped process does. The backup notices the next time it
|
|
goes to touch the lock, and stops before it starts another file. The originals
|
|
it is downloading at that moment are still stored. It leaves `failures.json` and
|
|
`backup.lock` as they are, since another backup may be using them, prints
|
|
`quak: lost the lock on <dir>: <reason>` and exits 1.
|
|
|
|
#### `--verify`
|
|
|
|
With `--verify`, a run hashes each original already at its save path the way a
|
|
download is checked. For a live photo it hashes the image and the video and
|
|
joins the two as `<imageHash>:<videoHash>`. An original that matches the content
|
|
hash in its metadata is left as it is. One that does not is logged on one line
|
|
naming the file, downloaded again in the same run, and replaced once the new
|
|
copy matches. The new copy is downloaded even when the cache holds the original,
|
|
because a copy from the cache is not checked as a download is.
|
|
|
|
The new copy is written to a temporary file beside the original, hashed the same
|
|
way, and renamed over the original only when it matches. A live photo's image
|
|
and video are both hashed before either is renamed. Until then the original
|
|
stays as it was. When the download fails, or does not match either, the
|
|
temporary files are deleted, the original keeps its bytes and its album links,
|
|
and the file goes into `failures.json`. A run that stops between renaming a live
|
|
photo's new image and its new video leaves the new image beside the old video. A
|
|
later `--verify` run then replaces the video, if the video was the part that did
|
|
not match.
|
|
|
|
A file whose metadata records no hash is left as it is and counted as unchecked.
|
|
A stored original that cannot be read is left as it is and counted as failed.
|
|
The summary and the `--json` output add the counts `verified`, `mismatched` and
|
|
`unchecked`, all over originals that were already stored. A mismatch that was
|
|
replaced with matching bytes does not make the exit code non-zero. Only a later
|
|
`--verify` run checks a failed original again. A run without `--verify` leaves
|
|
it as it is and takes it out of `failures.json`, so that run can exit 0.
|
|
|
|
Without `--verify`, nothing is hashed, the summary is unchanged, and the three
|
|
counts are 0 in the JSON.
|
|
|
|
#### Temporary files and crash safety
|
|
|
|
Each original is written to a temporary file in the same directory, synced to
|
|
disk, and renamed into place. So an original is either complete or absent, even
|
|
after a power cut. A downloaded original's temporary file is named
|
|
`.quak-<pid>-<random>.tmp`. One copied from the cache is named
|
|
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that
|
|
`--verify` downloads is written to such a `.quak-<pid>-<random>.tmp` too, and a
|
|
live photo's image and video to `.quak-<pid>-<random>.<ext>`, each with its own
|
|
extension.
|
|
|
|
A run that is killed can leave one of these files behind. The next backup
|
|
deletes those whose process is no longer running. The cache uses the same
|
|
scheme, and opening a library deletes such leftovers in the cache the same way.
|
|
A download another process has in progress 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
|
|
temporary file's permissions, not those of the file it replaced.
|
|
|
|
### The metadata dump directory
|
|
|
|
`quak backup-metadata <dir>` writes `account.json`, holding the account's
|
|
`email` and `userID`, and under `collections/` one directory per collection,
|
|
named `<id>-<name>/`. Each directory holds `_collection.json`, with the fields a
|
|
backup's collection JSON holds. It also holds a `<fileID>.json` for each file in
|
|
the collection, with the fields a backup's JSON beside an original holds. The
|
|
name after the ID has unsafe characters replaced and is cut short so that the
|
|
directory name fits in 255 bytes; the ID keeps the directories apart. With
|
|
`--exif`, the image metadata of a file in several collections is read once and
|
|
written with the first of them.
|
|
|
|
### File names from the server
|
|
|
|
A file's title and a collection's name come from the server, so a name taken
|
|
from them is made safe before it is used on disk. Each path separator, each
|
|
character Windows forbids in a file name and each control character becomes `_`.
|
|
A leading run of dots becomes one `_`. A name Windows reserves for a device,
|
|
such as `CON`, gets a leading `_`. An empty title becomes `file-<id>`. An empty
|
|
collection name becomes `collection-<id>` in a backup and `unnamed` in the
|
|
metadata dump.
|
|
|
|
A name over 255 bytes, which most file systems refuse, is cut short between two
|
|
characters before its extension, so the extension is kept whenever it fits. An
|
|
extension too long to leave room for the name's first character is cut short
|
|
too, so the name never starts with a dot. A path you give with `--out` is used
|
|
as it is.
|
|
|
|
## Library reference
|
|
|
|
The library's main surface is the cache-backed `Library`. The `Client` sits
|
|
underneath it and handles login, saved sessions and the raw requests. The test
|
|
suite is the executable documentation: `test/library/` and
|
|
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
|
|
walks `lib.backup()`, and `test/cli/backup-end-to-end.test.ts` runs
|
|
`quak backup` against a fake of the Ente HTTP API.
|
|
|
|
### Opening a library
|
|
|
|
`Library.open(options)` loads the on-disk cache and resolves to a `Library`. On
|
|
an empty cache it waits for the first refresh, so it opens onto the account's
|
|
data whenever the server is reachable. If that refresh fails, it opens with no
|
|
data and records the error in `lib.status()`. On an existing cache it returns at
|
|
once and refreshes in the background, so an unreachable server does not block
|
|
opening. With `refreshOnOpen: false` it starts no refresh and returns at once
|
|
onto what the cache holds, empty or not. A refresh then starts only when
|
|
`fresh()` or `lib.backup()` asks, or when the background timer fires.
|
|
|
|
`LibraryOptions`:
|
|
|
|
- `client`, required: the account's `Client`, or anything that satisfies
|
|
`LibraryClient`.
|
|
- `cacheDirectory`: where `metadata.json` and the content cache live. Default:
|
|
`quak/<userID>` under the per-user cache directory (see "The cache").
|
|
- `downloadDirectory`: the root of the save paths. Default: `photos` in the
|
|
working directory, resolved once when the library opens;
|
|
`lib.downloadDirectory` holds the result. An original stored at its save path
|
|
counts as cached.
|
|
- `refreshIntervalSeconds`: how long after one refresh ends the background timer
|
|
starts the next. Default `3`. `Infinity` starts no timer.
|
|
- `refreshOnOpen`: whether `open()` starts a refresh. Default `true`.
|
|
- `precacheThumbnails`: fetch every thumbnail in the background, newest first,
|
|
until all are on disk. Default `true`.
|
|
- `precacheOriginals`: fetch, in the background, the originals of the favorites
|
|
album and of every file taken in the latest window. Default `true`.
|
|
- `precacheOriginalsDays`: the length of that window in days, counted back from
|
|
the newest file. Default `7`.
|
|
- `cacheOriginalsMaxBytes`: the size limit on the originals cache. Default 100
|
|
GiB. Pinned originals can push it over the limit.
|
|
- `freeBelowBytes`: the free space to protect on the cache's volume. Default 50
|
|
GiB. The effective limit shrinks as the disk fills.
|
|
- `isOriginalPinned`: an extra predicate naming originals that must never be
|
|
evicted. The favorites and the latest window are pinned already.
|
|
- `pools`: the bounded request pools, which set the concurrency (see "Request
|
|
pools"). Default: a fresh `RequestPools`.
|
|
- `onProgress`: a callback for refresh, machine-learning and precache progress,
|
|
called with a `RefreshEvent`.
|
|
- `contentSource`: a replacement for the client's own byte source. Mainly for
|
|
tests.
|
|
|
|
The two background fills yield to on-demand work, and a file already cached
|
|
costs no fetch. When the originals cache grows past its limit, the least
|
|
recently used originals are evicted, but never a pinned one, and never one whose
|
|
fetch is still in flight.
|
|
|
|
`onProgress` receives a `RefreshEvent` with `status` `"started"` as a refresh, a
|
|
machine-learning fetch or a precache sweep starts, and `"done"` as it ends. A
|
|
refresh or a machine-learning fetch that fails ends with `"failed"` and the
|
|
error message instead. In between, a refresh sends `"progress"` after each
|
|
collection whose files it fetched, with `fetched`, how many it has fetched so
|
|
far, and `total`, how many it will. A refresh fetches the files of each
|
|
collection that is new or changed since the cache's copy, so on an empty cache
|
|
it fetches those of every collection.
|
|
|
|
`lib.status()` returns a `LibraryStatus`. It holds the collection and file
|
|
counts, and the time and error of the last refresh and of the last
|
|
machine-learning fetch. It also holds the originals cache's usage and effective
|
|
limit, the precache's progress and `closed`. `lib.status().mlDataErrors` lists,
|
|
as `{ fileID, error }`, each file whose machine-learning data the last
|
|
successful fetch got but could not decrypt, unzip or parse. The `fetchMLData`
|
|
event with status `"done"` carries the same list. Such data is not cached, so
|
|
the next fetch asks for it again. The reasons are kept in memory only.
|
|
|
|
`lib.close()` stops the background timer. It can be called more than once. A
|
|
refresh in flight is left to finish, with its write to the cache. The promise
|
|
`close()` returns resolves once that refresh, the machine-learning fetch and the
|
|
precache fetches already running have all finished. The cache directory can then
|
|
be removed.
|
|
|
|
### Default reads and fresh reads
|
|
|
|
Default reads, through `lib.albums`, `lib.photos` and `lib.timeline`, answer
|
|
synchronously from the copy held in memory and never touch the network. A
|
|
refresh replaces that copy only once all its server requests have succeeded. So
|
|
a default read is immediate, but only as current as the last refresh whose
|
|
requests all succeeded. Right after opening an existing cache, it is the copy on
|
|
disk.
|
|
|
|
`await lib.fresh()` waits for a refresh to complete and persist, then returns
|
|
the same `{ albums, photos, timeline }`, which then reflect a completed server
|
|
round-trip. When a refresh is already running, `fresh()` waits for that one, so
|
|
its answer can come from requests made before the call. Only when none is
|
|
running does it start one. A refresh that fails rejects the caller. Default
|
|
reads stay silent and keep serving the last good copy.
|
|
|
|
`await lib.freshCollections()` is the fresh read of the collection list alone:
|
|
one request, answered with every live collection that decrypts, as `Collection`
|
|
objects, in the server's order. It fetches no collection's files and stores
|
|
nothing, not even the list. A refresh fetches a collection's files only when the
|
|
collection is new or has changed since the stored copy, so a collection stored
|
|
without its files would never get them.
|
|
|
|
`await lib.freshFiles(collection)` is the fresh read of one collection's files,
|
|
as `EnteFile` objects, in the server's order, each file once. `collection` is an
|
|
object that `freshCollections()` returned. The method fetches no other
|
|
collection's files and stores nothing.
|
|
|
|
A collection or file in the server's answer that quak cannot decrypt, or whose
|
|
metadata is not JSON, does not fail the refresh. The refresh leaves it out, a
|
|
collection with all its files, and drops any older copy the cache held, so reads
|
|
answer from what did decrypt. The library keeps a list of what it left out, with
|
|
the reason, in `metadata.json`. It needs the list because the next refresh asks
|
|
only for what changed, and would not see the record again. A record stays on
|
|
that list until a later refresh gets a copy that decrypts or learns that the
|
|
server deleted it.
|
|
|
|
### Albums, photos and the timeline
|
|
|
|
- `lib.albums.list()` returns `Album[]`, newest updated first.
|
|
`lib.albums.byID({ collectionID })` and `lib.albums.byName({ albumName })`
|
|
return `Album | undefined`. Names are not unique in Ente, so `byName` picks
|
|
the newest updated album of that name.
|
|
- `lib.photos.byID({ fileID })` returns `Photo | undefined`.
|
|
`lib.photos.records({ fileIDs })` returns `PhotoRecord[]` in the order asked
|
|
for, each ID once, unknown IDs dropped.
|
|
- `lib.timeline.groups({ groupBy, filter? })` returns `TimelineGroup[]`, newest
|
|
group first. `groupBy` is `"day"`, `"week"` or `"month"`, and the group keys
|
|
are `YYYY-MM-DD`, `YYYY-Www` (the ISO week) and `YYYY-MM`. Each group holds
|
|
`startsAt`, local time in milliseconds, and `fileIDs`, newest first. Weeks
|
|
start on Monday.
|
|
|
|
A `PhotoFilter` narrows the timeline. Its fields combine with AND:
|
|
|
|
- `albumID`: only photos in that album.
|
|
- `text`: a case-insensitive substring of the title, the caption or the name of
|
|
an album the photo is in.
|
|
- `fileTypes`: only photos of one of these types.
|
|
- `hasLocation`: `true` keeps only photos with coordinates, `false` only those
|
|
without.
|
|
- `includeArchived`: archived photos are left out unless this is `true`.
|
|
|
|
Hidden photos are always left out.
|
|
|
|
An `Album` exposes its record's fields and `album.photos.list()`, which returns
|
|
`Photo[]`, newest first. A `Photo` exposes its record's fields except the cache
|
|
paths, plus `photo.year`, the local-time year of `takenAt`. All of these are
|
|
synchronous getters read from memory. `photo.record()` returns the
|
|
`PhotoRecord`.
|
|
|
|
Two more getters look at the disk and never touch the network:
|
|
|
|
- `photo.savePath`: where `photo.download()` and `lib.backup()` put the
|
|
original, whether or not it is there yet (see "Save paths"). For a live photo
|
|
already stored, it is the image. For a live photo not yet stored, it carries
|
|
the title's extension, and the image may be stored under another one.
|
|
- `photo.isLocal`: whether the whole original is at `savePath`. A copy only in
|
|
the cache does not count.
|
|
|
|
### A photo's content
|
|
|
|
These methods may download. They serve from the cache when the bytes are present
|
|
and otherwise fetch through the pools. They throw when the library was opened
|
|
without a content source. `opts.onProgress`, where accepted, reports the
|
|
progress of that one file.
|
|
|
|
- `await photo.original(opts?)` returns `{ path, bytes, videoPath? }`, the
|
|
full-size file. For a live photo, `path` and `bytes` are its image's and
|
|
`videoPath` is its video. `original()` also serves an original already stored
|
|
at its save path.
|
|
- `await photo.download()` returns the same shape and puts the original at
|
|
`savePath`, creating its folders. When it is already there, nothing is
|
|
written. When the cache holds it, it is copied from the cache. Otherwise it is
|
|
fetched straight to `savePath`, with no copy left in the cache. Afterwards
|
|
`isLocal` is true.
|
|
- `await photo.thumbnail(opts?)` returns `{ path, bytes }`.
|
|
- `await photo.content(opts?)` returns the original's bytes as a `Uint8Array`,
|
|
read through `original()`. For a live photo, its image's.
|
|
- `await photo.exif(opts?)` returns `ExifTags`: every EXIF tag in the file,
|
|
keyed by tag name, each as exifreader decodes it with its `id`, `value`,
|
|
`description` and `computed` value. For example, `Make` is
|
|
`{ id: 271, value: ["Canon"], description: "Canon", computed: "Canon" }`. A
|
|
tag exifreader has no name for is keyed `undefined-<tag number>`. The tags of
|
|
the thumbnail embedded in the file are under `Thumbnail`, so they cannot hide
|
|
the main image's tags of the same name. The thumbnail image itself is left
|
|
out. Any file exifreader cannot read gives `{}`, and a video gives `{}`
|
|
without being downloaded.
|
|
- `await photo.make(opts?)` returns one common EXIF field, picked from the tags
|
|
`exif()` returns and typed as in `PhotoExif`, or `undefined` when the file
|
|
lacks it. The methods `model()`, `lensModel()`, `dateTimeOriginal()`,
|
|
`offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, `iso()`,
|
|
`focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` and
|
|
`gpsAltitude()` do the same for their fields. GPS values are signed decimal
|
|
degrees and metres. `dateTimeOriginal()` is the camera's clock reading, held
|
|
in the `Date`'s UTC fields; `offsetTimeOriginal()`, when present, is that
|
|
clock's offset from UTC. Each of these calls `exif()`, so each call reads the
|
|
original again.
|
|
|
|
An original that `original()`, `content()` or `exif()` downloads lands in the
|
|
cache, which does not make `isLocal` true. Only `download()` and `lib.backup()`
|
|
do.
|
|
|
|
`await lib.originalOf(file, opts?)` and `await lib.thumbnailOf(file, opts?)`
|
|
fetch and cache as `photo.original()` and `photo.thumbnail()` do. `file` is an
|
|
`EnteFile` the caller holds, and the library need not hold it. `quak get` and
|
|
`quak get-thumb` use them; on an empty cache they pass a file that
|
|
`lib.freshFiles()` returned.
|
|
|
|
Lower-level reads return the decrypted model objects, which hold key material:
|
|
`lib.listCollections()`, `lib.getCollection(id)`, `lib.listFiles(collectionID)`,
|
|
`lib.getFile(collectionID, fileID)` and `lib.getFileByID(fileID)`.
|
|
|
|
### Records and change notifications
|
|
|
|
The records hold no key material and no binary, so they survive
|
|
`structuredClone` and JSON, for example across a window boundary:
|
|
|
|
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt` in
|
|
milliseconds, `fileType`, `isArchived` and `isHidden`. Optional: `caption`,
|
|
`width`, `height`, `latitude`, `longitude`, `hash` (the content hash recorded
|
|
at upload, which very old files lack), and `thumbnailPath` and `originalPath`
|
|
once the bytes are cached. A live photo's `originalPath` is its image.
|
|
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime` and
|
|
`fileIDs`, newest first.
|
|
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
|
|
|
`lib.snapshot()` returns a `LibrarySnapshot`, albums newest updated first and
|
|
photos newest first. `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|
whenever a refresh changes the records, and returns `{ unsubscribe }`. A
|
|
`LibraryChange` holds `albumsChanged`, `photosChanged`, `fileIDsRemoved`,
|
|
`albumIDsRemoved` and `refreshedAt`. A refresh that changes nothing delivers
|
|
nothing.
|
|
|
|
### Thumbnails, machine-learning data and backup
|
|
|
|
`lib.thumbnails.ensure({ fileIDs, priority, signal?, onProgress? })` fetches
|
|
thumbnails ahead of need through the thumbnail pool, each file once, and returns
|
|
one `EnsureResult` per file: `{ fileID, path?, error? }`. `priority` is
|
|
`"visible"`, `"ahead"` or `"background"`. Only `"visible"` goes ahead of
|
|
background work.
|
|
|
|
`lib.mldata` searches the CLIP index built from Ente's per-file machine-learning
|
|
data:
|
|
|
|
- `forFile({ fileID })` returns `Promise<MLData | undefined>`, the whole stored
|
|
payload (faces, landmarks and embedding), read from disk on demand.
|
|
- `similar({ fileID, limit? })` and `searchByEmbedding({ embedding, limit? })`
|
|
return `SimilarResult[]`, each `{ fileID, score }`, most similar first. The
|
|
score is the cosine similarity. `limit` defaults to 20. quak bundles no text
|
|
encoder, so `searchByEmbedding` takes a query vector the caller produced
|
|
elsewhere.
|
|
|
|
`await lib.backup(opts?)` returns a `BackupResult` and writes the tree described
|
|
under "The backup directory". It takes the lock in the download directory and
|
|
fails at once, with an error whose `code` is `ELOCKED`, while another backup of
|
|
it runs. Then it waits for a refresh as `fresh()` does. It puts every original
|
|
in scope at its save path as `photo.download()` does. It waits for a
|
|
machine-learning fetch, and rebuilds the JSON files and symlinks. Once the lock
|
|
is lost, it rejects with `lost the lock on <dir>: <reason>` before it starts
|
|
another file.
|
|
|
|
`BackupOptions`:
|
|
|
|
- `downloadDirectory`: where the tree lives. Default: the library's.
|
|
- `includeOriginals`: fetch and store originals. Default `true`.
|
|
- `includeThumbnails`: also store thumbnails as `thumbnails/<fileID>.jpg` beside
|
|
`collections/`. Default `false`. `quak backup` does not set it.
|
|
- `onlyAlbumNames`: back up only the albums with these names.
|
|
- `verify`: as `--verify`. Default `false`.
|
|
- `onProgress`: called with each progress line and, for a line that reports a
|
|
failure, the level `"error"`.
|
|
- `lock`: a lock the caller took itself.
|
|
|
|
A caller can take the lock before it opens its library, as `quak backup` does:
|
|
`await lockBackupDirectory(dir)` takes it, failing the same way, and returns a
|
|
`BackupLock`. Its `release()` removes the lock unless it was lost, and its
|
|
`lost` holds why it was lost. The caller passes it to the backup as `lock` and
|
|
releases it once the backup ends.
|
|
|
|
### Request pools
|
|
|
|
`RequestPools` holds three independent bounded pools, because Ente meters these
|
|
three kinds of traffic differently: metadata, with 10 slots, content, with 5,
|
|
and thumbnails, with 25. Construct one with
|
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
|
and pass it as `pools`. Each pool serves on-demand work ahead of background
|
|
work, and runs a task submitted under a key once while a task with that key is
|
|
still pending. An idle pool never lends its slots to a busy one.
|
|
|
|
`lib.backup()`, and so `quak backup`, works on up to the content pool's limit of
|
|
originals at once, 5 unless the library was opened with other `pools`. The limit
|
|
covers everything it does for one original, hashing for `verify` included.
|
|
|
|
### Retries and timeouts
|
|
|
|
Every request in the library goes through one policy. A request is repeated only
|
|
when repeating it could produce a different answer:
|
|
|
|
- An `ApiError` with a 5xx status is retried. So are `408` and `429`, the two
|
|
4xx codes that are statements about timing rather than about the request.
|
|
- Every other 4xx is not retried. A 404 in particular is an answer, and
|
|
`list-missing-thumbnails` depends on getting it promptly and once.
|
|
- A transport failure is retried: a rejected `fetch`, `ECONNRESET`, `ETIMEDOUT`,
|
|
a DNS or TLS failure, and one of quak's own deadlines. Node's `fetch` rejects
|
|
with a plain `TypeError`, so every `TypeError` is retried. The error code is
|
|
looked for along the error's `cause` chain, which is where Node's `fetch` puts
|
|
it.
|
|
- A truncated download is retried.
|
|
- Anything else is not retried. This includes an authentication failure in the
|
|
encrypted stream that is not truncation.
|
|
|
|
The binary that `make install` builds runs under bun, whose `fetch` rejects with
|
|
a plain `Error` that has no `cause` and carries a code of bun's own. quak
|
|
retries a refused connection, a name that does not resolve, a reset connection
|
|
and a deadline under bun as it does under Node. Bun reports a refused
|
|
connection, and a failed TLS handshake, as `ConnectionRefused`. For a name that
|
|
does not resolve it gives `ConnectionRefused` on some requests and
|
|
`FailedToOpenSocket` on others. A connection reset or closed before the response
|
|
ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`.
|
|
A certificate bun rejects, with a code such as `DEPTH_ZERO_SELF_SIGNED_CERT`, is
|
|
not retried.
|
|
|
|
Backoff is exponential with full jitter. The delay before retry _n_ is
|
|
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
|
|
is the ceiling, and the wait is drawn at random below it. So a client that lost
|
|
many parallel downloads to one blip does not send them all again at the same
|
|
instant. The numbers are set through `ApiClientOptions.retry`:
|
|
|
|
- `attempts`: total calls, not retries. Default `4`; `quak backup` uses `10`.
|
|
- `baseDelayMs`: the ceiling for the first retry's delay. Default `500`;
|
|
`quak backup` uses `1000`.
|
|
- `maxDelayMs`: the upper bound on that ceiling. Default `10000`; `quak backup`
|
|
uses `60000`.
|
|
|
|
With the defaults, a request that keeps failing gives up after at most three and
|
|
a half seconds of waiting. `quak backup` usually runs from cron with nobody
|
|
watching, so every request it makes uses its own numbers, exported as
|
|
`UNATTENDED_RETRY_OPTIONS`. Under them, a request that keeps failing gives up
|
|
after at most 243 seconds of waiting. It usually gives up after about half that,
|
|
since each wait is drawn at random below its ceiling. Every other command uses
|
|
the defaults. A library user gets the backup's budget by passing
|
|
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` can
|
|
be injected through the same option, which is how the test suite exercises the
|
|
policy without waiting. `onRetry`, given there too, is called before each wait
|
|
with the error, the attempt's number and the wait.
|
|
|
|
Two deadlines apply, each renewed for every attempt:
|
|
|
|
- `requestTimeoutMs`, default `30000`, bounds a whole JSON request or upload.
|
|
- `downloadTimeoutMs`, default `60000`, is an idle deadline on a file or
|
|
thumbnail download: the download is aborted when no bytes have arrived for
|
|
that long.
|
|
|
|
They differ because a download's length depends on the file and the link. A
|
|
whole-transfer deadline short enough to catch a hung connection would cancel a
|
|
large video on a slow link that is still making progress. The download deadline
|
|
restarts each time bytes arrive, so a slow download runs as long as it keeps
|
|
moving, and one that stalls is aborted after 60 seconds of silence. It covers
|
|
the wait for the headers as well as the body. There is no limit on the total
|
|
length of a download.
|
|
|
|
A request that changes state is not blindly replayed. Every `POST` and `PUT` in
|
|
the endpoint list under "HTTP API" goes through this rule; verifying a second
|
|
factor, for one, uses up one of a small number of attempts. Such a request is
|
|
retried only when every error code in its `cause` chain proves that no TCP
|
|
connection to the server ever existed. Three codes prove that: `ENOTFOUND` and
|
|
`EAI_AGAIN`, when name resolution produced no address, and `ECONNREFUSED`, when
|
|
the peer refused the connection. A 5xx, a reset mid-flight and a deadline are
|
|
all left to the caller, because each of them can happen after the server has
|
|
already acted. These requests do not follow redirects either: a redirect means
|
|
the server already received the request, so it is reported as an error and not
|
|
retried.
|
|
|
|
The routing errors `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are left out of
|
|
that rule although they look like connect-time failures. On Linux they are also
|
|
delivered on an established socket, after the request may have been written.
|
|
They stay retryable for requests that change nothing. So do bun's
|
|
`ConnectionRefused` and `FailedToOpenSocket`, on which a `POST` or `PUT` is not
|
|
replayed either. When a connection bun kept open from an earlier request closes
|
|
before any answer, bun sends the request again on a new connection. If that one
|
|
fails, bun gives one of these codes although the server may already have the
|
|
request. The upload of a thumbnail to its presigned URL is exempt from the rule.
|
|
It stores one whole object at one key in one request, so replaying it has no
|
|
partial state to damage.
|
|
|
|
A download is retried as a whole: the request, the reading of the body and the
|
|
decryption. A socket reset after the response headers have arrived surfaces in
|
|
the download layer rather than in the HTTP client. That is the common failure
|
|
for a large file over a CDN. The encrypted stream cannot be resumed and the
|
|
endpoints have no Range support, so a retry starts the file over. Each attempt
|
|
writes its own temporary files and removes them if it fails. Only the attempt
|
|
that completes renames anything into place.
|
|
|
|
One imprecision is deliberate. When a body ends partway through an encrypted
|
|
chunk, the authentication check fails and carries no sign of why, so a cut
|
|
connection and corrupt bytes look the same. quak reports that as truncation,
|
|
which means it is retried. For a body of more than one chunk the distinction is
|
|
real. A chunk that fails while the stream carries on past it stays an
|
|
authentication failure and is not retried. For a single-chunk body, which is
|
|
most thumbnails and every small file, a wrong key, server-side corruption and a
|
|
cut mid-chunk all look alike and all get retried. The cost is bounded by the
|
|
attempt count, and it buys never keeping a truncated file.
|
|
|
|
### The `Client`
|
|
|
|
`Client.login({ email, password, totp?, emailOTP?, apiOptions? })` logs in and
|
|
resolves to a `Client`. `totp` and `emailOTP` are callbacks that return the code
|
|
when the account asks for one. Without the callback the account needs, login
|
|
throws. A passkey challenge always throws. `apiOptions` configures the HTTP
|
|
client (see below).
|
|
|
|
The `Client` holds the session token, the master key, the secret key and the
|
|
public key in memory. There is no on-disk session store in the library; the
|
|
caller decides how to keep a session. `client.toJSON()` returns a
|
|
`ClientSnapshot`, a plain object with the keys in base64, which the caller can
|
|
write wherever it likes. `Client.fromJSON(snapshot, apiOptions?)` restores a
|
|
working client from that snapshot without logging in again. It checks every
|
|
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. After
|
|
it, every other method on that client throws. It does not contact the server, so
|
|
the token stays valid there and in any saved snapshot;
|
|
`await client.logoutOnServer()` ends the session on the server first.
|
|
|
|
`client.whoami()` returns `{ email, userID }`.
|
|
`client.collectionsSince({ sinceTime })` and
|
|
`client.filesSince({ collectionID, collectionKey, sinceTime })` enumerate the
|
|
changes since a microsecond timestamp, with `0` for everything. Each returns the
|
|
decrypted live records, the IDs of deleted records, the records that did not
|
|
decrypt with the reason, and the cursor to pass next time.
|
|
`client.listCollections()` and `client.listFiles(collectionID, collectionKey)`
|
|
are the same from the beginning. `client.fetchMLData({ fileIDs, fileKeys })`
|
|
fetches and decrypts the machine-learning data of up to 200 files.
|
|
`client.downloadFile(file, outPath?)` and
|
|
`client.downloadThumbnail(file, outPath?)` download and decrypt one file to
|
|
`outPath`, or to the working directory under the name `quak get` would use.
|
|
|
|
`ApiClientOptions`:
|
|
|
|
- `apiOrigin`: the API server. Default `https://api.ente.io`. A self-hosted
|
|
server is set here; file and thumbnail downloads then go through it too.
|
|
- `filesOrigin` and `thumbsOrigin`: the download hosts used with the default API
|
|
server.
|
|
- `authToken`: a session token to send from the start.
|
|
- `fetch`: a replacement for the global `fetch`.
|
|
- `retry`, `requestTimeoutMs` and `downloadTimeoutMs`: see "Retries and
|
|
timeouts".
|
|
- `onRequest`: called once for each request sent, and once for each attempt of a
|
|
retried one. It gets the method, the URL and the status of the answer or the
|
|
error. The URL of a thumbnail upload is given without its query, which holds
|
|
the upload's signature.
|
|
|
|
The package also exports the pieces the `Client` is built from: `ApiClient`,
|
|
`ApiError`, `TruncatedStreamError`, `withRetry`, `isRetryable`,
|
|
`isSafeToReplay`, `DEFAULT_RETRY_OPTIONS`, `beginLogin`, `submitTOTP`,
|
|
`requestEmailOTP`, `submitEmailOTP`, `unwrapAuth`, `decryptCollection`,
|
|
`decryptFile`, `downloadFile`, `downloadThumbnail` and `VERSION`.
|
|
|
|
### Key types by source file
|
|
|
|
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
|
`LibraryClient`, `RefreshEvent`
|
|
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
|
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup`
|
|
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
|
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
|
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
|
`LibraryChange`
|
|
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
|
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
|
- `src/exif.ts`: `ExifTags`, `PhotoExif`
|
|
- `src/backup.ts`: `BackupLock`, `BackupOptions`, `BackupResult`, `BackupError`
|
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
|
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
|
|
- `src/errors.ts`: `ApiError`, `TruncatedStreamError`
|
|
- `src/retry.ts`: `withRetry`, `isRetryable`, `isSafeToReplay`, `RetryOptions`
|
|
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileType`,
|
|
`CollectionType`, `RawCollection`, `RawEnteFile`
|
|
- `src/thumbnails.ts`: `MissingThumbnailInfo`, `ThumbnailFixResult`
|
|
|
|
`src/index.ts` lists what the package exports. `MLDataAPI`, `SimilarResult`,
|
|
`MissingThumbnailInfo` and `ThumbnailFixResult` are not among its exports; they
|
|
are reached through `lib.mldata` and the command line.
|
|
|
|
## Examples
|
|
|
|
`examples/download-albums.ts` downloads every album's photos and their metadata
|
|
into a directory, `photos` in the working directory unless you name another. It
|
|
uses only the library's public API. The build compiles it; run it after
|
|
`yarn install`:
|
|
|
|
```bash
|
|
yarn build
|
|
QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
|
|
```
|
|
|
|
It opens the library with `precacheThumbnails` and `precacheOriginals` off, as
|
|
`quak backup` does, so the only file content it fetches is the originals it
|
|
saves. It asks on the terminal for a second-factor code or an email code when
|
|
the account requires one. It writes:
|
|
|
|
- each photo's original at its save path under `dir`, as `photo.download()`
|
|
writes it, and for a live photo its image, its video and the `.livephoto.json`
|
|
file naming them;
|
|
- beside each original, a JSON file named after it with `.json` added, such as
|
|
`2026-03-01.12345.jpg.json`. It holds the photo's record without its cache
|
|
paths, and under `exif` every EXIF tag of the photo;
|
|
- `albums/<collectionID>.json` for each album, holding its `collectionID`, its
|
|
`name` and, under `savePaths`, the save paths of its photos relative to `dir`,
|
|
newest first.
|
|
|
|
A photo in several albums is downloaded once. A second run downloads nothing and
|
|
rewrites only the JSON files whose content changed. A failed download stops the
|
|
run; running it again carries on, since every photo already saved is skipped.
|
|
|
|
## Rationale
|
|
|
|
Ente encrypts photos end to end, so a backup of an Ente account cannot be made
|
|
from the server's side. Only a client that holds the account's keys can read the
|
|
photos. Ente's own clients are built for a person at a screen. quak is the
|
|
client for scripts and for cron.
|
|
|
|
quak is a library first, so that a program of your own can read an account the
|
|
way quak's command line does. The backup it writes is plain files in a tree a
|
|
photo viewer can open, with no quak needed to read it. One failed file never
|
|
ends a run; it is recorded and tried again next time.
|
|
|
|
## Design
|
|
|
|
quak is a TypeScript library with a thin command-line wrapper. The library does
|
|
the work; the command line is for people and cron.
|
|
|
|
### Layout
|
|
|
|
```
|
|
quak/
|
|
src/
|
|
crypto/ libsodium primitives: boxes, streams, key derivation,
|
|
hashing
|
|
api/ the HTTP client (ApiClient)
|
|
auth/ the login flow (SRP, email code, TOTP) and key unwrap
|
|
model/ decrypted Collection, File and Metadata types, and
|
|
the functions that decrypt them
|
|
download/ streaming download and decryption of files and
|
|
thumbnails
|
|
library/ the cache-backed Library: metadata store, read
|
|
surface, records, content cache, precache,
|
|
machine-learning data and search, request pools
|
|
backup.ts the full-account backup
|
|
metadata-backup.ts
|
|
backup-metadata: everything Ente records, as JSON
|
|
exif.ts EXIF read from an image's bytes with exifreader
|
|
mldata-fetch.ts fetch and decrypt per-file machine-learning data
|
|
clip-keywords.ts
|
|
a file's keywords from its CLIP embedding
|
|
clip-keyword-vectors.ts
|
|
Ente's Discover categories with their text
|
|
embeddings, written by bin/clip-keyword-vectors
|
|
filename.ts safe file names from server metadata
|
|
errors.ts error types shared across layers, and how an error
|
|
is described
|
|
retry.ts the retry classifier and the backoff loop
|
|
thumbnails.ts find and regenerate missing thumbnails
|
|
client.ts the Client class assembled from the above
|
|
cli-commands.ts the command line's commands as functions returning
|
|
exit codes
|
|
cli-log.ts the command line's log level and the one function
|
|
its commands write stderr lines through
|
|
cli-output.ts how the command line prints a file's title and time
|
|
cli-read.ts how the read commands get current data
|
|
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 the package's exports
|
|
bin/
|
|
quak.ts the command-line entrypoint (commander.js)
|
|
clip-keyword-vectors, clip-keyword-vectors.py
|
|
write src/clip-keyword-vectors.ts and a test
|
|
fixture (see "Source attribution")
|
|
examples/
|
|
download-albums.ts
|
|
download every album's photos and metadata
|
|
test/ unit and integration tests (vitest)
|
|
Makefile
|
|
Dockerfile lint phase, test phase, compile
|
|
package.json
|
|
tsconfig.json
|
|
```
|
|
|
|
`make build` compiles that tree into `dist/`, keeping its shape: the library
|
|
lands in `dist/src/`, the examples in `dist/examples/` and the command line in
|
|
`dist/bin/quak.js`. `package.json` points `main` and `types` at
|
|
`dist/src/index.js` and `dist/src/index.d.ts`, and `bin` at `dist/bin/quak.js`.
|
|
The compiler's `rootDir` is the repository root rather than `src/`, because
|
|
`bin/` and `examples/` are compiled too.
|
|
|
|
### Cryptography
|
|
|
|
All cryptography is done by `libsodium-wrappers-sumo`, with two exceptions. The
|
|
SRP handshake uses `fast-srp-hap`, and the MD5 checksum sent with a thumbnail
|
|
upload uses Node's `node:crypto`. The "sumo" build is required for
|
|
`crypto_pwhash`, the Argon2id function. No cryptography is written by hand.
|
|
|
|
Login derives the keys in this order:
|
|
|
|
1. The user enters the password.
|
|
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
|
|
with server-issued `memLimit` and `opsLimit`, produces a 32-byte key
|
|
encryption key, the KEK.
|
|
3. For SRP login, `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte
|
|
subkey from the KEK with subkey ID 1 and context `loginctx`. Its first 16
|
|
bytes are the SRP password. SRP is a protocol that proves the password to the
|
|
server without sending it.
|
|
4. When SRP completes, the server returns the key attributes and an encrypted
|
|
token, or first asks for a second factor. quak answers a TOTP request with
|
|
the code (`POST /users/two-factor/verify`), after which the server returns
|
|
them. quak cannot answer a passkey request. When the account has email MFA on
|
|
(`isEmailMFAEnabled`), an email code replaces SRP, and the server returns
|
|
them after it.
|
|
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
|
yields the 32-byte master key.
|
|
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
|
key yields the user's X25519 private key. The matching public key arrives in
|
|
cleartext.
|
|
7. `crypto_box_seal_open` over the encrypted token with the user's key pair
|
|
yields the token's bytes. Encoded as URL-safe base64 with padding, they are
|
|
the `X-Auth-Token` value for every later call.
|
|
|
|
A collection the account owns carries its key as a secretbox under the master
|
|
key. A collection shared with the account carries it as a sealed box to the
|
|
account's public key. Each file's key is a secretbox under its collection's key.
|
|
File metadata is a secretstream blob, a single chunk with the final tag, under
|
|
the file key. File content is a chunked `crypto_secretstream_xchacha20poly1305`
|
|
stream under the file key, with 4 MiB plaintext chunks and 17 bytes of
|
|
authentication overhead per chunk. A thumbnail is such a stream too. For a
|
|
thumbnail upload, `encryptBlob` does the push side: one chunk with the final
|
|
tag, returning the header and the ciphertext.
|
|
|
|
### HTTP API
|
|
|
|
The production endpoints are:
|
|
|
|
- API: `https://api.ente.io`
|
|
- File downloads: `https://files.ente.io/?fileID=<id>`
|
|
- Thumbnail downloads: `https://thumbnails.ente.io/?fileID=<id>`
|
|
|
|
A self-hosted server is set through the `apiOrigin` option. File downloads then
|
|
go through `<apiOrigin>/files/download/<id>` and thumbnail downloads through
|
|
`<apiOrigin>/files/preview/<id>`.
|
|
|
|
Every authenticated call carries two headers: `X-Auth-Token`, the token from
|
|
login, and `X-Client-Package`, which names the client as `berlin.sneak.quak`.
|
|
|
|
Endpoints used:
|
|
|
|
- `GET /users/srp/attributes?email=<email>`: fetch the SRP and key-derivation
|
|
parameters.
|
|
- `POST /users/srp/create-session`: begin the SRP handshake.
|
|
- `POST /users/srp/verify-session`: complete SRP; receive the second-factor
|
|
challenge or the encrypted token with the key attributes.
|
|
- `POST /users/ott` and `POST /users/verify-email`: the email code, used instead
|
|
of SRP when the SRP attributes have `isEmailMFAEnabled` set.
|
|
- `POST /users/two-factor/verify`: the TOTP second factor.
|
|
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
|
- `GET /collections/v2?sinceTime=<usec>`: list the collections changed since a
|
|
microsecond timestamp; `0` lists them all.
|
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list the files
|
|
changed in a collection; quak pages while `hasMore` is true.
|
|
- `GET https://files.ente.io/?fileID=<id>`: download a file's encrypted bytes.
|
|
- `GET https://thumbnails.ente.io/?fileID=<id>`: download a thumbnail's
|
|
encrypted bytes.
|
|
- `POST /files/data/fetch`: fetch the encrypted machine-learning data of a batch
|
|
of files.
|
|
- `POST /files/upload-url`: get a presigned upload URL, for thumbnail repair.
|
|
- `PUT /files/thumbnail`: register an uploaded thumbnail's object key.
|
|
|
|
## Source attribution
|
|
|
|
The cryptographic protocol and wire format are Ente's, read from the Ente
|
|
clients at <https://github.com/ente-io/ente>. No code from those projects is
|
|
included; quak's implementation is written in TypeScript from reading them.
|
|
|
|
The keywords come from Ente too. `src/clip-keyword-vectors.ts` holds the
|
|
Discover categories of Ente's photos app, from
|
|
`mobile/apps/photos/assets/discover.json`, each with the text embedding of its
|
|
query from Ente's CLIP text model. `bin/clip-keyword-vectors` writes that file,
|
|
and the CLIP embedding of a public-domain photo that the tests use. It runs
|
|
Ente's models in Docker, with every download pinned by SHA-256 hash. It is run
|
|
by hand only, followed by `make fmt`.
|
|
|
|
## Entrypoints
|
|
|
|
This repository adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard. Normalized scripts in `script/` are the entrypoints for the
|
|
development workflow, and most Makefile targets are thin shims that call them.
|
|
The scripts are POSIX `sh`, not bash, so they run in minimal containers such as
|
|
Alpine. The scripts marked "ours" are this repository's own additions to the
|
|
standard.
|
|
|
|
- `script/bootstrap`: install all dependencies. It installs `make`, `git`, Node
|
|
and `yarn` when they are missing, then runs `yarn install --frozen-lockfile`.
|
|
- `script/setup`: set up the repo for development after a fresh clone. It runs
|
|
`script/bootstrap`, then `script/install-precommit`.
|
|
- `script/projectname` (ours): print the project name. `script/docker` uses it
|
|
for the image tag.
|
|
- `script/build` (ours): compile the TypeScript sources into `dist/` and stamp
|
|
the version into `dist/package.json`. Then check that the entrypoints
|
|
`package.json` declares (`main`, `types`, `bin`) were written, and make the
|
|
command line executable.
|
|
- `script/version` (ours): print the version `script/build` stamps (see
|
|
"Version").
|
|
- `script/test`: run the test suite, by building the `test` phase of the
|
|
`Dockerfile`. Requires Docker.
|
|
- `script/lint`: run eslint and a prettier check, by building the `lint` phase
|
|
of the `Dockerfile`. Requires Docker.
|
|
- `script/fmt`: format all files with prettier. It writes.
|
|
- `script/fmt-check`: check formatting on the host. It is read-only.
|
|
- `script/check` (ours): run all checks: `test`, `lint`, `fmt-check`.
|
|
- `script/docker`: build the image, tagged with `script/projectname`.
|
|
- `script/cibuild`: what CI runs: `script/bootstrap`, then `script/check`, then
|
|
the image build.
|
|
- `script/precommit` (ours): run by the git pre-commit hook. It runs
|
|
`script/lint` and `script/fmt-check`, and not the tests, so that a commit of
|
|
failing tests can land (see "Development workflow").
|
|
- `script/install-precommit` (ours): install the git pre-commit hook.
|
|
`make hooks` calls it.
|
|
|
|
`make build-bin` bundles the built `dist/` into one binary, `bin/quak`, with bun
|
|
run through `nix-shell -p bun`. `make install` runs it and copies the binary to
|
|
`~/bin/quak`.
|
|
|
|
### Linting and testing
|
|
|
|
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
|
|
repo into a node image pinned by digest and runs eslint and
|
|
`prettier --check .`. The `test` phase does the same with the suite, as the
|
|
image's non-root `node` user, under a 90-second `timeout` that catches a hung
|
|
test. A suite that fails is run again with verbose output. `script/lint` and
|
|
`script/test` each build one phase with
|
|
`docker build --no-cache --target <phase>`. Neither script runs the tools on the
|
|
host, so Docker is required, and the scripts also work where the Docker daemon
|
|
is remote and bind mounts are impossible.
|
|
|
|
The last stage of the `Dockerfile` compiles the package. It copies a file from
|
|
each phase, so it cannot be built unless lint and the tests pass. The image
|
|
build in `script/cibuild` therefore runs lint and the tests a second time, after
|
|
`script/check` has run them.
|
|
|
|
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
|
|
Docker would otherwise serve the lint and test steps from its cache, nothing
|
|
would run, and the build would still exit 0.
|
|
|
|
`script/fmt-check` runs prettier on the host. Its verdict matches the `lint`
|
|
phase's, because prettier is pinned to one version and installed from
|
|
`yarn.lock` in both places. Prettier reads `.gitignore` as its default ignore
|
|
file, which is why `.dockerignore` keeps `.gitignore` in the build context.
|
|
|
|
### Version
|
|
|
|
`quak --version` reports the `version` in `dist/package.json`, which
|
|
`script/build` writes after compiling. The repo's own `package.json` keeps
|
|
`0.0.0`, and that is what the tests, which run from source, report.
|
|
`script/version` decides what is written:
|
|
|
|
- the `VERSION` environment variable, or the `Dockerfile`'s `VERSION` build
|
|
argument (`--build-arg VERSION=...`), when one is given and not empty;
|
|
- otherwise, in a checkout with `.git`, what `git describe --tags --always`
|
|
prints. That is the tag on a tagged commit. On a later commit it is the tag,
|
|
the number of commits since it and the short commit, as in
|
|
`v1.2.3-4-gabc1234`. When no tag is reachable it is the short commit;
|
|
- otherwise, as in a source tarball, the version `package.json` declares.
|
|
|
|
The build fails if the checkout has `.git` and the version still comes out
|
|
empty, `dev` or `unknown`, because such a build could not be traced to its
|
|
commit.
|
|
|
|
`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of
|
|
a clone stamps the commit it was built from. A shallow clone stamps a tag only
|
|
when the cloned commit itself carries one, and otherwise the short commit.
|
|
`.dockerignore` does leave out `.git/config`, which holds the clone's remote URL
|
|
and any credential in it; `git describe` does not need that file.
|
|
`script/docker` and `script/cibuild` pass the version they resolve on the host,
|
|
with `--dirty`, as the build argument, which takes precedence. The image's
|
|
`org.opencontainers.image.version` label carries that build argument only, so a
|
|
build given none leaves it empty. `make build-bin` bundles the built `dist/`, so
|
|
the single binary reports the stamped version too.
|
|
|
|
## Development workflow
|
|
|
|
All work on quak is test-driven.
|
|
|
|
1. Every change starts on a feature branch off `next`, and its pull request
|
|
targets `next`.
|
|
2. The first commit on the branch is the tests for what is being added or
|
|
changed. Those tests fail at that commit; the branch is red until the
|
|
implementation lands.
|
|
3. Later commits add the implementation and any refactors needed to make the
|
|
tests pass.
|
|
4. A pull request is merged into `next` only when `make check` is green. Once it
|
|
has passed review, the repository manager squash-merges it into `next`. Only
|
|
sneak merges `next` into `main`. `main` and `next` are always green. CI runs
|
|
`script/cibuild`, which builds the `Dockerfile`: its `lint` and `test`
|
|
phases, then the compile. So neither a red branch nor one that does not
|
|
compile can pass CI.
|
|
5. Tests are the canonical API documentation for this library. Every test file
|
|
is commented well enough that a reader who has never seen quak can learn how
|
|
to use it from the tests alone. Comments explain why a behavior matters, not
|
|
just what the assertion checks.
|
|
6. Test fixtures, such as cryptographic vectors, recorded HTTP responses and
|
|
sample files, are committed beside their tests. Where possible they are
|
|
generated by deterministic helpers in `test/`, so a reviewer can reproduce
|
|
them by running the helper.
|
|
7. `git rebase -i` is allowed on a feature branch before merge, to clean the
|
|
tests-then-implementation sequence into reviewable commits. The final history
|
|
must still show tests landing before, or with, the matching implementation.
|
|
8. The pre-commit hook that `make hooks` installs runs `script/precommit`, which
|
|
runs `script/lint` and `script/fmt-check` and not the tests. This is
|
|
deliberate, so that the commit of failing tests can land. CI runs the tests,
|
|
so a red branch still cannot reach `next`.
|
|
|
|
### Rules for contributors
|
|
|
|
These apply to people and to coding agents alike.
|
|
|
|
- Read and follow `REPO_POLICIES.md` in the repo root. It is copied from
|
|
<https://git.eeqj.de/sneak/prompts> and covers repository structure, tooling,
|
|
Makefile targets, Dockerfile conventions, dependency pinning and commit
|
|
hygiene. Every dependency is pinned in `yarn.lock`. Never `git add -A`. Never
|
|
force-push `main`.
|
|
- Follow the workflow above: branches off `next`, tests first and committed
|
|
failing, and `next` merged into `main` by sneak alone.
|
|
- Before every commit, `make lint` and `make fmt-check` must pass. `make lint`
|
|
builds the `lint` phase of the `Dockerfile`, so it needs Docker. The
|
|
pre-commit hook enforces exactly that. `make check`, which also runs the
|
|
tests, must pass before a merge into `next`. Never invoke eslint or prettier
|
|
directly; linting runs in the container only.
|
|
- Format with `make fmt`: prettier with 4-space indents and `proseWrap: always`
|
|
for markdown. Use `yarn`, not `npm`.
|
|
- Tests use vitest and live in `test/`, mirroring `src/`. Use `mkdtempSync` for
|
|
temporary directories, never a path built from a timestamp. The 90-second
|
|
`timeout` in the `test` phase of the `Dockerfile` is a backstop for a hung
|
|
test, not a budget.
|
|
- Code style: `const` for everything, `let` only when a variable is reassigned,
|
|
never `var`. Avoid comments that restate the code. No cryptography written by
|
|
hand. `LLM_PROSE_TELLS.md` in the prompts repo applies to any prose written in
|
|
this repository: the README, comments and commit messages.
|
|
|
|
## TODO
|
|
|
|
`TODO.md` in the repo root is the work log: the next step, the steps done and
|
|
the steps planned. Open now:
|
|
|
|
- [ ] Check quak against a live Ente account. This needs an account only sneak
|
|
has.
|
|
|
|
Planned for a desktop client, in a separate repository, on top of this library:
|
|
|
|
- [ ] An Electron app skeleton that uses this library.
|
|
- [ ] A local cache in SQLite keyed on `(collectionID, fileID, updationTime)`.
|
|
- [ ] A background sync worker that streams new files into the cache.
|
|
- [ ] A gallery UI: thumbnails, full-image view, basic search.
|
|
- [ ] Upload, delete and share operations in the library.
|
|
|
|
Tagging and releases are decided by sneak alone, and happen only when he
|
|
declares one.
|
|
|
|
## License
|
|
|
|
WTFPL. See [LICENSE](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|