159 lines
4.7 KiB
JavaScript
159 lines
4.7 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import Database from "better-sqlite3";
|
|
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const config = ensureDataLayout();
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
|
|
const uploadsBackupDir = path.join(backupDir, "uploads");
|
|
const databaseBackupPath = path.join(backupDir, "neta.db");
|
|
|
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
|
|
const sqlite = new Database(config.databasePath);
|
|
|
|
try {
|
|
applySqlitePragmas(sqlite);
|
|
await sqlite.backup(databaseBackupPath);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
|
|
|
|
const manifest = {
|
|
format: "neta-backup",
|
|
version: 1,
|
|
createdAt: new Date().toISOString(),
|
|
source: {
|
|
dataDir: config.dataDir,
|
|
databasePath: config.databasePath,
|
|
uploadsDir: config.uploadsDir,
|
|
},
|
|
files: collectFiles(backupDir).map((filePath) => ({
|
|
path: path.relative(backupDir, filePath).replace(/\\/g, "/"),
|
|
bytes: fs.statSync(filePath).size,
|
|
sha256: hashFile(filePath),
|
|
})),
|
|
};
|
|
|
|
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
const removedBackups = applyRetention(config.backupsDir, backupDir, args.retentionCount);
|
|
console.log(`Backup created at ${backupDir}`);
|
|
if (args.retentionCount !== null) {
|
|
console.log(
|
|
`Retention kept the newest ${args.retentionCount} backup(s); removed ${removedBackups.length}.`,
|
|
);
|
|
}
|
|
|
|
function parseArgs(values) {
|
|
let retentionCount = parseOptionalPositiveInteger(
|
|
process.env.BACKUP_RETENTION_COUNT,
|
|
"BACKUP_RETENTION_COUNT",
|
|
);
|
|
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
const value = values[index];
|
|
if (value === "--retention-count") {
|
|
retentionCount = parseOptionalPositiveInteger(values[index + 1], "--retention-count");
|
|
if (retentionCount === null) {
|
|
throw new Error("--retention-count requires a positive integer.");
|
|
}
|
|
index += 1;
|
|
} else {
|
|
throw new Error(
|
|
"Usage: node scripts/backup.mjs [--retention-count <positive-integer>]",
|
|
);
|
|
}
|
|
}
|
|
|
|
return { retentionCount };
|
|
}
|
|
|
|
function parseOptionalPositiveInteger(value, name) {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
if (!/^[1-9]\d*$/.test(value)) {
|
|
throw new Error(`${name} must be a positive integer.`);
|
|
}
|
|
return Number.parseInt(value, 10);
|
|
}
|
|
|
|
function copyDirectoryIfExists(sourceDir, targetDir) {
|
|
if (!fs.existsSync(sourceDir)) {
|
|
return;
|
|
}
|
|
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
|
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
const sourcePath = path.join(sourceDir, entry.name);
|
|
const targetPath = path.join(targetDir, entry.name);
|
|
|
|
if (entry.isSymbolicLink()) {
|
|
throw new Error(`Upload tree contains a symbolic link: ${sourcePath}`);
|
|
} else if (entry.isDirectory()) {
|
|
copyDirectoryIfExists(sourcePath, targetPath);
|
|
} else if (entry.isFile()) {
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
} else {
|
|
throw new Error(`Upload tree contains an unsupported filesystem entry: ${sourcePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectFiles(rootDir) {
|
|
const files = [];
|
|
|
|
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
const entryPath = path.join(rootDir, entry.name);
|
|
|
|
if (entry.isSymbolicLink()) {
|
|
throw new Error(`Backup contains a symbolic link: ${entryPath}`);
|
|
} else if (entry.isDirectory()) {
|
|
files.push(...collectFiles(entryPath));
|
|
} else if (entry.isFile()) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function hashFile(filePath) {
|
|
const hash = crypto.createHash("sha256");
|
|
hash.update(fs.readFileSync(filePath));
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
function applyRetention(backupsDir, currentBackupDir, retentionCount) {
|
|
if (retentionCount === null) return [];
|
|
|
|
const candidates = fs
|
|
.readdirSync(backupsDir, { withFileTypes: true })
|
|
.filter(
|
|
(entry) =>
|
|
entry.isDirectory() &&
|
|
!entry.isSymbolicLink() &&
|
|
/^neta-\d{4}-\d{2}-\d{2}T/.test(entry.name) &&
|
|
fs.existsSync(path.join(backupsDir, entry.name, "manifest.json")),
|
|
)
|
|
.map((entry) => path.join(backupsDir, entry.name))
|
|
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)));
|
|
|
|
const currentIndex = candidates.indexOf(currentBackupDir);
|
|
if (currentIndex > 0) {
|
|
candidates.splice(currentIndex, 1);
|
|
candidates.unshift(currentBackupDir);
|
|
}
|
|
|
|
const removed = candidates.slice(retentionCount);
|
|
for (const candidate of removed) {
|
|
fs.rmSync(candidate, { recursive: true, force: false });
|
|
}
|
|
return removed;
|
|
}
|