Files
2026-07-13 13:39:12 +08:00

470 lines
15 KiB
JavaScript

import {
copyFileSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import { dirname, join, extname, basename } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { resolveDataDir } from "../data-dir.mjs";
import { getBaseUrl, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
import { backupSqliteFile } from "../sqlite.mjs";
import { CLI_TOKEN_HEADER, getCliToken } from "../utils/cliToken.mjs";
function getBackupDir() {
return join(resolveDataDir(), "backups");
}
const FILES_TO_BACKUP = [
{ name: "storage.sqlite" },
{ name: "settings.json" },
{ name: "combos.json" },
{ name: "providers.json" },
];
export function registerBackup(program) {
const backup = program.command("backup").description(t("backup.description"));
backup
.command("create")
.description(t("backup.createDescription"))
.option("--name <name>", t("backup.nameOpt"))
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--key-file <path>", t("backup.keyFileOpt"))
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
.option("--retention <n>", t("backup.retentionOpt"), parseInt)
.action(async (opts) => {
const exitCode = await runBackupCommand(opts);
if (exitCode !== 0) process.exit(exitCode);
});
const auto = backup.command("auto").description(t("backup.auto.title"));
auto
.command("enable")
.description(t("backup.auto.enableDescription"))
.option("--cron <expr>", t("backup.auto.cronOpt"), "0 3 * * *")
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--retention <n>", t("backup.retentionOpt"), parseInt)
.action(async (opts) => {
const exitCode = await runBackupAutoEnableCommand(opts);
if (exitCode !== 0) process.exit(exitCode);
});
auto
.command("disable")
.description(t("backup.auto.disableDescription"))
.action(async () => {
const exitCode = await runBackupAutoDisableCommand();
if (exitCode !== 0) process.exit(exitCode);
});
auto
.command("status")
.description(t("backup.auto.statusDescription"))
.action(async () => {
const exitCode = await runBackupAutoStatusCommand();
if (exitCode !== 0) process.exit(exitCode);
});
// Legacy: `omniroute backup` without subcommand still creates a backup
backup.action(async (opts) => {
const exitCode = await runBackupCommand(opts);
if (exitCode !== 0) process.exit(exitCode);
});
backup
.option("--name <name>", t("backup.nameOpt"))
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--key-file <path>", t("backup.keyFileOpt"))
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
.option("--retention <n>", t("backup.retentionOpt"), parseInt);
}
export function registerRestore(program) {
program
.command("restore [backupId]")
.description(t("backup.restoreDescription"))
.option("--list", "List available backups")
.option("--yes", "Skip confirmation")
.action(async (backupId, opts) => {
const exitCode = await runRestoreCommand(backupId, opts);
if (exitCode !== 0) process.exit(exitCode);
});
}
function matchesGlob(fileName, pattern) {
if (!pattern.includes("*")) return fileName === pattern;
const parts = pattern.split("*");
let pos = 0;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (i === 0) {
if (!fileName.startsWith(part)) return false;
pos = part.length;
} else if (i === parts.length - 1) {
if (!fileName.endsWith(part)) return false;
if (fileName.length < pos + part.length) return false;
} else {
const idx = fileName.indexOf(part, pos);
if (idx === -1) return false;
pos = idx + part.length;
}
}
return true;
}
function shouldExclude(fileName, patterns) {
if (!patterns || patterns.length === 0) return false;
return patterns.some((p) => matchesGlob(fileName, p));
}
async function encryptFile(srcPath, destPath, passphrase) {
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = scryptSync(passphrase, salt, 32);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const tmpCipherPath = `${destPath}.ciphertext`;
await pipeline(createReadStream(srcPath), cipher, createWriteStream(tmpCipherPath));
const authTag = cipher.getAuthTag();
// Format: salt(16) + iv(12) + authTag(16) + ciphertext
const out = createWriteStream(destPath);
try {
await new Promise((resolve, reject) => {
out.write(Buffer.concat([salt, iv, authTag]), (err) => {
if (err) reject(err);
else resolve();
});
});
await pipeline(createReadStream(tmpCipherPath), out);
} finally {
try {
unlinkSync(tmpCipherPath);
} catch {}
}
}
async function promptPassphrase() {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) =>
rl.question(t("backup.passphrasePrompt"), (ans) => {
rl.close();
resolve(ans.trim());
})
);
}
async function pruneBackups(backupDir, retention) {
if (!retention || retention <= 0 || !existsSync(backupDir)) return;
try {
const dirs = readdirSync(backupDir)
.filter((f) => f.startsWith("omniroute-backup-"))
.sort()
.reverse();
for (const old of dirs.slice(retention)) {
const { rmSync } = await import("node:fs");
rmSync(join(backupDir, old), { recursive: true, force: true });
}
} catch {}
}
export async function runBackupCommand(opts = {}) {
const dataDir = resolveDataDir();
const backupDir = getBackupDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const safeName = opts.name ? String(opts.name).replace(/[/\\]/g, "_") : null;
const backupName = safeName ? `omniroute-backup-${safeName}` : `omniroute-backup-${timestamp}`;
const backupPath = join(backupDir, backupName);
const excludePatterns = opts.exclude || [];
console.log(t("backup.creating"));
let passphrase = null;
if (opts.encrypt) {
if (opts.keyFile) {
passphrase = readFileSync(opts.keyFile, "utf8").trim();
} else {
passphrase = await promptPassphrase();
if (!passphrase) {
console.error(t("backup.noPassphrase"));
return 1;
}
}
}
try {
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
let backedUp = 0;
let skipped = 0;
for (const file of FILES_TO_BACKUP) {
if (shouldExclude(file.name, excludePatterns)) {
skipped++;
continue;
}
const sourcePath = join(dataDir, file.name);
if (existsSync(sourcePath)) {
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
const destPath = join(backupPath, destName);
mkdirSync(dirname(destPath), { recursive: true });
if (file.name.endsWith(".sqlite")) {
const tmpPath = destPath.replace(/\.enc$/, "");
await backupSqliteFile(sourcePath, tmpPath);
if (opts.encrypt) {
await encryptFile(tmpPath, destPath, passphrase);
unlinkSync(tmpPath);
}
} else if (opts.encrypt) {
await encryptFile(sourcePath, destPath, passphrase);
} else {
copyFileSync(sourcePath, destPath);
}
backedUp++;
} else {
skipped++;
}
}
if (backedUp > 0) {
const info = {
timestamp: new Date().toISOString(),
version: "omniroute-cli-v1",
encrypted: !!opts.encrypt,
files: FILES_TO_BACKUP.filter(
(f) => existsSync(join(dataDir, f.name)) && !shouldExclude(f.name, excludePatterns)
).map((f) => (opts.encrypt ? `${f.name}.enc` : f.name)),
};
writeFileSync(join(backupPath, "backup-info.json"), JSON.stringify(info, null, 2), "utf8");
if (opts.cloud) {
const cloudCode = await _uploadBackupToCloud(backupPath, info);
if (cloudCode !== 0) {
console.warn(t("backup.cloudFailed"));
}
}
if (opts.retention) {
await pruneBackups(backupDir, opts.retention);
}
console.log(t("backup.done", { path: backupPath }));
console.log(
`\x1b[2m ${backedUp} backed up, ${skipped} skipped${opts.encrypt ? " (encrypted)" : ""}\x1b[0m`
);
return 0;
}
console.log(t("backup.noFiles"));
return 0;
} catch (err) {
console.error(t("backup.failed", { error: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
async function _uploadBackupToCloud(backupPath, info) {
const serverUp = await isServerUp();
if (!serverUp) {
console.warn(t("common.serverOffline"));
return 1;
}
try {
const boundary = `omniroute-backup-${Date.now().toString(36)}-${randomBytes(8).toString("hex")}`;
const headers = new Headers({
accept: "application/json",
"content-type": `multipart/form-data; boundary=${boundary}`,
});
const apiKey = process.env.OMNIROUTE_API_KEY;
if (apiKey) headers.set("authorization", `Bearer ${apiKey}`);
const cliToken = process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
if (cliToken) headers.set(CLI_TOKEN_HEADER, cliToken);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const res = await fetch(`${getBaseUrl()}/api/db-backups/cloud`, {
method: "POST",
headers,
body: Readable.from(createBackupMultipartStream(backupPath, info, boundary)),
duplex: "half",
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
if (res.ok) {
const data = await res.json();
console.log(t("backup.cloudUploaded", { url: data.url || "(stored)" }));
return 0;
}
return 1;
} catch {
return 1;
}
}
async function* createBackupMultipartStream(backupPath, info, boundary) {
const encoder = new TextEncoder();
const encode = (value) => encoder.encode(value);
yield encode(
`--${boundary}\r\nContent-Disposition: form-data; name="info"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(info)}\r\n`
);
for (const fname of readdirSync(backupPath)) {
const fullPath = join(backupPath, fname);
const stat = statSync(fullPath);
if (!stat.isFile()) continue;
const safeName = fname.replace(/["\r\n]/g, "_");
yield encode(
`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${safeName}"\r\nContent-Type: application/octet-stream\r\n\r\n`
);
yield* createReadStream(fullPath);
yield encode("\r\n");
}
yield encode(`--${boundary}--\r\n`);
}
function getSchedulePath() {
return join(resolveDataDir(), "backup-schedule.json");
}
export async function runBackupAutoEnableCommand(opts = {}) {
const schedulePath = getSchedulePath();
const schedule = {
enabled: true,
cron: opts.cron || "0 3 * * *",
cloud: !!opts.cloud,
encrypt: !!opts.encrypt,
retention: opts.retention || null,
updatedAt: new Date().toISOString(),
};
mkdirSync(dirname(schedulePath), { recursive: true });
writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8");
console.log(t("backup.auto.enabled", { cron: schedule.cron }));
console.log(t("backup.auto.hint"));
return 0;
}
export async function runBackupAutoDisableCommand() {
const schedulePath = getSchedulePath();
if (existsSync(schedulePath)) {
const schedule = JSON.parse(readFileSync(schedulePath, "utf8"));
schedule.enabled = false;
schedule.updatedAt = new Date().toISOString();
writeFileSync(schedulePath, JSON.stringify(schedule, null, 2), "utf8");
}
console.log(t("backup.auto.disabled"));
return 0;
}
export async function runBackupAutoStatusCommand() {
const schedulePath = getSchedulePath();
if (!existsSync(schedulePath)) {
console.log(t("backup.auto.notConfigured"));
return 0;
}
const schedule = JSON.parse(readFileSync(schedulePath, "utf8"));
const statusLabel = schedule.enabled ? "\x1b[32m● enabled\x1b[0m" : "\x1b[31m○ disabled\x1b[0m";
console.log(`${t("backup.auto.title")}: ${statusLabel}`);
console.log(` cron: ${schedule.cron}`);
console.log(` cloud: ${schedule.cloud ? "yes" : "no"}`);
console.log(` encrypt: ${schedule.encrypt ? "yes" : "no"}`);
console.log(` retention: ${schedule.retention ?? "unlimited"}`);
return 0;
}
export async function runRestoreCommand(backupId, opts = {}) {
const backupDir = getBackupDir();
if (opts.list || !backupId) {
console.log(`\n\x1b[1m\x1b[36m${t("backup.listTitle")}\x1b[0m\n`);
if (!existsSync(backupDir)) {
console.log(t("backup.noBackups"));
return 0;
}
try {
const dirs = readdirSync(backupDir)
.filter((f) => f.startsWith("omniroute-backup-"))
.sort()
.reverse();
if (dirs.length === 0) {
console.log(t("backup.noBackups"));
return 0;
}
for (const dir of dirs) {
const infoPath = join(backupDir, dir, "backup-info.json");
if (existsSync(infoPath)) {
const info = JSON.parse(readFileSync(infoPath, "utf8"));
const id = dir.replace("omniroute-backup-", "");
const dateStr = new Date(info.timestamp).toLocaleString();
console.log(` ${id}`);
console.log(`\x1b[2m ${dateStr}${info.files?.length || 0} files\x1b[0m`);
} else {
console.log(`\x1b[2m ${dir.replace("omniroute-backup-", "")}\x1b[0m`);
}
}
} catch (err) {
console.error(
t("common.error", { message: err instanceof Error ? err.message : String(err) })
);
return 1;
}
if (!backupId) console.log("\nUsage: omniroute restore <backup-id>");
return 0;
}
const safeBackupId = String(backupId).replace(/[/\\]/g, "_");
const backupPath = join(backupDir, `omniroute-backup-${safeBackupId}`);
if (!existsSync(backupPath)) {
console.error(t("backup.notFound", { name: backupId }));
return 1;
}
const infoPath = join(backupPath, "backup-info.json");
const ts = existsSync(infoPath) ? JSON.parse(readFileSync(infoPath, "utf8")).timestamp : backupId;
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("backup.confirmRestore", { ts }) + " [y/N] ", resolve)
);
rl.close();
if (!/^y(es)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}
console.log(t("backup.restoring", { path: backupPath }));
const dataDir = resolveDataDir();
try {
for (const file of FILES_TO_BACKUP) {
const sourcePath = join(backupPath, file.name);
if (existsSync(sourcePath)) {
copyFileSync(sourcePath, join(dataDir, file.name));
console.log(`\x1b[2m Restored: ${file.name}\x1b[0m`);
}
}
console.log(t("backup.restored"));
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}