Files
dashback/src/api/routes/backup.js
2026-10-14 03:07:21 +02:00

285 lines
10 KiB
JavaScript

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
}