quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 5m0s

`quak login` takes the server's API address from `--server` or
`QUAK_SERVER`. `isServerAddress` refuses anything but an http or https
URL with no query or fragment, there and in `Client.fromJSON`. The
client's snapshot keeps a non-default `apiOrigin`, so the session file
names the server and every later command uses it.

Two servers can give two accounts, and two files, the same ID. A
self-hosted account's default cache directory therefore names the
server, and `metadata.json` records it: `Library.open` deletes a cache
from another server whole. `originalOf` and `thumbnailOf` record the
account in a cache that names none before storing a file, and `open`
treats a cache with no collection as empty. This fixes
#254 (closes #254).

Model: opus-5-5
This commit was merged in pull request #253.
This commit is contained in:
clawbot committed 2026-11-13 22:57:49 +01:00
1 parent 9bd7e8f099
commit 2492946e9d
10 files changed
+768 -68

No files matched your search

+25 -3
View File
@@ -21,6 +21,7 @@ import { extname, join } from "node:path";
import {
type Client,
type ClientSnapshot,
isServerAddress,
type LoginOptions,
} from "./client.js";
import { lockBackupDirectory, type BackupLock } from "./backup.js";
@@ -56,7 +57,7 @@ export interface CliContext {
// Directory holding `session.json`.
sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its
// per-user default keyed by the account id.
// per-user default keyed by the account's server and user ID.
cacheDir?: string;
// Reads the session file into a client, or null when there is none. The
// CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client.
@@ -129,6 +130,7 @@ const parseID = (
// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch
// originals through the on-disk cache.
const readLibraryClient = (client: Client): LibraryClient => ({
apiOrigin: client.apiOrigin,
whoami: () => client.whoami(),
collectionsSince: (args) => client.collectionsSince(args),
filesSince: (args) => client.filesSince(args),
@@ -155,8 +157,23 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
onProgress: logRefresh(ctx.log),
});
export const loginCommand = async (ctx: CliContext): Promise<number> => {
// Logs in to the server `--server` or `QUAK_SERVER` names, the flag first, or
// to Ente's hosted service when neither is set. The saved session keeps the
// server, so every later command uses it.
export const loginCommand = async (
ctx: CliContext,
opts: { server?: string },
): Promise<number> => {
await init();
const server = opts.server ?? process.env.QUAK_SERVER;
if (server !== undefined && !isServerAddress(server)) {
ctx.log(
"fatal",
`Invalid server address "${server}": it must be an http or ` +
`https URL with no query or fragment`,
);
return 1;
}
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
@@ -168,6 +185,8 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
password,
totp: async () => ctx.prompt("TOTP code: "),
emailOTP: async () => ctx.prompt("Email verification code: "),
apiOptions:
server === undefined ? undefined : { apiOrigin: server },
});
saveSession(ctx.sessionDir, client.toJSON());
@@ -208,7 +227,10 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
try {
const client = ctx.loadSession(path);
if (client) {
cacheDir ??= defaultCacheDirectory(client.whoami().userID);
cacheDir ??= defaultCacheDirectory(
client.whoami().userID,
client.apiOrigin,
);
await client.logoutOnServer();
client.logout();
}
+37 -1
View File
@@ -40,6 +40,9 @@ export interface ClientSnapshot {
masterKey: string;
secretKey: string;
publicKey: string;
// The `apiOrigin` the client was given, so a restored client talks to
// the server that issued its token. Absent means the default server.
apiOrigin?: string;
}
// The result of a resumable enumeration. Live decrypted records and deleted
@@ -66,7 +69,19 @@ export interface FilesPage {
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
// Whether `text` can be an Ente server's address: an http or https URL, which
// may have a path, and no query or fragment, which request URLs could not
// carry. `quak login --server` and `Client.fromJSON` both check with it.
export const isServerAddress = (text: string): boolean =>
URL.canParse(text) &&
["http:", "https:"].includes(new URL(text).protocol) &&
!/[?#]/.test(text);
export class Client {
// The `apiOrigin` the client was given, or undefined when it was given
// none, which means Ente's hosted service. The library keeps a cache per
// server as well as per user ID.
readonly apiOrigin: string | undefined;
private readonly api: ApiClient;
private readonly email: string;
private readonly userID: number;
@@ -82,6 +97,7 @@ export class Client {
masterKey: Uint8Array,
secretKey: Uint8Array,
publicKey: Uint8Array,
apiOrigin: string | undefined,
) {
this.api = api;
this.email = email;
@@ -89,6 +105,7 @@ export class Client {
this.masterKey = masterKey;
this.secretKey = secretKey;
this.publicKey = publicKey;
this.apiOrigin = apiOrigin;
}
static async login(opts: LoginOptions): Promise<Client> {
@@ -130,12 +147,15 @@ export class Client {
unwrapped.masterKey,
unwrapped.secretKey,
unwrapped.publicKey,
opts.apiOptions?.apiOrigin,
);
}
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
// straight from `JSON.parse` of a file on disk, so every field is checked
// before use; a bad one throws an error naming it. Needs `init()` first.
// before use; a bad one throws an error naming it. The snapshot's
// `apiOrigin`, when it has one, is used instead of `apiOptions.apiOrigin`.
// Needs `init()` first.
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
const invalid = (field: string, problem: string): Error =>
new Error(`Invalid session data: ${field} ${problem}`);
@@ -152,6 +172,17 @@ export class Client {
if (!Number.isInteger(s.userID)) {
throw invalid("userID", "must be an integer");
}
if (
s.apiOrigin !== undefined &&
(typeof s.apiOrigin !== "string" || !isServerAddress(s.apiOrigin))
) {
throw invalid(
"apiOrigin",
"must be an http or https URL with no query or fragment",
);
}
const apiOrigin =
(s.apiOrigin as string | undefined) ?? apiOptions?.apiOrigin;
const key = (field: string): Uint8Array => {
const value = s[field];
if (typeof value !== "string") {
@@ -175,6 +206,7 @@ export class Client {
const api = new ApiClient({
...apiOptions,
apiOrigin,
authToken: s.token as string,
});
return new Client(
@@ -184,6 +216,7 @@ export class Client {
key("masterKey"),
key("secretKey"),
key("publicKey"),
apiOrigin,
);
}
@@ -222,6 +255,9 @@ export class Client {
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey),
...(this.apiOrigin === undefined
? {}
: { apiOrigin: this.apiOrigin }),
};
}
+65 -17
View File
@@ -119,9 +119,18 @@ export {
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
// The account's cache directory when `cacheDirectory` is not given: the
// env-paths cache directory plus the user id, so each account has its own.
export const defaultCacheDirectory = (userID: number): string =>
join(envPaths("quak", { suffix: "" }).cache, String(userID));
// env-paths cache directory plus the user id, so each account has its own. An
// account on a self-hosted server has the server's address, URL-encoded, in
// between, because two servers can give two accounts the same user id.
export const defaultCacheDirectory = (
userID: number,
apiOrigin?: string,
): string => {
const cache = envPaths("quak", { suffix: "" }).cache;
return apiOrigin === undefined
? join(cache, String(userID))
: join(cache, encodeURIComponent(apiOrigin), String(userID));
};
// Project a metadata store into by-id records, filling each record's cache
// paths from the content cache when one is given. Shared by the live read
@@ -144,6 +153,9 @@ const deriveRecordsFromStore = (
// tests drive a mock with no crypto or network; the real `Client` satisfies it
// structurally.
export interface LibraryClient {
// The server the client talks to, as `Client.apiOrigin` names it. Absent
// means Ente's hosted service.
readonly apiOrigin?: string;
whoami(): { email: string; userID: number };
collectionsSince(args: { sinceTime: number }): Promise<CollectionsPage>;
filesSince(args: {
@@ -196,8 +208,8 @@ export type RefreshProgressCallback = (event: RefreshEvent) => void;
export interface LibraryOptions {
client: LibraryClient;
// Where `metadata.json` lives. Defaults to the env-paths cache directory
// plus the user id, so each account has its own cache.
// Where `metadata.json` lives. Defaults to `defaultCacheDirectory`, so
// each account has its own cache.
cacheDirectory?: string;
// The root of every photo's save path, where `Photo.download()` and
// `lib.backup()` put originals. Defaults to `photos` in the working
@@ -300,6 +312,7 @@ export class Library {
// available (a metadata-only client with no explicit source).
private readonly cache?: ContentCache;
private readonly userID: number;
private readonly apiOrigin: string | undefined;
private readonly intervalMs: number;
private readonly onProgress?: RefreshProgressCallback;
private readonly pools: RequestPools;
@@ -346,6 +359,7 @@ export class Library {
client: LibraryClient;
store: MetadataStore;
userID: number;
apiOrigin: string | undefined;
cacheDirectory: string;
downloadDirectory: string;
intervalMs: number;
@@ -358,6 +372,7 @@ export class Library {
this.client = args.client;
this.store = args.store;
this.userID = args.userID;
this.apiOrigin = args.apiOrigin;
this.cacheDirectory = args.cacheDirectory;
this.downloadDirectory = args.downloadDirectory;
this.intervalMs = args.intervalMs;
@@ -406,8 +421,9 @@ export class Library {
// refresh: `open()` returns onto the cache as it is.
static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami();
const { apiOrigin } = opts.client;
const cacheDirectory =
opts.cacheDirectory ?? defaultCacheDirectory(userID);
opts.cacheDirectory ?? defaultCacheDirectory(userID, apiOrigin);
if (opts.downloadDirectory === "") {
throw new Error(
"library: downloadDirectory is empty (leave it out to save " +
@@ -419,14 +435,24 @@ export class Library {
let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache.
// Its records and cursor are not this account's, so delete it and the
// ML data beside it and start empty. A user ID of 0 means the cache
// was never refreshed and so holds nothing to discard.
if (store.userID !== 0 && store.userID !== userID) {
await rm(metadataPath, { force: true });
await rm(join(cacheDirectory, "mldata"), {
recursive: true,
force: true,
});
// ML data beside it and start empty. A cache from another server goes
// whole: two servers can give two files the same ID, so its originals
// and thumbnails could be served for this account's files. A user ID
// of 0 means the cache names no account, so nothing is stored in it.
if (
store.userID !== 0 &&
(store.userID !== userID || store.apiOrigin !== apiOrigin)
) {
const discard = ["metadata.json", "mldata"];
if (store.apiOrigin !== apiOrigin) {
discard.push("originals", "thumbnails");
}
for (const name of discard) {
await rm(join(cacheDirectory, name), {
recursive: true,
force: true,
});
}
store = await MetadataStore.load(metadataPath);
}
const intervalMs =
@@ -490,6 +516,7 @@ export class Library {
client: opts.client,
store,
userID,
apiOrigin,
cacheDirectory,
downloadDirectory,
intervalMs,
@@ -509,9 +536,11 @@ export class Library {
if (opts.refreshOnOpen === false) {
// The caller asks for each refresh it needs.
lib.scheduleNext();
} else if (store.loadedFromDisk) {
} else if (store.listCollections().length > 0) {
// An existing copy already answers reads; refresh in the background
// and start the interval once that first cycle settles.
// and start the interval once that first cycle settles. A cache
// that names the account but holds no collection has nothing to
// answer with, such as after `originalOf` on an empty cache.
void lib.runRefresh().then(() => lib.scheduleNext());
} else {
// Nothing was cached: wait for the first refresh to fill the store
@@ -658,6 +687,7 @@ export class Library {
"originalOf requires a library opened with a content cache",
);
}
await this.saveAccountInEmptyCache();
return this.cache.originalOf(file, opts);
}
@@ -673,9 +703,23 @@ export class Library {
"thumbnailOf requires a library opened with a content cache",
);
}
await this.saveAccountInEmptyCache();
return this.cache.thumbnailOf(file, opts);
}
// Saves the account's user ID and server in a cache that names no account
// yet, before `originalOf` or `thumbnailOf` stores a file there. A later
// `open()` for an account on another server then deletes that file (see
// `open()`), although no refresh has stored any records.
private async saveAccountInEmptyCache(): Promise<void> {
if (this.store.userID !== 0) return;
this.store.userID = this.userID;
this.store.apiOrigin = this.apiOrigin;
this.unsaved = true;
await this.store.save();
this.unsaved = false;
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the
@@ -857,8 +901,12 @@ export class Library {
// actually changed.
let changed = false;
if (this.store.userID !== this.userID) {
if (
this.store.userID !== this.userID ||
this.store.apiOrigin !== this.apiOrigin
) {
this.store.userID = this.userID;
this.store.apiOrigin = this.apiOrigin;
changed = true;
}
+11 -12
View File
@@ -1,11 +1,12 @@
// On-disk JSON metadata store for the local cache.
//
// The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental
// collections listing, the decrypted collection and file records, and the
// collections and files the refresh could not decrypt. The whole file is read
// into RAM on load and rewritten as a whole on save; there is no partial
// update and no lock file. A separate refresh unit populates the store from
// state: the user id, the server's address for an account on a self-hosted
// server, a schema version, the cursor for the incremental collections
// listing, the decrypted collection and file records, and the collections and
// files the refresh could not decrypt. The whole file is read into RAM on
// load and rewritten as a whole on save; there is no partial update and no
// lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given.
//
// The file is a cache, so it is never trusted to exist or to be intact: a
@@ -46,6 +47,7 @@ export interface Undecryptable {
interface StoredMetadata {
schemaVersion: number;
userID: number;
apiOrigin?: string;
collectionsSinceTime: Microseconds;
collections: StoredCollection[];
files: StoredFile[];
@@ -68,14 +70,10 @@ export class MetadataStore {
readonly path: string;
readonly schemaVersion = METADATA_SCHEMA_VERSION;
userID = 0;
// The account's `apiOrigin`, or undefined for Ente's hosted service.
apiOrigin: string | undefined;
collectionsSinceTime: Microseconds = 0;
// True when `load` populated this store from a valid existing file; false
// on a first run or a missing/corrupt/wrong-version file that loaded empty.
// `Library.open` reads it to decide whether the first refresh may run in
// the background (an existing copy already serves reads) or must be awaited.
loadedFromDisk = false;
private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
@@ -102,8 +100,8 @@ export class MetadataStore {
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
return store;
}
store.loadedFromDisk = true;
store.userID = parsed.userID ?? 0;
store.apiOrigin = parsed.apiOrigin;
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
for (const stored of parsed.collections ?? []) {
const collection: Collection = {
@@ -143,6 +141,7 @@ export class MetadataStore {
const model: StoredMetadata = {
schemaVersion: METADATA_SCHEMA_VERSION,
userID: this.userID,
apiOrigin: this.apiOrigin,
collectionsSinceTime: this.collectionsSinceTime,
collections: [...this.collections.values()].map((c) => ({
...c,