Files
quak/src/client.ts

217 lines
6.3 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 quak");
} 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),
);
}
getApiClient(): ApiClient {
this.assertLoggedIn();
return this.api;
}
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 });
// The sync API keeps returning deleted collections as tombstones
// (isDeleted: true); their diff endpoint 404s, so drop them.
return collections
.filter((raw) => !raw.isDeleted)
.map((raw) =>
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
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);
}
}