This commit is contained in:
seb
2026-10-14 03:07:21 +02:00
parent 1991c0b529
commit 6c821d381e
10 changed files with 607 additions and 124 deletions

View File

@@ -1806,7 +1806,7 @@
* nullable: true
* BackupManifest:
* type: object
* required: [id, timestamp, dateKey, monthKey, dbProvider, manual, files, totalSizeBytes, durationMs]
* required: [id, timestamp, dateKey, monthKey, dbProvider, files, totalSizeBytes, durationMs]
* properties:
* id:
* type: string
@@ -1819,8 +1819,6 @@
* type: string
* dbProvider:
* type: string
* manual:
* type: boolean
* files:
* type: object
* required: [vfs, storage, database]
@@ -1925,33 +1923,57 @@
* $ref: '#/components/schemas/BackupStatus'
* backups:
* $ref: '#/components/schemas/BackupListing'
* BackupRunResult:
* BackupVfsFileItem:
* type: object
* required: [success, id, timestamp, promotedDaily, promotedMonthly, totalSizeBytes, pruneResult, manifest]
* required: [name, size, mime, mtime]
* properties:
* name:
* type: string
* size:
* type: integer
* mime:
* type: string
* mtime:
* type: string
* format: date-time
* BackupVfsListResponse:
* type: object
* required: [backupId, companyId, files]
* properties:
* backupId:
* type: string
* companyId:
* type: integer
* files:
* type: array
* items:
* $ref: '#/components/schemas/BackupVfsFileItem'
* BackupVfsRecoverRequest:
* type: object
* required: [path]
* properties:
* path:
* type: string
* description: Relative path of the VFS file to recover
* companyId:
* type: integer
* description: Optional company ID (defaults to user's selected company)
* BackupVfsRecoverResponse:
* type: object
* required: [success, backupId, companyId, path, sizeBytes, mime]
* properties:
* success:
* type: boolean
* id:
* backupId:
* type: string
* timestamp:
* type: string
* format: date-time
* promotedDaily:
* type: boolean
* promotedMonthly:
* type: boolean
* totalSizeBytes:
* companyId:
* type: integer
* pruneResult:
* type: object
* required: [hourlyPruned, dailyPruned]
* properties:
* hourlyPruned:
* type: integer
* dailyPruned:
* type: integer
* manifest:
* $ref: '#/components/schemas/BackupManifest'
* path:
* type: string
* sizeBytes:
* type: integer
* mime:
* type: string
*/
export {};

View File

@@ -1,7 +1,86 @@
import { listBackups, getBackupServiceStatus, createHourlyBackup } from '../../services/backupService.js';
import {
listBackups,
getBackupServiceStatus,
listBackupVfsFiles,
recoverBackupVfsFile,
} from '../../services/backupService.js';
import { emit } from '../sse.js';
import { listCompanies } from '../../db/company.js';
// #region Helpers
/**
* Registers internal backup service status and execution endpoints.
* Ensures the authenticated request has a selected company.
* @param {import('express').Request} req
* @returns {{ companyId: number } | { error: string }}
*/
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
/**
* Resolves and authorizes the target company ID for backup operations.
* If a companyId is explicitly requested, verifies that the authenticated user has access.
* Otherwise falls back to the user's selected company.
* @param {import('express').Request} req
* @param {number|string|null|undefined} requestedCompanyId
* @returns {Promise<{ companyId: number } | { error: string, status: number }>}
*/
async function resolveAuthorizedCompany(req, requestedCompanyId) {
const userCompanyId = req.user?.selectedCompanyId;
const targetCompanyId = requestedCompanyId != null && requestedCompanyId !== ''
? Number(requestedCompanyId)
: userCompanyId;
if (targetCompanyId == null || !Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
if (targetCompanyId == null) {
return { error: 'No company selected', status: 400 };
}
return { error: 'Invalid companyId', status: 400 };
}
if (userCompanyId === targetCompanyId) {
return { companyId: targetCompanyId };
}
try {
const companies = await listCompanies(req.user.id);
const hasAccess = Array.isArray(companies) && companies.some((c) => c.id === targetCompanyId);
if (!hasAccess) {
return { error: 'Not authorized', status: 403 };
}
return { companyId: targetCompanyId };
} catch {
return { error: 'Not authorized', status: 403 };
}
}
/**
* Maps backup error messages to HTTP status codes.
* @param {Error} error
* @returns {number}
*/
function backupErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Invalid companyId') return 400;
if (error.message === 'File path is required') return 400;
if (error.message === 'Invalid file path') return 400;
if (error.message?.includes('escapes virtual filesystem')) return 400;
if (error.message === 'Backup not found') return 404;
if (error.message === 'VFS archive not found in backup') return 404;
if (error.message === 'File not found in backup') return 404;
return 500;
}
// #endregion
/**
* Registers internal backup service status, listing, and VFS recovery endpoints.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
@@ -42,34 +121,164 @@ export function register(app, basePath = '') {
/**
* @openapi
* /backup/run:
* post:
* /backup/{id}/vfs:
* get:
* tags: [Backup]
* summary: Trigger manual backup
* description: Immediately triggers an on-demand backup snapshot of VFS, storage, and SQLite database.
* summary: List VFS files in a backup
* description: Lists all virtual filesystem (VFS) files stored for the authorized company inside a specific backup archive.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* description: Backup identifier (e.g. backup-2026-10-14_02-00-00, backup-2026-10-14, backup-2026-10)
* schema:
* type: string
* - in: query
* name: companyId
* required: false
* description: Optional company ID to inspect (defaults to user's selected company)
* schema:
* type: integer
* responses:
* 200:
* description: Backup triggered successfully
* description: List of VFS files in the backup for the authorized company
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BackupRunResult'
* $ref: '#/components/schemas/BackupVfsListResponse'
* 400:
* description: No company selected or invalid companyId
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized for the requested company
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Backup or VFS archive not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Backup execution failure
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /backup/run
// MARK: handles POST /backup/run to trigger manual backup
app.post(`${basePath}/backup/run`, async (_req, res) => {
// #region GET /backup/:id/vfs
// MARK: handles GET /backup/:id/vfs to list VFS files from a specific backup
app.get(`${basePath}/backup/:id/vfs`, async (req, res) => {
const authorized = await resolveAuthorizedCompany(req, req.query?.companyId);
if (authorized.error) {
return res.status(authorized.status).json({ error: authorized.error });
}
try {
const result = await createHourlyBackup({ manual: true });
const result = await listBackupVfsFiles({
backupId: req.params.id,
companyId: authorized.companyId,
});
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
const status = backupErrorStatus(error);
if (status === 500) console.error('List backup VFS files failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /backup/{id}/vfs/recover:
* post:
* tags: [Backup]
* summary: Recover VFS file from a backup
* description: Recovers a specific virtual filesystem (VFS) file from a backup archive into the active VFS directory for the authorized company.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* description: Backup identifier (e.g. backup-2026-10-14_02-00-00, backup-2026-10-14, backup-2026-10)
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BackupVfsRecoverRequest'
* responses:
* 200:
* description: VFS file recovered successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BackupVfsRecoverResponse'
* 400:
* description: Validation error or path escapes boundary
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized for the company
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Backup or file not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /backup/:id/vfs/recover
// MARK: handles POST /backup/:id/vfs/recover to restore a VFS file from a backup
const handleRecover = async (req, res) => {
const filePath = req.params.path || req.body?.path || req.body?.file || req.body?.filename || req.query?.path;
if (!filePath) {
return res.status(400).json({ error: 'File path is required' });
}
const requestedCompanyId = req.body?.companyId ?? req.query?.companyId;
const authorized = await resolveAuthorizedCompany(req, requestedCompanyId);
if (authorized.error) {
return res.status(authorized.status).json({ error: authorized.error });
}
try {
const result = await recoverBackupVfsFile({
backupId: req.params.id,
companyId: authorized.companyId,
filePath,
});
emit('vfs', { companyId: authorized.companyId });
res.status(200).json(result);
} catch (error) {
const status = backupErrorStatus(error);
if (status === 500) console.error('Backup VFS recover error:', error);
res.status(status).json({ error: error.message });
}
};
app.post(`${basePath}/backup/:id/vfs/recover`, handleRecover);
app.post(`${basePath}/backup/:id/vfs/recover/*path`, handleRecover);
// #endregion
}

View File

@@ -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