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

`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 is contained in:
sneak committed 2026-11-13 21:19:24 +00:00
1 parent 9bd7e8f099
commit 6f8f85fff5
10 files changed
+768 -68

No files matched your search

+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 }),
};
}