u
This commit is contained in:
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
|
||||
import env from '../config/env.js';
|
||||
import { getProvider } from '../db/store.js';
|
||||
import { createLogger } from '../util/logger.js';
|
||||
import { getMimeType } from '../tools/vfs.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const backupLog = createLogger('backup');
|
||||
@@ -218,7 +219,7 @@ export function pruneBackups() {
|
||||
|
||||
// #region Core Backup Execution
|
||||
// MARK: executes a full backup and promotes to daily/monthly tiers as appropriate
|
||||
export async function createHourlyBackup({ manual = false } = {}) {
|
||||
export async function createHourlyBackup() {
|
||||
if (activeBackupPromise) {
|
||||
backupLog.warn('A backup is already in progress, waiting for it to finish...');
|
||||
return activeBackupPromise;
|
||||
@@ -255,7 +256,6 @@ export async function createHourlyBackup({ manual = false } = {}) {
|
||||
dateKey,
|
||||
monthKey,
|
||||
dbProvider: env.dbProvider,
|
||||
manual,
|
||||
files: {
|
||||
vfs: {
|
||||
name: 'vfs.tar.gz',
|
||||
@@ -419,6 +419,168 @@ export function getBackupServiceStatus() {
|
||||
latestMonthly: listing.monthly[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// MARK: resolves an existing backup directory across hourly, daily, or monthly tiers
|
||||
export function resolveBackupDir(backupId) {
|
||||
if (!backupId || typeof backupId !== 'string') return null;
|
||||
const cleanId = path.basename(backupId.trim());
|
||||
if (!cleanId || cleanId !== backupId.trim() || cleanId === '.' || cleanId === '..') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paths = resolveBackupPaths();
|
||||
for (const dir of [paths.hourlyDir, paths.dailyDir, paths.monthlyDir]) {
|
||||
const exact = path.join(dir, cleanId);
|
||||
if (fs.existsSync(exact) && fs.statSync(exact).isDirectory()) {
|
||||
return exact;
|
||||
}
|
||||
if (!cleanId.startsWith('backup-')) {
|
||||
const prefixed = path.join(dir, `backup-${cleanId}`);
|
||||
if (fs.existsSync(prefixed) && fs.statSync(prefixed).isDirectory()) {
|
||||
return prefixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// MARK: lists VFS files belonging to a specific company in a backup archive
|
||||
export async function listBackupVfsFiles({ backupId, companyId }) {
|
||||
const backupDir = resolveBackupDir(backupId);
|
||||
if (!backupDir) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
const vfsTarPath = path.join(backupDir, 'vfs.tar.gz');
|
||||
if (!fs.existsSync(vfsTarPath)) {
|
||||
throw new Error('VFS archive not found in backup');
|
||||
}
|
||||
|
||||
const targetCompanyId = Number(companyId);
|
||||
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
|
||||
throw new Error('Invalid companyId');
|
||||
}
|
||||
|
||||
const prefix = `vfs/${targetCompanyId}/`;
|
||||
let stdout;
|
||||
try {
|
||||
const res = await execFileAsync('tar', ['-tvzf', vfsTarPath, '--full-time', '--utc', prefix]);
|
||||
stdout = res.stdout;
|
||||
} catch (err) {
|
||||
if (err.code === 2 && /Not found in archive/i.test(err.stderr || '')) {
|
||||
return {
|
||||
backupId: path.basename(backupDir),
|
||||
companyId: targetCompanyId,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('d')) continue;
|
||||
const match = trimmed.match(/^([^\s]+)\s+([^\s]+)\s+(\d+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.+)$/);
|
||||
if (!match) continue;
|
||||
const [, _perms, _owner, sizeStr, dateStr, timeStr, entryPath] = match;
|
||||
if (!entryPath.startsWith(prefix)) continue;
|
||||
const relName = entryPath.slice(prefix.length);
|
||||
if (!relName) continue;
|
||||
files.push({
|
||||
name: relName,
|
||||
size: parseInt(sizeStr, 10),
|
||||
mime: getMimeType(relName),
|
||||
mtime: `${dateStr}T${timeStr}.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
backupId: path.basename(backupDir),
|
||||
companyId: targetCompanyId,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
// MARK: restores a single VFS file from a backup archive to the active company VFS directory
|
||||
export async function recoverBackupVfsFile({ backupId, companyId, filePath }) {
|
||||
const backupDir = resolveBackupDir(backupId);
|
||||
if (!backupDir) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
const vfsTarPath = path.join(backupDir, 'vfs.tar.gz');
|
||||
if (!fs.existsSync(vfsTarPath)) {
|
||||
throw new Error('VFS archive not found in backup');
|
||||
}
|
||||
|
||||
const targetCompanyId = Number(companyId);
|
||||
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
|
||||
throw new Error('Invalid companyId');
|
||||
}
|
||||
|
||||
if (!filePath || typeof filePath !== 'string' || !filePath.trim()) {
|
||||
throw new Error('File path is required');
|
||||
}
|
||||
|
||||
let cleanPath = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^api\/vfs\//i, '').replace(/^vfs\//i, '');
|
||||
cleanPath = cleanPath.replace(/^\/+/, '');
|
||||
try {
|
||||
cleanPath = decodeURIComponent(cleanPath);
|
||||
} catch {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
if (cleanPath.includes('\0') || cleanPath.includes('\\')) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
const segments = cleanPath.split('/').filter((s) => s && s !== '.');
|
||||
if (segments.some((s) => s === '..')) {
|
||||
throw new Error('Path escapes virtual filesystem boundary');
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
throw new Error('File path is required');
|
||||
}
|
||||
const normalizedRelPath = segments.join('/');
|
||||
const entryPath = `vfs/${targetCompanyId}/${normalizedRelPath}`;
|
||||
|
||||
let buffer;
|
||||
try {
|
||||
const res = await execFileAsync('tar', ['-xzf', vfsTarPath, '-O', entryPath], {
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 100 * 1024 * 1024,
|
||||
});
|
||||
buffer = res.stdout;
|
||||
} catch (err) {
|
||||
if (err.code === 2 && /Not found in archive/i.test(err.stderr || '')) {
|
||||
throw new Error('File not found in backup');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const paths = resolveBackupPaths();
|
||||
const companyVfsDir = path.resolve(paths.vfsDir, String(targetCompanyId));
|
||||
const targetDiskPath = path.resolve(companyVfsDir, normalizedRelPath);
|
||||
|
||||
if (!targetDiskPath.startsWith(companyVfsDir + path.sep)) {
|
||||
throw new Error('Path escapes virtual filesystem boundary');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetDiskPath), { recursive: true });
|
||||
fs.writeFileSync(targetDiskPath, buffer);
|
||||
|
||||
backupLog.info(
|
||||
`Recovered VFS file "${normalizedRelPath}" for company ${targetCompanyId} from backup ${path.basename(backupDir)} (${buffer.length} bytes)`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backupId: path.basename(backupDir),
|
||||
companyId: targetCompanyId,
|
||||
path: normalizedRelPath,
|
||||
sizeBytes: buffer.length,
|
||||
mime: getMimeType(normalizedRelPath),
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Service Lifecycle
|
||||
|
||||
Reference in New Issue
Block a user