diff --git a/docs/api/README.md b/docs/api/README.md index 2ee463b..72cf821 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -89,7 +89,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | | [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` | | [`routes/backup.md`](routes/backup.md) | `GET /backup` | -| [`routes/backup/run.md`](routes/backup/run.md) | `POST /backup/run` | +| [`routes/backup/_id/vfs.md`](routes/backup/_id/vfs.md) | `GET /backup/{id}/vfs` | +| [`routes/backup/_id/vfs/recover.md`](routes/backup/_id/vfs/recover.md) | `POST /backup/{id}/vfs/recover` | | [`routes/brand/list.md`](routes/brand/list.md) | `GET /brand` | | [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` | | [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index ca29d9f..33aac28 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 232 API endpoints across all modules. +Overview of all 233 API endpoints across all modules. ### Notation @@ -17,7 +17,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T13[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }` - `T7` = `{ id: int, number: str, type: str, date: str, lines: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }` - `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }` -- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, manual: bool, files: T17, totalSizeBytes: int, durationMs: int }` +- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T17, totalSizeBytes: int, durationMs: int }` - `T10` = `{ labeltemplate: T11 }` - `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }` - `T12` = `{ quote: T15 }` @@ -259,9 +259,12 @@ _none_ #### List backups and service status — `GET /backup` _none_ - Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T8|null, latestDaily?: T8|null, latestMonthly?: T8|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T8[], daily: T8[], monthly: T8[] } }` -#### Trigger manual backup — `POST /backup/run` -_none_ -- Returns: `200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: T9 }` +#### List VFS files in a backup — `GET /backup/{id}/vfs` +- Params: `id: str, query: companyId?: int` +- Returns: `200 { backupId: str, companyId: int, files: { name: str, size: int, mime: str, mtime: str }[] }` +#### Recover VFS file from a backup — `POST /backup/{id}/vfs/recover` +- Params: `id: str, body: { path: str, companyId?: int }` +- Returns: `200 { success: bool, backupId: str, companyId: int, path: str, sizeBytes: int, mime: str }` ### Section: /brand diff --git a/docs/api/routes/backup.md b/docs/api/routes/backup.md index d0d58cc..f3c6491 100644 --- a/docs/api/routes/backup.md +++ b/docs/api/routes/backup.md @@ -39,7 +39,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", @@ -73,7 +72,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", @@ -107,7 +105,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", @@ -149,7 +146,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", @@ -185,7 +181,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", @@ -221,7 +216,6 @@ Backup service status and listing "dateKey": "…", "monthKey": "…", "dbProvider": "…", - "manual": true, "files": { "vfs": { "name": "…", diff --git a/docs/api/routes/backup/_id/vfs.md b/docs/api/routes/backup/_id/vfs.md new file mode 100644 index 0000000..5b9c23d --- /dev/null +++ b/docs/api/routes/backup/_id/vfs.md @@ -0,0 +1,51 @@ +# `GET /backup/{id}/vfs` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List VFS files in a backup + +Lists all virtual filesystem (VFS) files stored for the authorized company inside a specific backup archive. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | string | Backup identifier (e.g. backup-2026-10-14_02-00-00, backup-2026-10-14, backup-2026-10) | + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `companyId` | no | integer | Optional company ID to inspect (defaults to user's selected company) | + +## Response `200` + +List of VFS files in the backup for the authorized company + +```json +{ + "backupId": "…", + "companyId": 1, + "files": [ + { + "name": "…", + "size": 1, + "mime": "…", + "mtime": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid companyId — `{"error":"…"}` | +| 403 | Not authorized for the requested company — `{"error":"…"}` | +| 404 | Backup or VFS archive not found — `{"error":"…"}` | +| 500 | Internal server error — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/backup/_id/vfs/recover.md b/docs/api/routes/backup/_id/vfs/recover.md new file mode 100644 index 0000000..f1b025a --- /dev/null +++ b/docs/api/routes/backup/_id/vfs/recover.md @@ -0,0 +1,55 @@ +# `POST /backup/{id}/vfs/recover` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Recover VFS file from a backup + +Recovers a specific virtual filesystem (VFS) file from a backup archive into the active VFS directory for the authorized company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | string | Backup identifier (e.g. backup-2026-10-14_02-00-00, backup-2026-10-14, backup-2026-10) | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | Relative path of the VFS file to recover | +| `companyId` | no | integer | Optional company ID (defaults to user's selected company) | + +```json +{ + "path": "…", + "companyId": 1 +} +``` + +## Response `200` + +VFS file recovered successfully + +```json +{ + "success": true, + "backupId": "…", + "companyId": 1, + "path": "…", + "sizeBytes": 1, + "mime": "…" +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Validation error or path escapes boundary — `{"error":"…"}` | +| 403 | Not authorized for the company — `{"error":"…"}` | +| 404 | Backup or file not found — `{"error":"…"}` | +| 500 | Internal server error — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/backup/run.md b/docs/api/routes/backup/run.md deleted file mode 100644 index 9dbfb14..0000000 --- a/docs/api/routes/backup/run.md +++ /dev/null @@ -1,64 +0,0 @@ -# `POST /backup/run` - -Protected. Requires a valid session cookie (`credentials: 'include'`). - -Trigger manual backup - -Immediately triggers an on-demand backup snapshot of VFS, storage, and SQLite database. - -## Response `200` - -Backup triggered successfully - -```json -{ - "success": true, - "id": "…", - "timestamp": "2026-08-10T00:00:00.000Z", - "promotedDaily": true, - "promotedMonthly": true, - "totalSizeBytes": 1, - "pruneResult": { - "hourlyPruned": 1, - "dailyPruned": 1 - }, - "manifest": { - "id": "…", - "timestamp": "2026-08-10T00:00:00.000Z", - "dateKey": "…", - "monthKey": "…", - "dbProvider": "…", - "manual": true, - "files": { - "vfs": { - "name": "…", - "sizeBytes": 1, - "sourceExists": true - }, - "storage": { - "name": "…", - "sizeBytes": 1, - "sourceExists": true - }, - "database": { - "name": "…", - "sizeBytes": 1, - "backedUp": true, - "reason": "…" - } - }, - "totalSizeBytes": 1, - "durationMs": 1 - } -} -``` - -## Errors - -| Status | Description | -|--------|-------------| -| 500 | Backup execution failure — `{"error":"…"}` | - ---- - -_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/test-backup-service.js b/scripts/test-backup-service.js index c9b2a4d..1361799 100644 --- a/scripts/test-backup-service.js +++ b/scripts/test-backup-service.js @@ -10,6 +10,8 @@ import { startBackupService, stopBackupService, resolveBackupPaths, + listBackupVfsFiles, + recoverBackupVfsFile, } from '../src/services/backupService.js'; import { closeStore } from '../src/db/store.js'; @@ -23,8 +25,9 @@ async function runTests() { // 2. Execute a backup console.log('\n[Test 1] Running createHourlyBackup()...'); - const backupResult = await createHourlyBackup({ manual: true }); + const backupResult = await createHourlyBackup(); assert(backupResult.success === true, 'Backup should report success'); + assert(backupResult.manifest.manual === undefined, 'manual field should not exist in manifest'); console.log(`Backup created: ${backupResult.id}, size: ${(backupResult.totalSizeBytes / 1024 / 1024).toFixed(2)} MB`); console.log(`Promoted to daily: ${backupResult.promotedDaily}, Promoted to monthly: ${backupResult.promotedMonthly}`); @@ -64,8 +67,55 @@ async function runTests() { assert(listing.monthly.length >= 1, 'Should have at least 1 monthly backup'); console.log(`Counts -> hourly: ${listing.counts.hourly}, daily: ${listing.counts.daily}, monthly: ${listing.counts.monthly}`); - // 4. Test Retention Pruning (exceeding 50 entries) - console.log('\n[Test 3] Testing retention pruning for > 50 entries...'); + // 4. Test VFS listing and recovery from specific backup + console.log('\n[Test 3] Testing listBackupVfsFiles() and recoverBackupVfsFile()...'); + const vfsListing = await listBackupVfsFiles({ backupId: backupResult.id, companyId: 1 }); + assert(vfsListing.backupId === backupResult.id, 'backupId should match'); + assert(vfsListing.companyId === 1, 'companyId should be 1'); + assert(Array.isArray(vfsListing.files), 'files should be an array'); + console.log(`Found ${vfsListing.files.length} VFS files in backup for company 1.`); + + if (vfsListing.files.length > 0) { + const fileToRecover = vfsListing.files[0]; + console.log(`Attempting recovery of ${fileToRecover.name}...`); + const recoverRes = await recoverBackupVfsFile({ + backupId: backupResult.id, + companyId: 1, + filePath: fileToRecover.name, + }); + assert(recoverRes.success === true, 'Recovery should succeed'); + assert(recoverRes.path === fileToRecover.name, 'Recovered path should match'); + assert(recoverRes.sizeBytes > 0, 'Recovered size should be > 0'); + + const recoveredDiskPath = path.resolve(paths.vfsDir, '1', fileToRecover.name); + assert(fs.existsSync(recoveredDiskPath), 'Recovered file should exist on disk'); + console.log(`Successfully recovered ${fileToRecover.name} (${recoverRes.sizeBytes} bytes).`); + } + + // Test error cases + let backupNotFound = false; + try { + await listBackupVfsFiles({ backupId: 'backup-nonexistent', companyId: 1 }); + } catch (e) { + if (e.message === 'Backup not found') backupNotFound = true; + } + assert(backupNotFound, 'Should throw Backup not found for invalid backupId'); + + let fileNotFound = false; + try { + await recoverBackupVfsFile({ + backupId: backupResult.id, + companyId: 1, + filePath: 'completely-nonexistent-file.xyz', + }); + } catch (e) { + if (e.message === 'File not found in backup') fileNotFound = true; + } + assert(fileNotFound, 'Should throw File not found in backup for invalid file'); + console.log('VFS listing and recovery tests passed.'); + + // 5. Test Retention Pruning (exceeding 50 entries) + console.log('\n[Test 4] Testing retention pruning for > 50 entries...'); // Create dummy hourly backup folders for (let i = 1; i <= 55; i++) { const dummyName = `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`; @@ -111,8 +161,8 @@ async function runTests() { if (fs.existsSync(mPath)) fs.rmSync(mPath, { recursive: true, force: true }); } - // 5. Test MSSQL warning behavior - console.log('\n[Test 4] Testing MSSQL console warning behavior...'); + // 6. Test MSSQL warning behavior + console.log('\n[Test 5] Testing MSSQL console warning behavior...'); const originalProvider = env.dbProvider; let warningCaptured = false; const origWarn = console.warn; @@ -126,7 +176,7 @@ async function runTests() { try { env.dbProvider = 'mssql'; - const mssqlBackup = await createHourlyBackup({ manual: true }); + const mssqlBackup = await createHourlyBackup(); assert(warningCaptured, 'Console warning must be printed when running on MSSQL'); assert(mssqlBackup.manifest.files.database.backedUp === false, 'Database backup must be skipped on MSSQL'); console.log('MSSQL warning and database skip verified.'); @@ -135,8 +185,8 @@ async function runTests() { env.dbProvider = originalProvider; } - // 6. Test Lifecycle methods - console.log('\n[Test 5] Testing startBackupService() and stopBackupService()...'); + // 7. Test Lifecycle methods + console.log('\n[Test 6] Testing startBackupService() and stopBackupService()...'); startBackupService(); assert(getBackupServiceStatus().isRunning === true, 'Service should be running'); await stopBackupService(); diff --git a/src/api/openapi.js b/src/api/openapi.js index 917fa33..a0c747c 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -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 {}; diff --git a/src/api/routes/backup.js b/src/api/routes/backup.js index 3f5dbbb..c3a8eed 100644 --- a/src/api/routes/backup.js +++ b/src/api/routes/backup.js @@ -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 } diff --git a/src/services/backupService.js b/src/services/backupService.js index b8f8850..6c1eba8 100644 --- a/src/services/backupService.js +++ b/src/services/backupService.js @@ -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