u
This commit is contained in:
@@ -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/me.md`](routes/auth/me.md) | `GET /auth/me` |
|
||||||
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
|
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
|
||||||
| [`routes/backup.md`](routes/backup.md) | `GET /backup` |
|
| [`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/list.md`](routes/brand/list.md) | `GET /brand` |
|
||||||
| [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` |
|
| [`routes/brand/create.md`](routes/brand/create.md) | `POST /brand` |
|
||||||
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
|
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 232 API endpoints across all modules.
|
Overview of all 233 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### 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 }`
|
- `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 }`
|
- `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 }`
|
- `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 }`
|
- `T10` = `{ labeltemplate: T11 }`
|
||||||
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
|
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
|
||||||
- `T12` = `{ quote: T15 }`
|
- `T12` = `{ quote: T15 }`
|
||||||
@@ -259,9 +259,12 @@ _none_
|
|||||||
#### List backups and service status — `GET /backup`
|
#### List backups and service status — `GET /backup`
|
||||||
_none_
|
_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[] } }`
|
- 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`
|
#### List VFS files in a backup — `GET /backup/{id}/vfs`
|
||||||
_none_
|
- Params: `id: str, query: companyId?: int`
|
||||||
- Returns: `200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: T9 }`
|
- 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
|
### Section: /brand
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
@@ -73,7 +72,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
@@ -107,7 +105,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
@@ -149,7 +146,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
@@ -185,7 +181,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
@@ -221,7 +216,6 @@ Backup service status and listing
|
|||||||
"dateKey": "…",
|
"dateKey": "…",
|
||||||
"monthKey": "…",
|
"monthKey": "…",
|
||||||
"dbProvider": "…",
|
"dbProvider": "…",
|
||||||
"manual": true,
|
|
||||||
"files": {
|
"files": {
|
||||||
"vfs": {
|
"vfs": {
|
||||||
"name": "…",
|
"name": "…",
|
||||||
|
|||||||
51
docs/api/routes/backup/_id/vfs.md
Normal file
51
docs/api/routes/backup/_id/vfs.md
Normal file
@@ -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`._
|
||||||
55
docs/api/routes/backup/_id/vfs/recover.md
Normal file
55
docs/api/routes/backup/_id/vfs/recover.md
Normal file
@@ -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`._
|
||||||
@@ -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`._
|
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
startBackupService,
|
startBackupService,
|
||||||
stopBackupService,
|
stopBackupService,
|
||||||
resolveBackupPaths,
|
resolveBackupPaths,
|
||||||
|
listBackupVfsFiles,
|
||||||
|
recoverBackupVfsFile,
|
||||||
} from '../src/services/backupService.js';
|
} from '../src/services/backupService.js';
|
||||||
import { closeStore } from '../src/db/store.js';
|
import { closeStore } from '../src/db/store.js';
|
||||||
|
|
||||||
@@ -23,8 +25,9 @@ async function runTests() {
|
|||||||
|
|
||||||
// 2. Execute a backup
|
// 2. Execute a backup
|
||||||
console.log('\n[Test 1] Running createHourlyBackup()...');
|
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.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(`Backup created: ${backupResult.id}, size: ${(backupResult.totalSizeBytes / 1024 / 1024).toFixed(2)} MB`);
|
||||||
console.log(`Promoted to daily: ${backupResult.promotedDaily}, Promoted to monthly: ${backupResult.promotedMonthly}`);
|
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');
|
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}`);
|
console.log(`Counts -> hourly: ${listing.counts.hourly}, daily: ${listing.counts.daily}, monthly: ${listing.counts.monthly}`);
|
||||||
|
|
||||||
// 4. Test Retention Pruning (exceeding 50 entries)
|
// 4. Test VFS listing and recovery from specific backup
|
||||||
console.log('\n[Test 3] Testing retention pruning for > 50 entries...');
|
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
|
// Create dummy hourly backup folders
|
||||||
for (let i = 1; i <= 55; i++) {
|
for (let i = 1; i <= 55; i++) {
|
||||||
const dummyName = `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`;
|
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 });
|
if (fs.existsSync(mPath)) fs.rmSync(mPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Test MSSQL warning behavior
|
// 6. Test MSSQL warning behavior
|
||||||
console.log('\n[Test 4] Testing MSSQL console warning behavior...');
|
console.log('\n[Test 5] Testing MSSQL console warning behavior...');
|
||||||
const originalProvider = env.dbProvider;
|
const originalProvider = env.dbProvider;
|
||||||
let warningCaptured = false;
|
let warningCaptured = false;
|
||||||
const origWarn = console.warn;
|
const origWarn = console.warn;
|
||||||
@@ -126,7 +176,7 @@ async function runTests() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
env.dbProvider = 'mssql';
|
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(warningCaptured, 'Console warning must be printed when running on MSSQL');
|
||||||
assert(mssqlBackup.manifest.files.database.backedUp === false, 'Database backup must be skipped on MSSQL');
|
assert(mssqlBackup.manifest.files.database.backedUp === false, 'Database backup must be skipped on MSSQL');
|
||||||
console.log('MSSQL warning and database skip verified.');
|
console.log('MSSQL warning and database skip verified.');
|
||||||
@@ -135,8 +185,8 @@ async function runTests() {
|
|||||||
env.dbProvider = originalProvider;
|
env.dbProvider = originalProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Test Lifecycle methods
|
// 7. Test Lifecycle methods
|
||||||
console.log('\n[Test 5] Testing startBackupService() and stopBackupService()...');
|
console.log('\n[Test 6] Testing startBackupService() and stopBackupService()...');
|
||||||
startBackupService();
|
startBackupService();
|
||||||
assert(getBackupServiceStatus().isRunning === true, 'Service should be running');
|
assert(getBackupServiceStatus().isRunning === true, 'Service should be running');
|
||||||
await stopBackupService();
|
await stopBackupService();
|
||||||
|
|||||||
@@ -1806,7 +1806,7 @@
|
|||||||
* nullable: true
|
* nullable: true
|
||||||
* BackupManifest:
|
* BackupManifest:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [id, timestamp, dateKey, monthKey, dbProvider, manual, files, totalSizeBytes, durationMs]
|
* required: [id, timestamp, dateKey, monthKey, dbProvider, files, totalSizeBytes, durationMs]
|
||||||
* properties:
|
* properties:
|
||||||
* id:
|
* id:
|
||||||
* type: string
|
* type: string
|
||||||
@@ -1819,8 +1819,6 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* dbProvider:
|
* dbProvider:
|
||||||
* type: string
|
* type: string
|
||||||
* manual:
|
|
||||||
* type: boolean
|
|
||||||
* files:
|
* files:
|
||||||
* type: object
|
* type: object
|
||||||
* required: [vfs, storage, database]
|
* required: [vfs, storage, database]
|
||||||
@@ -1925,33 +1923,57 @@
|
|||||||
* $ref: '#/components/schemas/BackupStatus'
|
* $ref: '#/components/schemas/BackupStatus'
|
||||||
* backups:
|
* backups:
|
||||||
* $ref: '#/components/schemas/BackupListing'
|
* $ref: '#/components/schemas/BackupListing'
|
||||||
* BackupRunResult:
|
* BackupVfsFileItem:
|
||||||
* type: object
|
* 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:
|
* properties:
|
||||||
* success:
|
* success:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* id:
|
* backupId:
|
||||||
* type: string
|
* type: string
|
||||||
* timestamp:
|
* companyId:
|
||||||
|
* type: integer
|
||||||
|
* path:
|
||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* sizeBytes:
|
||||||
* promotedDaily:
|
|
||||||
* type: boolean
|
|
||||||
* promotedMonthly:
|
|
||||||
* type: boolean
|
|
||||||
* totalSizeBytes:
|
|
||||||
* type: integer
|
* type: integer
|
||||||
* pruneResult:
|
* mime:
|
||||||
* type: object
|
* type: string
|
||||||
* required: [hourlyPruned, dailyPruned]
|
|
||||||
* properties:
|
|
||||||
* hourlyPruned:
|
|
||||||
* type: integer
|
|
||||||
* dailyPruned:
|
|
||||||
* type: integer
|
|
||||||
* manifest:
|
|
||||||
* $ref: '#/components/schemas/BackupManifest'
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {};
|
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 {import('express').Application} app - Express app
|
||||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||||
*/
|
*/
|
||||||
@@ -42,34 +121,164 @@ export function register(app, basePath = '') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /backup/run:
|
* /backup/{id}/vfs:
|
||||||
* post:
|
* get:
|
||||||
* tags: [Backup]
|
* tags: [Backup]
|
||||||
* summary: Trigger manual backup
|
* summary: List VFS files in a backup
|
||||||
* description: Immediately triggers an on-demand backup snapshot of VFS, storage, and SQLite database.
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Backup triggered successfully
|
* description: List of VFS files in the backup for the authorized company
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* 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:
|
* 500:
|
||||||
* description: Backup execution failure
|
* description: Internal server error
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* $ref: '#/components/schemas/Error'
|
* $ref: '#/components/schemas/Error'
|
||||||
*/
|
*/
|
||||||
// #region POST /backup/run
|
// #region GET /backup/:id/vfs
|
||||||
// MARK: handles POST /backup/run to trigger manual backup
|
// MARK: handles GET /backup/:id/vfs to list VFS files from a specific backup
|
||||||
app.post(`${basePath}/backup/run`, async (_req, res) => {
|
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 {
|
try {
|
||||||
const result = await createHourlyBackup({ manual: true });
|
const result = await listBackupVfsFiles({
|
||||||
|
backupId: req.params.id,
|
||||||
|
companyId: authorized.companyId,
|
||||||
|
});
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
} catch (error) {
|
} 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
|
// #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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
|
|||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
import { getProvider } from '../db/store.js';
|
import { getProvider } from '../db/store.js';
|
||||||
import { createLogger } from '../util/logger.js';
|
import { createLogger } from '../util/logger.js';
|
||||||
|
import { getMimeType } from '../tools/vfs.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const backupLog = createLogger('backup');
|
const backupLog = createLogger('backup');
|
||||||
@@ -218,7 +219,7 @@ export function pruneBackups() {
|
|||||||
|
|
||||||
// #region Core Backup Execution
|
// #region Core Backup Execution
|
||||||
// MARK: executes a full backup and promotes to daily/monthly tiers as appropriate
|
// 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) {
|
if (activeBackupPromise) {
|
||||||
backupLog.warn('A backup is already in progress, waiting for it to finish...');
|
backupLog.warn('A backup is already in progress, waiting for it to finish...');
|
||||||
return activeBackupPromise;
|
return activeBackupPromise;
|
||||||
@@ -255,7 +256,6 @@ export async function createHourlyBackup({ manual = false } = {}) {
|
|||||||
dateKey,
|
dateKey,
|
||||||
monthKey,
|
monthKey,
|
||||||
dbProvider: env.dbProvider,
|
dbProvider: env.dbProvider,
|
||||||
manual,
|
|
||||||
files: {
|
files: {
|
||||||
vfs: {
|
vfs: {
|
||||||
name: 'vfs.tar.gz',
|
name: 'vfs.tar.gz',
|
||||||
@@ -419,6 +419,168 @@ export function getBackupServiceStatus() {
|
|||||||
latestMonthly: listing.monthly[0] || null,
|
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
|
// #endregion
|
||||||
|
|
||||||
// #region Service Lifecycle
|
// #region Service Lifecycle
|
||||||
|
|||||||
Reference in New Issue
Block a user