| import fs from "fs/promises";
|
| import path from "path";
|
| import { resolveDataDir } from "@/lib/dataPaths";
|
|
|
| const BACKUP_DIR = path.join(resolveDataDir(), "backups");
|
| const MAX_BACKUPS_PER_TOOL = 5;
|
|
|
| |
| |
| |
|
|
| function safePath(...segments: string[]): string {
|
| const resolved = path.resolve(BACKUP_DIR, ...segments);
|
| const base = path.resolve(BACKUP_DIR);
|
| if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
| throw new Error("Invalid path: directory traversal detected");
|
| }
|
| return resolved;
|
| }
|
|
|
| |
| |
|
|
| function getToolBackupDir(toolId: string) {
|
| return safePath(toolId);
|
| }
|
|
|
| |
| |
|
|
| async function ensureBackupDir(toolId: string) {
|
| const dir = getToolBackupDir(toolId);
|
| await fs.mkdir(dir, { recursive: true });
|
| return dir;
|
| }
|
|
|
| |
| |
|
|
| function makeBackupName(originalPath: string) {
|
| const ext = path.extname(originalPath);
|
| const base = path.basename(originalPath, ext);
|
| const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| return `${base}_${ts}${ext}`;
|
| }
|
|
|
| |
| |
| |
|
|
| export async function createBackup(toolId: string, filePath: string) {
|
| try {
|
| await fs.access(filePath);
|
| } catch {
|
|
|
| return null;
|
| }
|
|
|
| const dir = await ensureBackupDir(toolId);
|
| const backupName = makeBackupName(filePath);
|
| const backupPath = path.join(dir, backupName);
|
|
|
| await fs.copyFile(filePath, backupPath);
|
|
|
|
|
| const metaPath = backupPath + ".meta.json";
|
| await fs.writeFile(
|
| metaPath,
|
| JSON.stringify({
|
| originalPath: filePath,
|
| backupName,
|
| toolId,
|
| createdAt: new Date().toISOString(),
|
| })
|
| );
|
|
|
|
|
| await rotateBackups(toolId);
|
|
|
| return backupPath;
|
| }
|
|
|
| |
| |
| |
|
|
| export async function createMultiBackup(toolId: string, filePaths: string[]) {
|
| const results: (string | null)[] = [];
|
| for (const filePath of filePaths) {
|
| const result = await createBackup(toolId, filePath);
|
| results.push(result);
|
| }
|
| return results;
|
| }
|
|
|
| |
| |
|
|
| export async function listBackups(toolId: string) {
|
| const dir = getToolBackupDir(toolId);
|
|
|
| let entries;
|
| try {
|
| entries = await fs.readdir(dir);
|
| } catch {
|
| return [];
|
| }
|
|
|
| const metaFiles = entries.filter((e) => e.endsWith(".meta.json"));
|
| const backups: any[] = [];
|
|
|
| for (const metaFile of metaFiles) {
|
| try {
|
| const metaPath = path.join(dir, metaFile);
|
| const raw = await fs.readFile(metaPath, "utf-8");
|
| const meta = JSON.parse(raw);
|
|
|
| const backupFile = metaFile.replace(".meta.json", "");
|
| const backupPath = path.join(dir, backupFile);
|
|
|
| let size = 0;
|
| try {
|
| const stat = await fs.stat(backupPath);
|
| size = stat.size;
|
| } catch {
|
|
|
| continue;
|
| }
|
|
|
| backups.push({
|
| id: backupFile,
|
| toolId: meta.toolId,
|
| originalPath: meta.originalPath,
|
| createdAt: meta.createdAt,
|
| size,
|
| });
|
| } catch {
|
|
|
| }
|
| }
|
|
|
|
|
| backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
| return backups;
|
| }
|
|
|
| |
| |
|
|
| export async function restoreBackup(toolId: string, backupId: string) {
|
| const dir = getToolBackupDir(toolId);
|
|
|
| const backupPath = safePath(toolId, backupId);
|
| const metaPath = backupPath + ".meta.json";
|
|
|
|
|
| let meta;
|
| try {
|
| const raw = await fs.readFile(metaPath, "utf-8");
|
| meta = JSON.parse(raw);
|
| } catch {
|
| throw new Error(`Backup metadata not found: ${backupId}`);
|
| }
|
|
|
|
|
| try {
|
| await fs.access(backupPath);
|
| } catch {
|
| throw new Error(`Backup file not found: ${backupId}`);
|
| }
|
|
|
|
|
| await createBackup(toolId, meta.originalPath);
|
|
|
|
|
| const targetDir = path.dirname(meta.originalPath);
|
| await fs.mkdir(targetDir, { recursive: true });
|
| await fs.copyFile(backupPath, meta.originalPath);
|
|
|
| return {
|
| restored: true,
|
| backupId,
|
| originalPath: meta.originalPath,
|
| };
|
| }
|
|
|
| |
| |
|
|
| export async function deleteBackup(toolId: string, backupId: string) {
|
|
|
| const backupPath = safePath(toolId, backupId);
|
| const metaPath = backupPath + ".meta.json";
|
|
|
| try {
|
| await fs.unlink(backupPath);
|
| } catch {
|
|
|
| }
|
| try {
|
| await fs.unlink(metaPath);
|
| } catch {
|
|
|
| }
|
|
|
| return { deleted: true, backupId };
|
| }
|
|
|
| |
| |
| |
|
|
| async function rotateBackups(toolId: string) {
|
| const all = await listBackups(toolId);
|
|
|
|
|
| const groups: Record<string, any[]> = {};
|
| for (const b of all) {
|
| const key = path.basename(b.originalPath);
|
| if (!groups[key]) groups[key] = [];
|
| groups[key].push(b);
|
| }
|
|
|
| for (const [, group] of Object.entries(groups) as [string, any[]][]) {
|
|
|
| if (group.length > MAX_BACKUPS_PER_TOOL) {
|
| const toDelete = group.slice(MAX_BACKUPS_PER_TOOL);
|
| for (const old of toDelete) {
|
| await deleteBackup(toolId, old.id);
|
| }
|
| }
|
| }
|
| }
|
|
|