Some checks failed
check / check (push) Failing after 8s
list-missing-thumbnails: iterates all files across all collections, fetches each thumbnail from the CDN, reports any that are missing or empty. Deduplicates by file ID across collections. fix-missing-thumbnails: for each missing thumbnail, downloads the original file, generates a 720px JPEG thumbnail via sharp, encrypts it with secretstream push (encryptBlob), uploads to a presigned URL, and registers the new thumbnail via PUT /files/thumbnail. New crypto: encryptBlob (secretstream push, single chunk TAG_FINAL). New ApiClient methods: getUploadURL, putFile, putJSON, updateThumbnail. New Client method: getApiClient() for modules that need raw API access. Deps: sharp 0.34.5 (image processing), @types/sharp 0.32.0.
460 lines
15 KiB
JavaScript
460 lines
15 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 {
|
|
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")
|
|
.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);
|
|
});
|
|
|
|
program.parse();
|