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.
This commit is contained in:
171
src/client.ts
171
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<Client> {
|
||||
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<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,
|
||||
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<Collection[]> {
|
||||
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<EnteFile[]> {
|
||||
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<DownloadResult> {
|
||||
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<DownloadResult> {
|
||||
throw new Error("Client.downloadThumbnail not implemented");
|
||||
this.assertLoggedIn();
|
||||
return dlThumb(this.api, file, outPath);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user