From a8641cbfe827d0472d583ba40ecdb44f2c6dd4ca Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 13 May 2026 18:00:10 -0700 Subject: [PATCH] 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. --- src/client.ts | 171 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 149 insertions(+), 22 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1cca50f..9631761 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,23 @@ -// Stub: see the README "Development workflow" section for TDD policy. - -import type { ApiClientOptions } from "./api/client.js"; -import type { Collection, EnteFile } from "./model/types.js"; +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 { @@ -22,51 +38,162 @@ export interface ClientSnapshot { } export class Client { - static async login(_opts: LoginOptions): Promise { - throw new Error("Client.login not implemented"); + 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 { + 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, + snapshot: ClientSnapshot, + apiOptions?: ApiClientOptions, ): Client { - throw new Error("Client.fromJSON not implemented"); + 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 } { - throw new Error("Client.whoami not implemented"); + this.assertLoggedIn(); + return { email: this.email, userID: this.userID }; } toJSON(): ClientSnapshot { - throw new Error("Client.toJSON not implemented"); + 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 { - throw new Error("Client.logout not implemented"); + this.loggedOut = true; + this.api.clearAuthToken(); } async listCollections(): Promise { - throw new Error("Client.listCollections not implemented"); + 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, + collectionID: number, + collectionKey: Uint8Array, ): Promise { - throw new Error("Client.listFiles not implemented"); + 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, + file: EnteFile, + outPath?: string, ): Promise { - throw new Error("Client.downloadFile not implemented"); + this.assertLoggedIn(); + return dlFile(this.api, file, outPath); } async downloadThumbnail( - _file: EnteFile, - _outPath?: string, + file: EnteFile, + outPath?: string, ): Promise { - throw new Error("Client.downloadThumbnail not implemented"); + this.assertLoggedIn(); + return dlThumb(this.api, file, outPath); } }