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; emailOTP?: () => Promise; 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 { 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 { 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 { 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 { this.assertLoggedIn(); return dlFile(this.api, file, outPath); } async downloadThumbnail( file: EnteFile, outPath?: string, ): Promise { this.assertLoggedIn(); return dlThumb(this.api, file, outPath); } }