Author SHA1 Message Date
sneak b6020aca4e quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 4m25s
`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
2026-11-13 18:48:55 +00:00
clawbot 9bd7e8f099 make build-bin: on macOS, build with bun's pinned release build (closes #251)
check / check (push) Successful in 2m57s
nixpkgs points its macOS bun at an ICU in the nix store, and bun build
--compile copied that reference into bin/quak, so nix-collect-garbage could
break the cron backup. script/build-bin now downloads bun 1.3.13's release
archive on macOS too, Intel (baseline) or Apple silicon, checks its SHA-256,
and compiles with it. Those builds load only /usr/lib libraries. nix-shell is
no longer used; any system without a bun release build stops with an error.
The hash check falls back to shasum where sha256sum is missing.

Model: opus-5-5
2026-11-12 02:17:25 +01:00
7 changed files with 241 additions and 15 deletions

No files matched your search

+25 -5
View File
@@ -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 <url>]
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 <url>]`
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 "<url>": 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
+11 -2
View File
@@ -74,7 +74,11 @@ const context = (): CliContext => ({
sessionDir: paths.data,
cacheDir: program.opts<ProgramOptions>().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<number>): Promise<void> =>
program
.command("login")
.description("Log in to an Ente account and save the session")
.action(() => run(loginCommand(context())));
.option(
"--server <url>",
"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")
+26 -1
View File
@@ -155,8 +155,31 @@ 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.
// 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<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 +191,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());
+23 -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
@@ -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<Client> {
@@ -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 }),
};
}
+13 -2
View File
@@ -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"/,
);
});
});
+103 -4
View File
@@ -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/<id>`, 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<string>();
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 () => {
+40
View File
@@ -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<string, unknown> = { ...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/,