mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
Squash merge PR #282: bug fixes for #273 (Gemini image routing), #276 (Ollama Cloud models), #277 (missing apiKey error), lint fix, and all security code-scanning patches.
234 lines
6.0 KiB
TypeScript
234 lines
6.0 KiB
TypeScript
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;
|
|
|
|
/**
|
|
* Resolve a path within BACKUP_DIR and verify it stays within bounds.
|
|
* Throws if the resolved path escapes BACKUP_DIR (path traversal guard).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Get backup directory for a specific tool
|
|
*/
|
|
function getToolBackupDir(toolId: string) {
|
|
return safePath(toolId);
|
|
}
|
|
|
|
/**
|
|
* Ensure backup directory exists for a tool
|
|
*/
|
|
async function ensureBackupDir(toolId: string) {
|
|
const dir = getToolBackupDir(toolId);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
/**
|
|
* Generate a backup filename with timestamp
|
|
*/
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* Create a backup of a file before modifying it.
|
|
* Returns the backup path, or null if the source doesn't exist.
|
|
*/
|
|
export async function createBackup(toolId: string, filePath: string) {
|
|
try {
|
|
await fs.access(filePath);
|
|
} catch {
|
|
// Source file doesn't exist — nothing to back up
|
|
return null;
|
|
}
|
|
|
|
const dir = await ensureBackupDir(toolId);
|
|
const backupName = makeBackupName(filePath);
|
|
const backupPath = path.join(dir, backupName);
|
|
|
|
await fs.copyFile(filePath, backupPath);
|
|
|
|
// Save metadata alongside the backup
|
|
const metaPath = backupPath + ".meta.json";
|
|
await fs.writeFile(
|
|
metaPath,
|
|
JSON.stringify({
|
|
originalPath: filePath,
|
|
backupName,
|
|
toolId,
|
|
createdAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
// Enforce rotation (max backups per tool)
|
|
await rotateBackups(toolId);
|
|
|
|
return backupPath;
|
|
}
|
|
|
|
/**
|
|
* Create backups for multiple files in one operation (e.g. Codex config.toml + auth.json).
|
|
* Returns an array of backup paths.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* List all backups for a tool (sorted newest first).
|
|
*/
|
|
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 {
|
|
// Backup file missing — skip
|
|
continue;
|
|
}
|
|
|
|
backups.push({
|
|
id: backupFile,
|
|
toolId: meta.toolId,
|
|
originalPath: meta.originalPath,
|
|
createdAt: meta.createdAt,
|
|
size,
|
|
});
|
|
} catch {
|
|
// Corrupt meta — skip
|
|
}
|
|
}
|
|
|
|
// Sort newest first
|
|
backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
return backups;
|
|
}
|
|
|
|
/**
|
|
* Restore a backup by its id (filename).
|
|
*/
|
|
export async function restoreBackup(toolId: string, backupId: string) {
|
|
const dir = getToolBackupDir(toolId);
|
|
// Anchor backupId within the tool dir — prevent path traversal via backupId
|
|
const backupPath = safePath(toolId, backupId);
|
|
const metaPath = backupPath + ".meta.json";
|
|
|
|
// Read metadata to find original path
|
|
let meta;
|
|
try {
|
|
const raw = await fs.readFile(metaPath, "utf-8");
|
|
meta = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`Backup metadata not found: ${backupId}`);
|
|
}
|
|
|
|
// Verify actual backup file exists
|
|
try {
|
|
await fs.access(backupPath);
|
|
} catch {
|
|
throw new Error(`Backup file not found: ${backupId}`);
|
|
}
|
|
|
|
// Before restoring, back up the current file (so restore is reversible)
|
|
await createBackup(toolId, meta.originalPath);
|
|
|
|
// Copy backup over the original
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete a specific backup by its id.
|
|
*/
|
|
export async function deleteBackup(toolId: string, backupId: string) {
|
|
// Anchor backupId within the tool dir — prevent path traversal via backupId
|
|
const backupPath = safePath(toolId, backupId);
|
|
const metaPath = backupPath + ".meta.json";
|
|
|
|
try {
|
|
await fs.unlink(backupPath);
|
|
} catch {
|
|
// Already gone
|
|
}
|
|
try {
|
|
await fs.unlink(metaPath);
|
|
} catch {
|
|
// Already gone
|
|
}
|
|
|
|
return { deleted: true, backupId };
|
|
}
|
|
|
|
/**
|
|
* Enforce max backups per tool — removes oldest when limit exceeded.
|
|
* Groups by original file basename so each config file gets its own rotation.
|
|
*/
|
|
async function rotateBackups(toolId: string) {
|
|
const all = await listBackups(toolId);
|
|
|
|
// Group by original file basename
|
|
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[]][]) {
|
|
// Already sorted newest first
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|