diff --git a/bin/quak.ts b/bin/quak.ts new file mode 100644 index 0000000..dc0872d --- /dev/null +++ b/bin/quak.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout, stderr } from "node:process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Command } from "commander"; +import envPaths from "env-paths"; +import { Client, type ClientSnapshot } from "../src/client.js"; +import { init } from "../src/crypto/index.js"; +import { runBackup } from "../src/backup.js"; + +const paths = envPaths("quak", { suffix: "" }); +const sessionPath = join(paths.data, "session.json"); + +const loadSession = (): ClientSnapshot | null => { + if (!existsSync(sessionPath)) return null; + try { + return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot; + } catch { + return null; + } +}; + +const saveSession = (snapshot: ClientSnapshot): void => { + mkdirSync(paths.data, { recursive: true, mode: 0o700 }); + writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), { + mode: 0o600, + }); +}; + +const requireSession = (): Client => { + const snapshot = loadSession(); + if (!snapshot) { + stderr.write( + `Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`, + ); + process.exit(1); + } + return Client.fromJSON(snapshot); +}; + +const prompt = async (message: string): Promise => { + const rl = createInterface({ input: stdin, output: stderr }); + const answer = await rl.question(message); + rl.close(); + return answer; +}; + +const promptPassword = async (message: string): Promise => { + if (!stdin.isTTY) { + return prompt(message); + } + stderr.write(message); + stdin.setRawMode(true); + stdin.resume(); + const chars: string[] = []; + return new Promise((resolve) => { + const onData = (buf: Buffer) => { + for (const byte of buf) { + if (byte === 3) { + stdin.setRawMode(false); + process.exit(130); + } + if (byte === 13 || byte === 10) { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + stdin.pause(); + stderr.write("\n"); + resolve(chars.join("")); + return; + } + if (byte === 127 || byte === 8) { + chars.pop(); + } else { + chars.push(String.fromCharCode(byte)); + } + } + }; + stdin.on("data", onData); + }); +}; + +const program = new Command(); + +program + .name("quak") + .description("CLI for the Ente end-to-end encrypted photo service") + .version("0.0.0"); + +program + .command("login") + .description("Log in to an Ente account and save the session") + .action(async () => { + await init(); + const email = + process.env.QUAK_EMAIL ?? + (stdin.isTTY ? await prompt("Email: ") : null); + const password = + process.env.QUAK_PASSWORD ?? + (stdin.isTTY ? await promptPassword("Password: ") : null); + if (!email || !password) { + stderr.write( + "Set QUAK_EMAIL and QUAK_PASSWORD env vars for non-interactive use.\n", + ); + process.exit(1); + } + + stderr.write("Authenticating...\n"); + try { + const client = await Client.login({ + email, + password, + totp: async () => prompt("TOTP code: "), + emailOTP: async () => prompt("Email verification code: "), + }); + + saveSession(client.toJSON()); + const info = client.whoami(); + stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); + stderr.write(`Session saved to ${sessionPath}\n`); + } catch (err) { + stderr.write( + `Login failed: ${err instanceof Error ? err.message : err}\n`, + ); + process.exit(1); + } + }); + +program + .command("whoami") + .description("Print the logged-in account") + .action(() => { + const client = requireSession(); + const info = client.whoami(); + stdout.write(JSON.stringify(info) + "\n"); + }); + +program + .command("logout") + .description("Delete the saved session") + .action(async () => { + if (existsSync(sessionPath)) { + const { unlinkSync } = await import("node:fs"); + unlinkSync(sessionPath); + stderr.write("Session deleted.\n"); + } else { + stderr.write("No session found.\n"); + } + }); + +program + .command("backup") + .description( + "Download all photos to a local directory, organized by collection", + ) + .argument("", "Output directory") + .option("--json", "Print result as JSON instead of human-readable summary") + .action(async (dir: string, opts: { json?: boolean }) => { + await init(); + const client = requireSession(); + + stderr.write("Starting backup...\n"); + const result = await runBackup(client, dir, (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }); + + if (opts.json) { + stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + stderr.write("\n--- Backup complete ---\n"); + stderr.write(` Total files: ${result.totalFiles}\n`); + stderr.write(` Downloaded: ${result.downloaded}\n`); + stderr.write(` Skipped: ${result.skipped}\n`); + stderr.write(` Failed: ${result.failed}\n`); + if (result.errors.length > 0) { + stderr.write("\nFailed files:\n"); + for (const e of result.errors) { + stderr.write( + ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, + ); + } + } + } + + process.exit(result.failed > 0 ? 1 : 0); + }); + +program.parse(); diff --git a/src/backup.ts b/src/backup.ts index a50d749..a0d6647 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -1,6 +1,13 @@ -// Stub: see the README "Development workflow" section for TDD policy. - +import { + existsSync, + mkdirSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative, extname } from "node:path"; import type { Client } from "./client.js"; +import type { EnteFile } from "./model/types.js"; export interface BackupError { fileID: number; @@ -17,9 +24,114 @@ export interface BackupResult { errors: BackupError[]; } -export const runBackup = async ( - _client: Client, - _outDir: string, -): Promise => { - throw new Error("backup.runBackup not implemented"); +export type ProgressCallback = (message: string) => void; + +const sanitizePath = (name: string): string => + name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_"); + +const originalFileName = (file: EnteFile): string => { + const ext = extname(file.metadata.title || "") || ".bin"; + return `${file.id}${ext}`; +}; + +export const runBackup = async ( + client: Client, + outDir: string, + onProgress?: ProgressCallback, +): Promise => { + const log = onProgress ?? (() => {}); + + mkdirSync(outDir, { recursive: true }); + const originalsDir = join(outDir, "originals"); + mkdirSync(originalsDir, { recursive: true }); + const collectionsDir = join(outDir, "collections"); + mkdirSync(collectionsDir, { recursive: true }); + + log("Fetching collections..."); + const collections = await client.listCollections(); + const downloadedIDs = new Set(); + + let totalFiles = 0; + let downloaded = 0; + let skipped = 0; + let failed = 0; + const errors: BackupError[] = []; + + for (const col of collections) { + const colDirName = sanitizePath(col.name || `collection-${col.id}`); + const colDir = join(collectionsDir, colDirName); + mkdirSync(colDir, { recursive: true }); + + log(`[${col.name}] Fetching file list...`); + const files = await client.listFiles(col.id, col.key); + log(`[${col.name}] ${files.length} file(s)`); + + const collectionMeta: { + id: number; + name: string; + type: string; + files: { id: number; metadata: EnteFile["metadata"] }[]; + } = { + id: col.id, + name: col.name, + type: col.type, + files: [], + }; + + for (const file of files) { + totalFiles++; + const origName = originalFileName(file); + const origPath = join(originalsDir, origName); + const linkName = sanitizePath( + file.metadata.title || `file-${file.id}`, + ); + const linkPath = join(colDir, linkName); + + if (!downloadedIDs.has(file.id)) { + if (existsSync(origPath) && statSync(origPath).size > 0) { + skipped++; + downloadedIDs.add(file.id); + } else { + try { + log(`[${col.name}] Downloading ${linkName}...`); + await client.downloadFile(file, origPath); + downloaded++; + downloadedIDs.add(file.id); + } catch (err) { + log( + `[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`, + ); + failed++; + errors.push({ + fileID: file.id, + title: file.metadata.title, + collection: col.name, + error: + err instanceof Error + ? err.message + : String(err), + }); + continue; + } + } + } + + if (!existsSync(linkPath) && existsSync(origPath)) { + const target = relative(colDir, origPath); + symlinkSync(target, linkPath); + } + + collectionMeta.files.push({ + id: file.id, + metadata: file.metadata, + }); + } + + writeFileSync( + join(collectionsDir, `${colDirName}.json`), + JSON.stringify(collectionMeta, null, 2), + ); + } + + return { totalFiles, downloaded, skipped, failed, errors }; }; diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 536d658..f559b80 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -33,7 +33,14 @@ * is a thin wrapper around this module. */ -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import sodium from "libsodium-wrappers-sumo"; @@ -363,13 +370,22 @@ describe("quak backup", () => { expect(result.failed).toBe(0); expect(result.errors).toEqual([]); - // Files are under // - const beach = readFileSync(join(outDir, "Vacation", "beach.jpg")); - const sunset = readFileSync(join(outDir, "Vacation", "sunset.jpg")); - const diagram = readFileSync(join(outDir, "Work", "diagram.png")); - expect(beach.length).toBe(3000); - expect(sunset.length).toBe(2000); - expect(diagram.length).toBe(1500); + // Originals are under <outDir>/originals/<fileID>.<ext> + expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe( + 3000, + ); + expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe( + 2000, + ); + expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe( + 1500, + ); + + // Collection dirs under collections/ contain symlinks to originals + const beachLink = join(outDir, "collections", "Vacation", "beach.jpg"); + expect(lstatSync(beachLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(beachLink)).toContain("originals"); + expect(readFileSync(beachLink).length).toBe(3000); }); it("skips files that already exist on disk with matching size", async () => { @@ -411,14 +427,17 @@ describe("quak backup", () => { expect(result.errors[0]!.fileID).toBe(101); expect(result.errors[0]!.title).toBe("sunset.jpg"); - // The two successful files are on disk - expect(existsSync(join(outDir, "Vacation", "beach.jpg"))).toBe(true); - expect(existsSync(join(outDir, "Work", "diagram.png"))).toBe(true); - // The failed file is NOT on disk (no partial write) - expect(existsSync(join(outDir, "Vacation", "sunset.jpg"))).toBe(false); + // The two successful originals are on disk + expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); + expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); + // The failed file has no original and no symlink + expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false); + expect( + existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")), + ).toBe(false); }); - it("writes metadata.json with collection and file info", async () => { + it("writes per-collection JSON metadata", async () => { const outDir = join(testDir, "metadata-check"); const client = await Client.login({ email: TEST_EMAIL, @@ -428,11 +447,12 @@ describe("quak backup", () => { await runBackup(client, outDir); - const metaPath = join(outDir, "metadata.json"); - expect(existsSync(metaPath)).toBe(true); - const meta = JSON.parse(readFileSync(metaPath, "utf-8")); - expect(meta.collections.length).toBe(2); - expect(meta.collections[0].files.length).toBeGreaterThan(0); - expect(meta.collections[0].files[0].metadata.title).toBeDefined(); + // Each collection gets a <name>.json next to its image dir + const vacationMeta = join(outDir, "collections", "Vacation.json"); + expect(existsSync(vacationMeta)).toBe(true); + const meta = JSON.parse(readFileSync(vacationMeta, "utf-8")); + expect(meta.name).toBe("Vacation"); + expect(meta.files.length).toBeGreaterThan(0); + expect(meta.files[0].metadata.title).toBeDefined(); }); });