From 72dedba64e5eda6879a7e7b4df02b84273436e8b Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 10:40:10 +0800 Subject: [PATCH] feat(webui): tar.gz backup archive module with prune and mutex --- src/lib/server/backup.test.ts | 83 +++++++++++++++++++++++++ src/lib/server/backup.ts | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/lib/server/backup.test.ts create mode 100644 src/lib/server/backup.ts diff --git a/src/lib/server/backup.test.ts b/src/lib/server/backup.test.ts new file mode 100644 index 0000000..a60a644 --- /dev/null +++ b/src/lib/server/backup.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backup-")); +const backendDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-backend-")); +const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR; +process.env.EVOSCIENTIST_DATA_DIR = dataDir; + +const backup = await import("./backup"); +const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } = + await import("./systemConfig"); + +describe("backup", () => { + beforeEach(() => { + fs.rmSync(backup.backupsDir(), { recursive: true, force: true }); + const config = getSystemConfig(); + config.backup.backendDataDir = backendDir; + saveSystemConfig(config); + resetSystemConfigCacheForTests(); + }); + + afterAll(() => { + if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR; + else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir; + fs.rmSync(dataDir, { recursive: true, force: true }); + fs.rmSync(backendDir, { recursive: true, force: true }); + }); + + it("creates a tar.gz containing webui + backend data, excluding backups/", async () => { + fs.writeFileSync(path.join(dataDir, "webui-users.sqlite3"), "users"); + fs.mkdirSync(backup.backupsDir(), { recursive: true }); + fs.writeFileSync(path.join(backup.backupsDir(), "stale.txt"), "x"); + fs.writeFileSync(path.join(backendDir, "memory.md"), "memory"); + + const entry = await backup.createBackup("manual"); + expect(entry.name).toMatch(/^backup-\d{8}T\d{6}-manual\.tar\.gz$/); + expect(entry.size).toBeGreaterThan(0); + + const listing = execFileSync("tar", [ + "-tzf", + path.join(backup.backupsDir(), entry.name), + ]).toString(); + expect(listing).toContain("webui-users.sqlite3"); + expect(listing).toContain("memory.md"); + expect(listing).not.toContain("stale.txt"); + }); + + it("validates names and rejects traversal", () => { + expect(backup.isValidBackupName("backup-20260811T073000-auto.tar.gz")).toBe(true); + expect(backup.isValidBackupName("../etc/passwd")).toBe(false); + expect(backup.isValidBackupName("backup.tar.gz")).toBe(false); + expect(() => backup.backupFilePath("..%2f..")).toThrow(); + }); + + it("lists newest first and prunes beyond keep count", async () => { + const dir = backup.backupsDir(); + fs.mkdirSync(dir, { recursive: true }); + const names = [ + "backup-20260801T000000-manual.tar.gz", + "backup-20260802T000000-auto.tar.gz", + "backup-20260803T000000-manual.tar.gz", + ]; + for (const name of names) fs.writeFileSync(path.join(dir, name), "x"); + fs.writeFileSync(path.join(dir, "ignore-me.txt"), "x"); + + expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse()); + expect(backup.pruneBackups(2)).toEqual([names[0]]); + expect(backup.listBackups().map((e) => e.name)).toEqual([...names].reverse().slice(0, 2)); + }); + + it("shares one in-flight run across concurrent callers", async () => { + const [a, b] = await Promise.all([ + backup.createBackup("manual"), + backup.createBackup("auto"), + ]); + expect(a.name).toBe(b.name); + }); +}); diff --git a/src/lib/server/backup.ts b/src/lib/server/backup.ts new file mode 100644 index 0000000..812f4be --- /dev/null +++ b/src/lib/server/backup.ts @@ -0,0 +1,114 @@ +import "server-only"; + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { webuiDataDir } from "./dataDir"; +import { getSystemConfig } from "./systemConfig"; + +const execFileAsync = promisify(execFile); + +export interface BackupEntry { + name: string; + size: number; + createdAt: string; + origin: "manual" | "auto"; +} + +const NAME_RE = /^backup-(\d{8}T\d{6})-(manual|auto)\.tar\.gz$/; + +export function backupsDir(): string { + return path.join(webuiDataDir(), "backups"); +} + +export function isValidBackupName(name: string): boolean { + return NAME_RE.test(name); +} + +export function backupFilePath(name: string): string { + if (!isValidBackupName(name)) throw new Error("Invalid backup file name."); + return path.join(backupsDir(), name); +} + +function timestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + + `T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` + ); +} + +let running: Promise | null = null; + +export function createBackup(origin: "manual" | "auto"): Promise { + if (running) return running; + running = runBackup(origin).finally(() => { + running = null; + }); + return running; +} + +async function runBackup(origin: "manual" | "auto"): Promise { + const config = getSystemConfig(); + const dataDir = webuiDataDir(); + const outDir = backupsDir(); + fs.mkdirSync(outDir, { recursive: true }); + let name = `backup-${timestamp(new Date())}-${origin}.tar.gz`; + let out = path.join(outDir, name); + // Names carry second precision; a same-second collision waits one second + // out and recomputes so every archive keeps a unique, regex-valid name. + while (fs.existsSync(out)) { + await new Promise((r) => setTimeout(r, 1100)); + name = `backup-${timestamp(new Date())}-${origin}.tar.gz`; + out = path.join(outDir, name); + } + const base = path.basename(dataDir); + const args = [ + "-czf", out, + "--exclude", `${base}/backups`, + "-C", path.dirname(dataDir), base, + ]; + const backend = config.backup.backendDataDir.trim(); + if (backend) { + if (!fs.existsSync(backend)) { + throw new Error(`Backend data directory does not exist: ${backend}`); + } + args.push("-C", path.dirname(backend), path.basename(backend)); + } + try { + await execFileAsync("tar", args); + } catch (error) { + fs.rmSync(out, { force: true }); + throw error instanceof Error ? error : new Error("Backup archive failed."); + } + const stat = fs.statSync(out); + return { name, size: stat.size, createdAt: stat.mtime.toISOString(), origin }; +} + +export function listBackups(): BackupEntry[] { + const dir = backupsDir(); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter(isValidBackupName) + .map((name) => { + const stat = fs.statSync(path.join(dir, name)); + return { + name, + size: stat.size, + createdAt: stat.mtime.toISOString(), + origin: name.match(NAME_RE)![2] as "manual" | "auto", + }; + }) + .sort((a, b) => b.name.localeCompare(a.name)); +} + +export function pruneBackups(keepCount: number): string[] { + const removed: string[] = []; + for (const entry of listBackups().slice(Math.max(0, keepCount))) { + fs.rmSync(backupFilePath(entry.name), { force: true }); + removed.push(entry.name); + } + return removed; +}