diff --git a/README.md b/README.md index dd10432..90126ca 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,7 @@ Endpoints used: encrypted token plus key attributes. - `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path. - `POST /users/two-factor/verify`: TOTP second factor. +- `POST /users/logout`: end the calling token's session (`quak logout`). - `GET /collections/v2?sinceTime=`: list collections changed since microsecond timestamp; pass 0 for a full enumeration. - `GET /collections/v2/diff?collectionID=&sinceTime=`: list files in a @@ -432,7 +433,9 @@ whatever else fits their use case. `Client.fromJSON(snapshot)` restores a working client from that snapshot without re-authenticating; it checks every field and each key's length first, and throws an error naming the bad field. `client.logout()` clears the token and zeroes the key buffers in place; every -later call on that client throws. +later call on that client throws. It does not contact the server, so the token +stays valid there and in any saved snapshot; `await client.logoutOnServer()` +first ends the session on the server (`POST /users/logout`). The CLI stores the snapshot at the platform-appropriate data directory via `env-paths`: `~/Library/Application Support/quak/session.json` on macOS, @@ -442,13 +445,21 @@ you would treat the password itself. A missing file is reported as "not logged in"; a file that exists but is corrupt is reported as such, naming the bad field. Both exit with status 1. +`quak logout` ends the session on the server, so the token in `session.json` +stops working even in a copy of the file, and then deletes the file. If the +server call fails (or the file is corrupt), the file is still deleted, the +command says the server session could not be ended, and it exits with status 1. +It does not delete the cache: it prints the account's cache directory and says +it still holds decrypted data (file keys in `metadata.json`, cached originals +and thumbnails), for the user to delete if they want it gone. + ### CLI surface ``` quak [--cache-dir ] global: local metadata/content cache location quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak whoami print logged-in account as JSON -quak logout delete saved session +quak logout end the session, delete it quak collections [--json] list all collections quak files --collection [--json] list files in a collection quak get [--out path] [--collection] download and decrypt a file diff --git a/TODO.md b/TODO.md index dcc9eb8..6f2aeb2 100644 --- a/TODO.md +++ b/TODO.md @@ -18,12 +18,20 @@ Tag v1.0.0. # Completed Steps +- 2026-09-23: `quak logout` ends the session on the server (issue 108). It calls + `POST /users/logout` through the new `Client.logoutOnServer()`, then deletes + `session.json` even when that call fails, says so and exits 1. It prints the + account's cache directory and says it still holds decrypted data. The default + cache path is now `defaultCacheDirectory()` in the library, shared with + `Library.open`. + - 2026-09-23: Kept one account's cache from mixing with another's (issue 104). When `metadata.json` in the cache directory was written for a different, non-zero user ID than the client's, `Library.open` deletes it and `mldata/` and starts empty, so the first refresh enumerates from 0. This only happens with `--cache-dir` or an explicit `cacheDirectory`; the default path already includes the user ID. A test opens one account's cache as another account. + - 2026-09-23: `backup-metadata` no longer stops on one failed ML data request (issue 101). Each request of up to 200 files is tried on its own; a failed one is logged, its files are written with the reason in `mlDataError`, and the diff --git a/bin/quak.ts b/bin/quak.ts index e4ad124..75ecb8c 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -73,7 +73,7 @@ program program .command("logout") - .description("Delete the saved session") + .description("End the session on the server and delete the saved session") .action(() => run(logoutCommand(context()))); program diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 72a9abd..a38c2af 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -17,7 +17,11 @@ import { import { join } from "node:path"; import { Client, type ClientSnapshot } from "./client.js"; import { init } from "./crypto/index.js"; -import { Library, type LibraryClient } from "./library/index.js"; +import { + defaultCacheDirectory, + Library, + type LibraryClient, +} from "./library/index.js"; import { fileListRow, fileListLine, @@ -146,14 +150,43 @@ export const whoamiCommand = async (ctx: CliContext): Promise => { return 0; }; +// Ends the session on the server, then deletes the session file even when that +// failed, and exits 1 if it did. The cache is left in place; the user is told +// where it is. export const logoutCommand = async (ctx: CliContext): Promise => { - if (existsSync(sessionPath(ctx))) { - unlinkSync(sessionPath(ctx)); - ctx.stderr.write("Session deleted.\n"); - } else { + const path = sessionPath(ctx); + if (!existsSync(path)) { ctx.stderr.write("No session found.\n"); + return 0; } - return 0; + await init(); + let cacheDir = ctx.cacheDir; + let failure: string | undefined; + try { + const client = ctx.loadSession(path); + if (client) { + cacheDir ??= defaultCacheDirectory(client.whoami().userID); + await client.logoutOnServer(); + client.logout(); + } + } catch (err) { + failure = err instanceof Error ? err.message : String(err); + } + unlinkSync(path); + if (failure === undefined) { + ctx.stderr.write("Session ended on the server.\n"); + } else { + ctx.stderr.write( + `Could not end the session on the server: ${failure}\n`, + ); + } + ctx.stderr.write("Session deleted.\n"); + if (cacheDir !== undefined) { + ctx.stderr.write( + `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`, + ); + } + return failure === undefined ? 0 : 1; }; export const collectionsCommand = async ( diff --git a/src/client.ts b/src/client.ts index 8859877..62784c1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -217,6 +217,14 @@ export class Client { }; } + // Ends this client's session on the server (`POST /users/logout`), so the + // token stops working everywhere, including in any saved copy of it. This + // client is left as it was; call `logout()` to clear it. + async logoutOnServer(): Promise { + this.assertLoggedIn(); + await this.api.postJSON("/users/logout", {}); + } + // Zeroes the key buffers in place, so any copy of the reference held // elsewhere is wiped too. Every method checks `assertLoggedIn` before // touching the keys, so nothing decrypts with the zeroed keys. diff --git a/src/library/index.ts b/src/library/index.ts index f61bd47..d8aa998 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -98,6 +98,11 @@ 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)); + // 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 // projection and the precache's initial seeding at open(). @@ -344,8 +349,7 @@ export class Library { static async open(opts: LibraryOptions): Promise { const { userID } = opts.client.whoami(); const cacheDirectory = - opts.cacheDirectory ?? - join(envPaths("quak", { suffix: "" }).cache, String(userID)); + opts.cacheDirectory ?? defaultCacheDirectory(userID); const metadataPath = join(cacheDirectory, "metadata.json"); let store = await MetadataStore.load(metadataPath); // A cache directory given explicitly can hold another account's cache. diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 9f100a1..5da826a 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -38,7 +38,8 @@ import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot } from "../../src/client.js"; import type { ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; -import { init } from "../../src/crypto/index.js"; +import { init, toBase64 } from "../../src/crypto/index.js"; +import { defaultCacheDirectory } from "../../src/library/index.js"; const USER_ID = 42; @@ -176,19 +177,6 @@ describe("session file", () => { expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); }); - it("is removed by logout", async () => { - const ctx = context(); - saveSession(ctx.sessionDir, snapshot); - expect(await logoutCommand(ctx)).toBe(0); - expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); - expect(stderr.text).toBe("Session deleted.\n"); - }); - - it("logout without a session says so and exits 0", async () => { - expect(await logoutCommand(context())).toBe(0); - expect(stderr.text).toBe("No session found.\n"); - }); - it("a missing session exits 1 with 'Not logged in'", async () => { const ctx = { ...context(), loadSession }; expect(await whoamiCommand(ctx)).toBe(1); @@ -211,6 +199,85 @@ describe("session file", () => { }); }); +// These use a real client read from the session file, over a fake API that +// records each request and answers with `status`. +describe("logout", () => { + const snapshot: ClientSnapshot = { + email: "cli@example.com", + userID: USER_ID, + token: "saved-token", + masterKey: toBase64(new Uint8Array(32)), + secretKey: toBase64(new Uint8Array(32)), + publicKey: toBase64(new Uint8Array(32)), + }; + + const requests: Request[] = []; + + const logoutContext = (status: number): CliContext => ({ + ...context(), + loadSession: (path) => + loadSession(path, { + fetch: async (url, init) => { + requests.push(new Request(url, init)); + return new Response(JSON.stringify({}), { + status, + headers: { "content-type": "application/json" }, + }); + }, + }), + }); + + beforeEach(() => { + requests.length = 0; + }); + + it("ends the session on the server, then deletes the file", async () => { + const ctx = logoutContext(200); + saveSession(ctx.sessionDir, snapshot); + expect(await logoutCommand(ctx)).toBe(0); + + expect(requests).toHaveLength(1); + expect(requests[0]!.method).toBe("POST"); + expect(new URL(requests[0]!.url).pathname).toBe("/users/logout"); + expect(requests[0]!.headers.get("X-Auth-Token")).toBe("saved-token"); + expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); + expect(stderr.text).toBe( + "Session ended on the server.\n" + + "Session deleted.\n" + + `Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`, + ); + }); + + it("still deletes the file when the server call fails, and says so", async () => { + const ctx = logoutContext(500); + saveSession(ctx.sessionDir, snapshot); + expect(await logoutCommand(ctx)).toBe(1); + + expect(requests).toHaveLength(1); + expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); + expect(stderr.text).toBe( + "Could not end the session on the server: HTTP 500\n" + + "Session deleted.\n" + + `Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`, + ); + }); + + it("names the account's default cache directory without --cache-dir", async () => { + const ctx = { ...logoutContext(200), cacheDir: undefined }; + saveSession(ctx.sessionDir, snapshot); + expect(await logoutCommand(ctx)).toBe(0); + expect(stderr.text).toContain( + `Cache directory ${defaultCacheDirectory(USER_ID)} 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); + expect(stderr.text).toBe("No session found.\n"); + }); +}); + describe("whoami", () => { it("prints the account as one line of JSON", async () => { expect(await whoamiCommand(context())).toBe(0);