Compare commits
1
Commits
573de7e104
...
d71555a90e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71555a90e |
@@ -318,6 +318,7 @@ Endpoints used:
|
|||||||
encrypted token plus key attributes.
|
encrypted token plus key attributes.
|
||||||
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
||||||
- `POST /users/two-factor/verify`: TOTP second factor.
|
- `POST /users/two-factor/verify`: TOTP second factor.
|
||||||
|
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
||||||
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
||||||
microsecond timestamp; pass 0 for a full enumeration.
|
microsecond timestamp; pass 0 for a full enumeration.
|
||||||
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: 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
|
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.
|
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
|
`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
|
The CLI stores the snapshot at the platform-appropriate data directory via
|
||||||
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
`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
|
in"; a file that exists but is corrupt is reported as such, naming the bad
|
||||||
field. Both exit with status 1.
|
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
|
### CLI surface
|
||||||
|
|
||||||
```
|
```
|
||||||
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
||||||
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
||||||
quak whoami print logged-in account as JSON
|
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 collections [--json] list all collections
|
||||||
quak files --collection <id> [--json] list files in a collection
|
quak files --collection <id> [--json] list files in a collection
|
||||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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: `backup-metadata` no longer stops on one failed ML data request
|
- 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
|
(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
|
is logged, its files are written with the reason in `mlDataError`, and the
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ program
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("logout")
|
.command("logout")
|
||||||
.description("Delete the saved session")
|
.description("End the session on the server and delete the saved session")
|
||||||
.action(() => run(logoutCommand(context())));
|
.action(() => run(logoutCommand(context())));
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
+39
-6
@@ -17,7 +17,11 @@ import {
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Client, type ClientSnapshot } from "./client.js";
|
import { Client, type ClientSnapshot } from "./client.js";
|
||||||
import { init } from "./crypto/index.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 {
|
import {
|
||||||
fileListRow,
|
fileListRow,
|
||||||
fileListLine,
|
fileListLine,
|
||||||
@@ -146,14 +150,43 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
|||||||
return 0;
|
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<number> => {
|
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
if (existsSync(sessionPath(ctx))) {
|
const path = sessionPath(ctx);
|
||||||
unlinkSync(sessionPath(ctx));
|
if (!existsSync(path)) {
|
||||||
ctx.stderr.write("Session deleted.\n");
|
|
||||||
} else {
|
|
||||||
ctx.stderr.write("No session found.\n");
|
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 (
|
export const collectionsCommand = async (
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
this.assertLoggedIn();
|
||||||
|
await this.api.postJSON("/users/logout", {});
|
||||||
|
}
|
||||||
|
|
||||||
// Zeroes the key buffers in place, so any copy of the reference held
|
// Zeroes the key buffers in place, so any copy of the reference held
|
||||||
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
|
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
|
||||||
// touching the keys, so nothing decrypts with the zeroed keys.
|
// touching the keys, so nothing decrypts with the zeroed keys.
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ export {
|
|||||||
|
|
||||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
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
|
// 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
|
// paths from the content cache when one is given. Shared by the live read
|
||||||
// projection and the precache's initial seeding at open().
|
// projection and the precache's initial seeding at open().
|
||||||
@@ -343,8 +348,7 @@ export class Library {
|
|||||||
static async open(opts: LibraryOptions): Promise<Library> {
|
static async open(opts: LibraryOptions): Promise<Library> {
|
||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
opts.cacheDirectory ??
|
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
||||||
join(envPaths("quak", { suffix: "" }).cache, String(userID));
|
|
||||||
const store = await MetadataStore.load(
|
const store = await MetadataStore.load(
|
||||||
join(cacheDirectory, "metadata.json"),
|
join(cacheDirectory, "metadata.json"),
|
||||||
);
|
);
|
||||||
|
|||||||
+81
-14
@@ -38,7 +38,8 @@ import { loadSession } from "../../src/cli-session.js";
|
|||||||
import type { Client, ClientSnapshot } from "../../src/client.js";
|
import type { Client, ClientSnapshot } from "../../src/client.js";
|
||||||
import type { ContentSource } from "../../src/library/content.js";
|
import type { ContentSource } from "../../src/library/content.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.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;
|
const USER_ID = 42;
|
||||||
|
|
||||||
@@ -176,19 +177,6 @@ describe("session file", () => {
|
|||||||
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
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 () => {
|
it("a missing session exits 1 with 'Not logged in'", async () => {
|
||||||
const ctx = { ...context(), loadSession };
|
const ctx = { ...context(), loadSession };
|
||||||
expect(await whoamiCommand(ctx)).toBe(1);
|
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", () => {
|
describe("whoami", () => {
|
||||||
it("prints the account as one line of JSON", async () => {
|
it("prints the account as one line of JSON", async () => {
|
||||||
expect(await whoamiCommand(context())).toBe(0);
|
expect(await whoamiCommand(context())).toBe(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user