quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 5m0s
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:
1 parent
9bd7e8f099
commit
2492946e9d
10 files changed
+768
-68
No files matched your search
+25
-3
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user