quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 2m56s
check / check (push) Successful in 2m56s
`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:
1 parent
9bd7e8f099
commit
94283522e9
10 files changed
+700
-68
No files matched your search
+250
-17
@@ -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
|
||||
@@ -461,6 +519,16 @@ describe("logout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("names a self-hosted account's default cache directory, under its server's address", async () => {
|
||||
const ctx = { ...logoutContext(200), cacheDir: undefined };
|
||||
const apiOrigin = "https://ente.example.com";
|
||||
saveSession(ctx.sessionDir, { ...snapshot, apiOrigin });
|
||||
expect(await logoutCommand(ctx)).toBe(0);
|
||||
expect(stderr.text).toContain(
|
||||
`Cache directory ${defaultCacheDirectory(USER_ID, apiOrigin)} still holds decrypted data`,
|
||||
);
|
||||
});
|
||||
|
||||
it("without a session says so, calls nothing and exits 0", async () => {
|
||||
expect(await logoutCommand(logoutContext(200))).toBe(0);
|
||||
expect(requests).toHaveLength(0);
|
||||
@@ -1618,18 +1686,19 @@ describe("an account with many albums", () => {
|
||||
// encrypted as the server stores it. The original's bytes are the title.
|
||||
// These tests fetch no thumbnail. The server records a thumbnail size, so
|
||||
// `helper fix-missing-thumbnails` downloads the original before it skips
|
||||
// the photo as not a JPEG.
|
||||
const photoRow = (photo: {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
title: string;
|
||||
updationTime: number;
|
||||
}): { row: Record<string, unknown>; original: Uint8Array } => {
|
||||
// the photo as not a JPEG. `albumKey` is that album's key, by default the
|
||||
// one in `albumKeys`.
|
||||
const photoRow = (
|
||||
photo: {
|
||||
id: number;
|
||||
collectionID: number;
|
||||
title: string;
|
||||
updationTime: number;
|
||||
},
|
||||
albumKey = albumKeys.get(photo.collectionID)!,
|
||||
): { row: Record<string, unknown>; original: Uint8Array } => {
|
||||
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const photoKeyBox = encryptSecretbox(
|
||||
photoKey,
|
||||
albumKeys.get(photo.collectionID)!,
|
||||
);
|
||||
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
|
||||
const metadata = encryptStreamBlob(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
@@ -1742,6 +1811,77 @@ describe("an account with many albums", () => {
|
||||
};
|
||||
};
|
||||
|
||||
// The albums in the cache's `metadata.json`. A search on an empty cache
|
||||
// stores none there, only the account whose original or thumbnail it
|
||||
// stored.
|
||||
const cachedAlbums = (): unknown[] =>
|
||||
JSON.parse(readFileSync(join(root, "cache", "metadata.json"), "utf-8"))
|
||||
.collections;
|
||||
|
||||
// A context whose login and saved session send each request to the server
|
||||
// its host names: `ente.example.com` is the fake server above, and
|
||||
// `photos.example.org` a second one. Every account the fake server logs in
|
||||
// has user ID 42, so the second server's account has the first one's user
|
||||
// ID. Its one album holds photo 200, `other-200.jpg`; the first server
|
||||
// gives ID 200 to `photo-2.jpg`.
|
||||
const twoServerContext = async (): Promise<CliContext> => {
|
||||
const account = await buildAccount(PASSWORD);
|
||||
const albumKey = sodium.crypto_secretbox_keygen();
|
||||
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
|
||||
const name = encryptSecretbox(
|
||||
new TextEncoder().encode("Other album"),
|
||||
albumKey,
|
||||
);
|
||||
const { row, original } = photoRow(
|
||||
{
|
||||
id: 200,
|
||||
collectionID: 1,
|
||||
title: "other-200.jpg",
|
||||
updationTime: 1,
|
||||
},
|
||||
albumKey,
|
||||
);
|
||||
const other = buildMetaFetch({
|
||||
verifier: account.verifier,
|
||||
srpAttributes: account.srpAttributes,
|
||||
keyAttributes: account.keyAttributes,
|
||||
encryptedToken: account.encryptedToken,
|
||||
collections: [
|
||||
{
|
||||
id: 1,
|
||||
owner: { id: USER_ID },
|
||||
encryptedKey: toBase64(albumKeyBox.ciphertext),
|
||||
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
|
||||
encryptedName: toBase64(name.ciphertext),
|
||||
nameDecryptionNonce: toBase64(name.nonce),
|
||||
type: "album",
|
||||
updationTime: 1,
|
||||
},
|
||||
],
|
||||
filesByCollection: { 1: [row] },
|
||||
encryptedMLData: {},
|
||||
fileCiphertexts: { 200: original },
|
||||
});
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { host } = new URL(
|
||||
input instanceof Request ? input.url : input,
|
||||
);
|
||||
return (host === "photos.example.org" ? other : server)(
|
||||
input,
|
||||
init,
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
return {
|
||||
...context(),
|
||||
login: (opts) =>
|
||||
Client.login({
|
||||
...opts,
|
||||
apiOptions: { ...opts.apiOptions, fetch },
|
||||
}),
|
||||
loadSession: (path) => loadSession(path, { fetch }),
|
||||
};
|
||||
};
|
||||
|
||||
it("collections lists every album from one request, fetching no album's files", async () => {
|
||||
const ctx = accountContext();
|
||||
expect(await collectionsCommand(ctx, {})).toBe(0);
|
||||
@@ -1848,7 +1988,100 @@ describe("an account with many albums", () => {
|
||||
"/collections/v2/diff?collectionID=2&sinceTime=0",
|
||||
"/?fileID=200",
|
||||
]);
|
||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||
expect(cachedAlbums()).toEqual([]);
|
||||
});
|
||||
|
||||
// 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"]);
|
||||
});
|
||||
|
||||
// The two servers' accounts share the cache directory `--cache-dir` names.
|
||||
it("after a login to another server whose account has the same user ID, files and get see none of the first server's albums and originals", async () => {
|
||||
const ctx = await twoServerContext();
|
||||
const first = join(root, "first.jpg");
|
||||
const second = join(root, "second.jpg");
|
||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
try {
|
||||
const opts = { server: "https://ente.example.com" };
|
||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
||||
expect(await filesCommand(ctx, undefined, {})).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
|
||||
|
||||
const otherOpts = { server: "https://photos.example.org" };
|
||||
expect(await loginCommand(ctx, otherOpts)).toBe(0);
|
||||
stdout.text = "";
|
||||
expect(await filesCommand(ctx, undefined, {})).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
expect(stdout.text).toBe("200\timage\tother-200.jpg\n");
|
||||
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
|
||||
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
|
||||
});
|
||||
|
||||
// `get` on an empty cache stores the original but no album.
|
||||
it("get on an empty cache, after the same on another server whose account has the same user ID, saves this server's photo", async () => {
|
||||
const ctx = await twoServerContext();
|
||||
const first = join(root, "first.jpg");
|
||||
const second = join(root, "second.jpg");
|
||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
||||
vi.stubEnv("QUAK_SERVER", undefined);
|
||||
try {
|
||||
const opts = { server: "https://ente.example.com" };
|
||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
|
||||
|
||||
const otherOpts = { server: "https://photos.example.org" };
|
||||
expect(await loginCommand(ctx, otherOpts)).toBe(0);
|
||||
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
|
||||
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
|
||||
});
|
||||
|
||||
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
||||
@@ -1906,7 +2139,7 @@ describe("an account with many albums", () => {
|
||||
"/collections/v2/diff?collectionID=39&sinceTime=0",
|
||||
"/?fileID=3900",
|
||||
]);
|
||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||
expect(cachedAlbums()).toEqual([]);
|
||||
});
|
||||
|
||||
// `files` first fills the cache, so the helper waits for a refresh. No
|
||||
|
||||
Reference in new issue
Block a user