import { listBackups, getBackupServiceStatus, listBackupVfsFiles, recoverBackupVfsFile, } from '../../services/backupService.js'; import { emit } from '../sse.js'; import { listCompanies } from '../../db/company.js'; // #region Helpers /** * 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 */ export function register(app, basePath = '') { /** * @openapi * /backup: * get: * tags: [Backup] * summary: List backups and service status * description: Retrieves the current status of the internal backup service and lists available hourly, daily, and monthly backups. * responses: * 200: * description: Backup service status and listing * content: * application/json: * schema: * $ref: '#/components/schemas/BackupOverviewResponse' * 500: * description: Internal error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /backup // MARK: handles GET /backup to list backups and service status app.get(`${basePath}/backup`, async (_req, res) => { try { const status = getBackupServiceStatus(); const backups = listBackups(); res.status(200).json({ status, backups }); } catch (error) { res.status(500).json({ error: error.message }); } }); // #endregion /** * @openapi * /backup/{id}/vfs: * get: * tags: [Backup] * 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: List of VFS files in the backup for the authorized company * content: * application/json: * schema: * $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: Internal server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #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 listBackupVfsFiles({ backupId: req.params.id, companyId: authorized.companyId, }); res.status(200).json(result); } catch (error) { 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 }