Compare commits
1
Commits
c1b4502f8d
...
6e238bc1bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e238bc1bd |
@@ -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=<usec>`: list collections changed since
|
||||
microsecond timestamp; pass 0 for a full enumeration.
|
||||
- `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
|
||||
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 <path>] <command> 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 <id> [--json] list files in a collection
|
||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||
|
||||
@@ -18,6 +18,13 @@ 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: Checked downloaded originals against their recorded content hash
|
||||
(issue 68). `downloadFile`, which `quak get`, the content cache and backup all
|
||||
use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and
|
||||
@@ -26,12 +33,14 @@ Tag v1.0.0.
|
||||
hashed separately as `<imageHash>:<videoHash>`. `decryptFile` reads older
|
||||
clients' `imageHash` and `videoHash` fields for live photos. A file with no
|
||||
recorded hash is stored unchecked.
|
||||
|
||||
- 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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+39
-6
@@ -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<number> => {
|
||||
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> => {
|
||||
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 (
|
||||
|
||||
@@ -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
|
||||
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
|
||||
// touching the keys, so nothing decrypts with the zeroed keys.
|
||||
|
||||
@@ -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<Library> {
|
||||
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.
|
||||
|
||||
+81
-14
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user