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.
200 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|