u
This commit is contained in:
@@ -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 {};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user