From b6020aca4e526a75cc28d8e70df2e8a024f76c3b Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 13 Nov 2026 18:48:55 +0000 Subject: [PATCH] quak login --server: log in to a self-hosted Ente server (closes #243) `quak login` takes the server's API address from `--server` or `QUAK_SERVER`, refusing anything that is not an http or https URL. The client's snapshot now keeps a non-default `apiOrigin`, so the session file names the server and every later command uses it. A snapshot's `apiOrigin` wins over the one in `fromJSON`'s `apiOptions`, since the token belongs to that server. Model: opus-5-5 --- README.md | 30 ++++++++-- bin/quak.ts | 13 ++++- src/cli-commands.ts | 27 ++++++++- src/client.ts | 24 +++++++- test/cli/bin.test.ts | 15 ++++- test/cli/commands.test.ts | 107 ++++++++++++++++++++++++++++++++++-- test/client/session.test.ts | 40 ++++++++++++++ 7 files changed, 241 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b80ae90..b2850dc 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ below belong to quak itself, and go before or after the command's name. ### The commands ``` -quak login +quak login [--server ] quak whoami quak logout quak collections [--json] @@ -246,13 +246,26 @@ Every command but `login` and `logout` needs a saved session. Every command but `login`, `whoami` and `logout` opens the library. How each one gets current data from the server is described under "How a command gets current data". -#### `quak login` +#### `quak login [--server ]` 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 shows as `*` characters while it is typed. +quak logs in to Ente's hosted service unless you name a self-hosted Ente server. +Name it with `--server`, or with `QUAK_SERVER` when `--server` is not given. +Give the address of the server's API, for example +`quak login --server https://ente.example.com`. The address is an http or https +URL. It may include a path, but no query or fragment. quak refuses any other +address before it asks for anything. It logs +`Invalid server address "": it must be an http or https URL with no query or fragment`, +and the exit status is 1. + +The session file keeps the server's address. Every later command, cron runs +included, uses that server without further setting. To change server, log in +again. + 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. An account whose only second factor is a passkey cannot log in with quak. When the account has @@ -597,9 +610,11 @@ Linux that is `$XDG_DATA_HOME/quak/session.json`, or mode `0600`, in a directory it creates with 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 cannot be read back -is reported as corrupt, with the reason. Run `quak logout`, then `quak login`. +account's three keys, in base64 and unencrypted. After a login to a self-hosted +server, it also holds the server's address as `apiOrigin`. Treat it as you would +treat the password. A missing file means "not logged in". A file that cannot be +read back is reported as corrupt, with the reason. Run `quak logout`, then +`quak login`. A command that needs a session exits 3 when the file is missing or corrupt. It also exits 3 when the server no longer accepts the session, and prints @@ -1477,6 +1492,11 @@ 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. +A client whose `apiOptions` named an `apiOrigin` puts it in its snapshot. The +restored client then uses that server, even when the `apiOptions` given to +`Client.fromJSON` name another. A snapshot without `apiOrigin` uses the +`apiOrigin` of those `apiOptions`, or the default server. + `Client.fromJSON` works only once libsodium, the cryptography library quak uses, is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program that restores a session in a new process first runs `await sodium.ready`, with diff --git a/bin/quak.ts b/bin/quak.ts index 9c2a588..cc37e37 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -74,7 +74,11 @@ const context = (): CliContext => ({ sessionDir: paths.data, cacheDir: program.opts().cacheDir, loadSession: (path) => loadSession(path, logRequests(log())), - login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }), + login: (opts) => + Client.login({ + ...opts, + apiOptions: { ...opts.apiOptions, ...logRequests(log()) }, + }), prompt: (message) => input({ message }), promptSecret: (message) => password({ message, mask: true }), }); @@ -85,7 +89,12 @@ const run = (command: Promise): Promise => program .command("login") .description("Log in to an Ente account and save the session") - .action(() => run(loginCommand(context()))); + .option( + "--server ", + "Address of a self-hosted Ente server's API, saved with the session " + + "(default: QUAK_SERVER, then Ente's hosted service)", + ) + .action((opts: { server?: string }) => run(loginCommand(context(), opts))); program .command("whoami") diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 6089b9b..d9952ee 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -155,8 +155,31 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise => onProgress: logRefresh(ctx.log), }); -export const loginCommand = async (ctx: CliContext): Promise => { +// 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. +// 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. +const isServerAddress = (text: string): boolean => + URL.canParse(text) && + ["http:", "https:"].includes(new URL(text).protocol) && + !/[?#]/.test(text); + +export const loginCommand = async ( + ctx: CliContext, + opts: { server?: string }, +): Promise => { 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 +191,8 @@ export const loginCommand = async (ctx: CliContext): Promise => { 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()); diff --git a/src/client.ts b/src/client.ts index 2f5ea8d..702f977 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 @@ -73,6 +76,7 @@ export class Client { private readonly masterKey: Uint8Array; private readonly secretKey: Uint8Array; private readonly publicKey: Uint8Array; + private readonly apiOrigin: string | undefined; private loggedOut = false; private constructor( @@ -82,6 +86,7 @@ export class Client { masterKey: Uint8Array, secretKey: Uint8Array, publicKey: Uint8Array, + apiOrigin: string | undefined, ) { this.api = api; this.email = email; @@ -89,6 +94,7 @@ export class Client { this.masterKey = masterKey; this.secretKey = secretKey; this.publicKey = publicKey; + this.apiOrigin = apiOrigin; } static async login(opts: LoginOptions): Promise { @@ -130,12 +136,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 +161,14 @@ export class Client { if (!Number.isInteger(s.userID)) { throw invalid("userID", "must be an integer"); } + if ( + s.apiOrigin !== undefined && + (typeof s.apiOrigin !== "string" || s.apiOrigin === "") + ) { + throw invalid("apiOrigin", "must be a non-empty string"); + } + const apiOrigin = + (s.apiOrigin as string | undefined) ?? apiOptions?.apiOrigin; const key = (field: string): Uint8Array => { const value = s[field]; if (typeof value !== "string") { @@ -175,6 +192,7 @@ export class Client { const api = new ApiClient({ ...apiOptions, + apiOrigin, authToken: s.token as string, }); return new Client( @@ -184,6 +202,7 @@ export class Client { key("masterKey"), key("secretKey"), key("publicKey"), + apiOrigin, ); } @@ -222,6 +241,9 @@ export class Client { masterKey: toBase64(this.masterKey), secretKey: toBase64(this.secretKey), publicKey: toBase64(this.publicKey), + ...(this.apiOrigin === undefined + ? {} + : { apiOrigin: this.apiOrigin }), }; } diff --git a/test/cli/bin.test.ts b/test/cli/bin.test.ts index 4f0fd4a..98464e9 100644 --- a/test/cli/bin.test.ts +++ b/test/cli/bin.test.ts @@ -2,8 +2,9 @@ * Tests for the retry options `bin/quak.ts` loads the saved session with * (`quak backup` gets the unattended ones, every other command the default), * each with the callbacks that log its requests and retries, for `quak files` - * taking its collection ID as an optional argument, and for `quak get` and - * `quak get-thumb` refusing `--collection`. + * taking its collection ID as an optional argument, for `quak get` and + * `quak get-thumb` refusing `--collection`, and for `quak login` taking + * `--server`. * * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * replaced by one that records the options it is given and reports no session, @@ -117,3 +118,13 @@ describe("bin/quak.ts get and get-thumb", () => { }, ); }); + +// The command refuses an address that is not a URL before it asks for anything +// or sends any request. +describe("bin/quak.ts login", () => { + it("hands --server to the command", async () => { + expect(await quak("login", "--server", "ente.example.com")).toMatch( + /^Invalid server address "ente\.example\.com"/, + ); + }); +}); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index d3c2530..af2a88a 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -328,6 +328,7 @@ describe("login", () => { prompts = []; vi.stubEnv("QUAK_EMAIL", "cli@example.com"); vi.stubEnv("QUAK_PASSWORD", "hunter2"); + vi.stubEnv("QUAK_SERVER", undefined); }); afterEach(() => { @@ -340,12 +341,14 @@ describe("login", () => { calls.push(opts); return loggedIn; }); - expect(await loginCommand(ctx)).toBe(0); + expect(await loginCommand(ctx, {})).toBe(0); expect(prompts).toEqual([]); expect(calls).toHaveLength(1); expect(calls[0]!.email).toBe("cli@example.com"); expect(calls[0]!.password).toBe("hunter2"); + // No server named: the client uses Ente's hosted service. + expect(calls[0]!.apiOptions).toBeUndefined(); const path = join(ctx.sessionDir, "session.json"); expect(stderr.text).toBe( "Authenticating...\n" + @@ -356,7 +359,7 @@ describe("login", () => { it("saves the session with mode 0600 in a directory with mode 0700", async () => { const ctx = loginContext(async () => loggedIn); - expect(await loginCommand(ctx)).toBe(0); + expect(await loginCommand(ctx, {})).toBe(0); expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700); const path = join(ctx.sessionDir, "session.json"); @@ -370,7 +373,7 @@ describe("login", () => { code = await opts.totp!(); return loggedIn; }); - expect(await loginCommand(ctx)).toBe(0); + expect(await loginCommand(ctx, {})).toBe(0); expect(prompts).toEqual(["TOTP code: "]); expect(code).toBe("123456"); @@ -380,13 +383,68 @@ describe("login", () => { const ctx = loginContext(async () => { throw new Error("HTTP 401 from server"); }); - expect(await loginCommand(ctx)).toBe(1); + expect(await loginCommand(ctx, {})).toBe(1); expect(stderr.text).toBe( "Authenticating...\nLogin failed: HTTP 401 from server\n", ); expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); }); + + it("logs in to the server --server names, ahead of QUAK_SERVER", async () => { + vi.stubEnv("QUAK_SERVER", "https://other.example.com"); + const calls: LoginOptions[] = []; + const ctx = loginContext(async (opts) => { + calls.push(opts); + return loggedIn; + }); + expect( + await loginCommand(ctx, { server: "https://ente.example.com" }), + ).toBe(0); + + expect(calls[0]!.apiOptions).toEqual({ + apiOrigin: "https://ente.example.com", + }); + }); + + it("logs in to the server QUAK_SERVER names without --server", async () => { + vi.stubEnv("QUAK_SERVER", "http://192.0.2.1:8080/ente"); + const calls: LoginOptions[] = []; + const ctx = loginContext(async (opts) => { + calls.push(opts); + return loggedIn; + }); + expect(await loginCommand(ctx, {})).toBe(0); + + expect(calls[0]!.apiOptions).toEqual({ + apiOrigin: "http://192.0.2.1:8080/ente", + }); + }); + + it.each([ + "", + "ente.example.com", + "ftp://ente.example.com", + "https://ente.example.com/?region=eu", + "https://ente.example.com/#top", + ])( + "refuses the server address %j before asking for anything", + async (server) => { + const ctx = loginContext(async () => { + throw new Error("login must not be called"); + }); + expect(await loginCommand(ctx, { server })).toBe(1); + + expect(prompts).toEqual([]); + expect(stderr.text).toBe( + `Invalid server address "${server}": it must be an http or ` + + `https URL with no query or fragment\n`, + ); + expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe( + false, + ); + }, + ); }); // These use a real client read from the session file, over a fake API that @@ -1851,6 +1909,47 @@ describe("an account with many albums", () => { expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false); }); + // A self-hosted server serves downloads under its own address, at + // `/files/download/`, so no request goes to Ente's hosted service. + it("login --server saves the server, and a later get sends every request to it", async () => { + vi.stubEnv("QUAK_EMAIL", "many@example.com"); + vi.stubEnv("QUAK_PASSWORD", PASSWORD); + vi.stubEnv("QUAK_SERVER", undefined); + const hosts = new Set(); + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + hosts.add( + new URL(input instanceof Request ? input.url : input).host, + ); + return server(input, init); + }) as typeof globalThis.fetch; + const ctx: CliContext = { + ...context(), + login: (opts) => + Client.login({ + ...opts, + apiOptions: { ...opts.apiOptions, fetch }, + }), + loadSession: (path) => loadSession(path, { fetch }), + }; + const out = join(root, "photo.jpg"); + try { + const opts = { server: "https://ente.example.com" }; + expect(await loginCommand(ctx, opts)).toBe(0); + requests.length = 0; + expect(await getCommand(ctx, "200", { out })).toBe(0); + } finally { + vi.unstubAllEnvs(); + } + + const path = join(ctx.sessionDir, "session.json"); + expect(JSON.parse(readFileSync(path, "utf-8")).apiOrigin).toBe( + "https://ente.example.com", + ); + expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg"); + expect(requests.at(-1)).toBe("/files/download/200"); + expect([...hosts]).toEqual(["ente.example.com"]); + }); + // `files` first fills the cache, so `get` waits for a refresh. No album has // changed since, so the refresh asks for no album's files. it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => { diff --git a/test/client/session.test.ts b/test/client/session.test.ts index 5a14775..80a8972 100644 --- a/test/client/session.test.ts +++ b/test/client/session.test.ts @@ -47,6 +47,20 @@ describe("Client.toJSON", () => { expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot); }); + it("round-trips a snapshot that names a server", () => { + const snapshot = { + ...validSnapshot(), + apiOrigin: "https://ente.example.com", + }; + expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot); + }); + + it("names the server apiOptions gave fromJSON", () => { + const apiOrigin = "https://ente.example.com"; + const client = Client.fromJSON(validSnapshot(), { apiOrigin }); + expect(client.toJSON().apiOrigin).toBe(apiOrigin); + }); + it("throws instead of emitting a snapshot without a token", () => { const client = Client.fromJSON(validSnapshot()); client.getApiClient().clearAuthToken(); @@ -77,6 +91,8 @@ describe("Client.fromJSON", () => { ["publicKey", undefined], ["publicKey", "not base64!"], ["publicKey", shortKey], + ["apiOrigin", 7], + ["apiOrigin", ""], ])("rejects %s = %j, naming the field", (field, value) => { const snapshot: Record = { ...validSnapshot() }; snapshot[field] = value; @@ -85,6 +101,30 @@ describe("Client.fromJSON", () => { ); }); + // The token belongs to the snapshot's server, so that server wins over + // the one in `apiOptions`. + it("sends requests to the snapshot's server", async () => { + const urls: string[] = []; + const client = Client.fromJSON( + { ...validSnapshot(), apiOrigin: "https://ente.example.com/" }, + { + apiOrigin: "https://other.example.com", + fetch: async (input) => { + urls.push(String(input)); + return new Response(JSON.stringify({ collections: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }, + ); + + await client.listCollections(); + expect(urls).toEqual([ + "https://ente.example.com/collections/v2?sinceTime=0", + ]); + }); + it.each([null, "a string", 42])("rejects a non-object %j", (value) => { expect(() => Client.fromJSON(value)).toThrow( /^Invalid session data: not a JSON object/,