sharp was the only native dependency preventing a single-file binary. Replaced with: - jpeg-js (pure JS) for JPEG decode/resize/encode in thumbnail gen - exif-reader (pure JS) for EXIF tag parsing - Raw JPEG APP1 marker extraction for EXIF segment discovery - Raw XMP packet extraction from file bytes make build-bin produces a ~59MB self-contained Mach-O binary via bun build --compile (bun installed via nix-shell). Zero runtime dependencies. Tested: login, whoami, collections, files all work from the compiled binary. bin/quak.ts: init() called once at program start before commander parses, so libsodium is ready for all commands including those that restore sessions from disk. 118 tests pass.
482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
#!/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";
|
|
import { runMetadataBackup } from "../src/metadata-backup.js";
|
|
import {
|
|
listMissingThumbnails,
|
|
fixMissingThumbnails,
|
|
} from "../src/thumbnails.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<string> => {
|
|
const rl = createInterface({ input: stdin, output: stderr });
|
|
const answer = await rl.question(message);
|
|
rl.close();
|
|
return answer;
|
|
};
|
|
|
|
const promptPassword = async (message: string): Promise<string> => {
|
|
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("collections")
|
|
.description("List all collections (albums)")
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const collections = await client.listCollections();
|
|
|
|
if (opts.json) {
|
|
stdout.write(
|
|
JSON.stringify(
|
|
collections.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
type: c.type,
|
|
ownerID: c.ownerID,
|
|
isShared: c.isShared,
|
|
updationTime: c.updationTime,
|
|
})),
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} else {
|
|
for (const c of collections) {
|
|
stdout.write(
|
|
`${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("files")
|
|
.description("List files in a collection")
|
|
.requiredOption(
|
|
"--collection <id>",
|
|
"Collection ID (from `quak collections`)",
|
|
)
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { collection: string; json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const collectionID = Number(opts.collection);
|
|
if (!Number.isFinite(collectionID)) {
|
|
stderr.write("Invalid collection ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const collections = await client.listCollections();
|
|
const col = collections.find((c) => c.id === collectionID);
|
|
if (!col) {
|
|
stderr.write(`Collection ${collectionID} not found\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const files = await client.listFiles(col.id, col.key);
|
|
|
|
if (opts.json) {
|
|
stdout.write(
|
|
JSON.stringify(
|
|
files.map((f) => ({
|
|
id: f.id,
|
|
title: f.metadata.title,
|
|
fileType: f.metadata.fileType,
|
|
creationTime: f.metadata.creationTime,
|
|
collectionID: f.collectionID,
|
|
})),
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} else {
|
|
for (const f of files) {
|
|
stdout.write(
|
|
`${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("get")
|
|
.description("Download and decrypt a single file")
|
|
.argument("<fileID>", "File ID (from `quak files`)")
|
|
.option("--out <path>", "Output file path")
|
|
.option(
|
|
"--collection <id>",
|
|
"Collection ID (required to look up the file key)",
|
|
)
|
|
.action(
|
|
async (
|
|
fileIDStr: string,
|
|
opts: { out?: string; collection?: string },
|
|
) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
stderr.write("Invalid file ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const collections = await client.listCollections();
|
|
let targetCol;
|
|
if (opts.collection) {
|
|
targetCol = collections.find(
|
|
(c) => c.id === Number(opts.collection),
|
|
);
|
|
}
|
|
|
|
// Search all collections (or the specified one) for the file
|
|
const searchCols = targetCol ? [targetCol] : collections;
|
|
for (const col of searchCols) {
|
|
const files = await client.listFiles(col.id, col.key);
|
|
const file = files.find((f) => f.id === fileID);
|
|
if (file) {
|
|
const result = await client.downloadFile(file, opts.out);
|
|
stderr.write(
|
|
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
stderr.write(`File ${fileID} not found\n`);
|
|
process.exit(1);
|
|
},
|
|
);
|
|
|
|
program
|
|
.command("get-thumb")
|
|
.description("Download and decrypt a thumbnail")
|
|
.argument("<fileID>", "File ID (from `quak files`)")
|
|
.option("--out <path>", "Output file path")
|
|
.option(
|
|
"--collection <id>",
|
|
"Collection ID (required to look up the file key)",
|
|
)
|
|
.action(
|
|
async (
|
|
fileIDStr: string,
|
|
opts: { out?: string; collection?: string },
|
|
) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const fileID = Number(fileIDStr);
|
|
if (!Number.isFinite(fileID)) {
|
|
stderr.write("Invalid file ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const collections = await client.listCollections();
|
|
let targetCol;
|
|
if (opts.collection) {
|
|
targetCol = collections.find(
|
|
(c) => c.id === Number(opts.collection),
|
|
);
|
|
}
|
|
|
|
const searchCols = targetCol ? [targetCol] : collections;
|
|
for (const col of searchCols) {
|
|
const files = await client.listFiles(col.id, col.key);
|
|
const file = files.find((f) => f.id === fileID);
|
|
if (file) {
|
|
const result = await client.downloadThumbnail(
|
|
file,
|
|
opts.out,
|
|
);
|
|
stderr.write(
|
|
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
stderr.write(`File ${fileID} not found\n`);
|
|
process.exit(1);
|
|
},
|
|
);
|
|
|
|
program
|
|
.command("backup-metadata")
|
|
.description(
|
|
"Dump all decrypted account metadata to a directory of JSON files",
|
|
)
|
|
.argument("<dir>", "Output directory")
|
|
.option(
|
|
"--exif",
|
|
"Download each file and extract full EXIF/IPTC/XMP metadata (slow)",
|
|
)
|
|
.option("--all", "Alias for --exif")
|
|
.action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
await runMetadataBackup(client, dir, {
|
|
exif: opts.exif || opts.all,
|
|
onProgress: (msg) => stderr.write(msg + "\n"),
|
|
});
|
|
});
|
|
|
|
program
|
|
.command("backup")
|
|
.description(
|
|
"Download all photos to a local directory, organized by collection",
|
|
)
|
|
.argument("<dir>", "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);
|
|
});
|
|
|
|
const helper = program
|
|
.command("helper")
|
|
.description("Maintenance and repair utilities");
|
|
|
|
helper
|
|
.command("list-missing-thumbnails")
|
|
.description("List files whose thumbnails are missing or empty")
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const missing = await listMissingThumbnails(client, (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
});
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(missing, null, 2) + "\n");
|
|
} else {
|
|
if (missing.length === 0) {
|
|
stderr.write("No missing thumbnails found.\n");
|
|
} else {
|
|
stderr.write(
|
|
`\n${missing.length} file(s) with missing thumbnails:\n`,
|
|
);
|
|
for (const m of missing) {
|
|
stdout.write(
|
|
`${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
helper
|
|
.command("fix-missing-thumbnails")
|
|
.description(
|
|
"Generate and upload thumbnails for files that are missing them",
|
|
)
|
|
.option(
|
|
"--file <ids...>",
|
|
"Specific file IDs to fix (default: fix all missing)",
|
|
)
|
|
.option("--json", "Output as JSON")
|
|
.action(async (opts: { file?: string[]; json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
|
|
let fileIDs: number[];
|
|
if (opts.file && opts.file.length > 0) {
|
|
fileIDs = opts.file.map(Number).filter(Number.isFinite);
|
|
} else {
|
|
stderr.write("Scanning for missing thumbnails...\n");
|
|
const missing = await listMissingThumbnails(client, (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
});
|
|
fileIDs = missing.map((m) => m.fileID);
|
|
if (fileIDs.length === 0) {
|
|
stderr.write("No missing thumbnails found.\n");
|
|
return;
|
|
}
|
|
stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
|
|
}
|
|
|
|
const results = await fixMissingThumbnails(client, fileIDs, (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
});
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
} else {
|
|
const ok = results.filter((r) => r.success).length;
|
|
const fail = results.filter((r) => !r.success).length;
|
|
stderr.write(`\n--- Done ---\n`);
|
|
stderr.write(` Fixed: ${ok}\n`);
|
|
stderr.write(` Failed: ${fail}\n`);
|
|
if (fail > 0) {
|
|
stderr.write("\nFailed files:\n");
|
|
for (const r of results.filter((r) => !r.success)) {
|
|
stderr.write(` ${r.fileID}\t${r.title}\t${r.error}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
process.exit(results.some((r) => !r.success) ? 1 : 0);
|
|
});
|
|
|
|
await init();
|
|
program.parse();
|