Files
quak/src/client.ts
sneak a8641cbfe8 Client class green: OO API wrapping the entire library
Client.login(email, password) performs the full SRP handshake, key
unwrap, and returns a ready Client. Session lives in the object.
Client.fromJSON(snapshot) restores from a serialized snapshot.
client.toJSON() produces a plain object with base64-encoded keys
that the consumer can persist however they like.

Methods: whoami, listCollections, listFiles (with pagination),
downloadFile, downloadThumbnail, logout.

All 86 tests pass including the 8-part literate usage tutorial.
2026-05-13 18:00:10 -07:00

200 lines
5.8 KiB
TypeScript

import { ApiClient, type ApiClientOptions } from "./api/client.js";
import {
beginLogin,
submitTOTP,
requestEmailOTP,
submitEmailOTP,
} from "./auth/login.js";
import { unwrapAuth } from "./auth/unwrap.js";
import { init, fromBase64, toBase64 } from "./crypto/index.js";
import { decryptCollection, decryptFile } from "./model/index.js";
import {
downloadFile as dlFile,
downloadThumbnail as dlThumb,
} from "./download/index.js";
import type {
Collection,
EnteFile,
RawCollection,
RawEnteFile,
} from "./model/types.js";
import type { DownloadResult } from "./download/index.js";
export interface LoginOptions {
email: string;
password: string;
totp?: () => Promise<string>;
emailOTP?: () => Promise<string>;
apiOptions?: ApiClientOptions;
}
export interface ClientSnapshot {
email: string;
userID: number;
token: string;
masterKey: string;
secretKey: string;
publicKey: string;
}
export class Client {
private readonly api: ApiClient;
private readonly email: string;
private readonly userID: number;
private readonly masterKey: Uint8Array;
private readonly secretKey: Uint8Array;
private readonly publicKey: Uint8Array;
private loggedOut = false;
private constructor(
api: ApiClient,
email: string,
userID: number,
masterKey: Uint8Array,
secretKey: Uint8Array,
publicKey: Uint8Array,
) {
this.api = api;
this.email = email;
this.userID = userID;
this.masterKey = masterKey;
this.secretKey = secretKey;
this.publicKey = publicKey;
}
static async login(opts: LoginOptions): Promise<Client> {
await init();
const api = new ApiClient(opts.apiOptions);
const challenge = await beginLogin(api, opts.email, opts.password);
let response;
if (challenge.kind === "complete") {
response = challenge.response;
} else if (challenge.kind === "totp") {
if (!opts.totp)
throw new Error(
"Account requires TOTP but no totp callback provided",
);
const code = await opts.totp();
response = await submitTOTP(api, challenge.sessionID, code);
} else if (challenge.kind === "emailOTP") {
if (!opts.emailOTP)
throw new Error(
"Account requires email OTP but no emailOTP callback provided",
);
await requestEmailOTP(api, opts.email);
const code = await opts.emailOTP();
response = await submitEmailOTP(api, opts.email, code);
} else if (challenge.kind === "passkey") {
throw new Error("Passkey authentication is not supported by quack");
} else {
throw new Error(`Unknown login challenge kind`);
}
const unwrapped = await unwrapAuth(response, opts.password);
api.setAuthToken(unwrapped.token);
return new Client(
api,
opts.email,
response.id,
unwrapped.masterKey,
unwrapped.secretKey,
unwrapped.publicKey,
);
}
static fromJSON(
snapshot: ClientSnapshot,
apiOptions?: ApiClientOptions,
): Client {
const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
return new Client(
api,
snapshot.email,
snapshot.userID,
fromBase64(snapshot.masterKey),
fromBase64(snapshot.secretKey),
fromBase64(snapshot.publicKey),
);
}
private assertLoggedIn(): void {
if (this.loggedOut) throw new Error("Client has been logged out");
}
whoami(): { email: string; userID: number } {
this.assertLoggedIn();
return { email: this.email, userID: this.userID };
}
toJSON(): ClientSnapshot {
this.assertLoggedIn();
return {
email: this.email,
userID: this.userID,
token: this.api["token"]!,
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey),
};
}
logout(): void {
this.loggedOut = true;
this.api.clearAuthToken();
}
async listCollections(): Promise<Collection[]> {
this.assertLoggedIn();
const { collections } = await this.api.getJSON<{
collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 });
return collections.map((raw) =>
decryptCollection(raw, this.masterKey, this.userID),
);
}
async listFiles(
collectionID: number,
collectionKey: Uint8Array,
): Promise<EnteFile[]> {
this.assertLoggedIn();
const allFiles: EnteFile[] = [];
let sinceTime = 0;
for (;;) {
const { diff, hasMore } = await this.api.getJSON<{
diff: RawEnteFile[];
hasMore: boolean;
}>("/collections/v2/diff", { collectionID, sinceTime });
for (const raw of diff) {
if (!raw.isDeleted) {
allFiles.push(decryptFile(raw, collectionKey));
}
if (raw.updationTime > sinceTime) {
sinceTime = raw.updationTime;
}
}
if (!hasMore) break;
}
return allFiles;
}
async downloadFile(
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> {
this.assertLoggedIn();
return dlFile(this.api, file, outPath);
}
async downloadThumbnail(
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> {
this.assertLoggedIn();
return dlThumb(this.api, file, outPath);
}
}