b36dd0ebfe
The restore flow accepted an unvalidated database.backup_file from the manifest (absolute paths and traversal both worked, and no containment check enforced the configured backup root), then interpolated it unescaped into a `sqlite3 .restore '<path>'` command, letting an attacker-chosen source file replace the live database.
1918 lines
76 KiB
JavaScript
1918 lines
76 KiB
JavaScript
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const zlib = require('zlib');
|
|
const { pipeline } = require('stream/promises');
|
|
const { Transform } = require('stream');
|
|
const { createReadStream, createWriteStream } = require('fs');
|
|
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
|
|
const { db } = require('../database/db');
|
|
const knexConfig = require('../../knexfile');
|
|
const logger = require('../utils/logger');
|
|
const backupManifest = require('./backupManifest');
|
|
const S3StorageAdapter = require('./storage/s3Storage');
|
|
const { queueEmail } = require('./emailProcessor');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
|
|
// A manifest is attacker-influenceable (hand-crafted backup). Reject any
|
|
// entry path that would resolve OUTSIDE its intended base directory
|
|
// (traversal / absolute path) before any fs write. The target may not exist
|
|
// yet, so resolve rather than realpath (GHSA-fm58).
|
|
function pathEscapes(baseDir, candidate) {
|
|
const rel = path.relative(path.resolve(baseDir), path.resolve(candidate));
|
|
return !rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel);
|
|
}
|
|
|
|
// GHSA-xfvx: `manifest.database.backup_file` is just as attacker-influenceable
|
|
// as the file-manifest entries `pathEscapes` guards above (hand-crafted or
|
|
// tampered backup manifest) — an absolute path or a `..`-laden relative one
|
|
// must not be allowed to point the SQLite/PG restore at an arbitrary file on
|
|
// disk. Resolve the SAME operator-configured backup roots that
|
|
// `adminRestore.js`'s `checkRestorePathsAllowed` (GHSA-fw4c) enforces for the
|
|
// top-level `source`/`manifestPath` request fields, plus the already-trusted
|
|
// `backupPath` this restore run resolved to (always included, so this never
|
|
// fails open even when no backup_destination_path/backup_manifest_path is
|
|
// configured yet).
|
|
async function getConfiguredBackupRoots(trustedRoot) {
|
|
const roots = [];
|
|
if (trustedRoot) roots.push(trustedRoot);
|
|
try {
|
|
const rows = await db('app_settings')
|
|
.whereIn('setting_key', ['backup_destination_path', 'backup_manifest_path'])
|
|
.select('setting_value');
|
|
for (const row of rows) {
|
|
let value;
|
|
try { value = JSON.parse(row.setting_value); } catch (_) { value = row.setting_value; }
|
|
if (value) roots.push(value);
|
|
}
|
|
} catch (_) {
|
|
// best effort — fall through to whatever roots we already have
|
|
}
|
|
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
|
|
if (extra.trim()) roots.push(extra.trim());
|
|
}
|
|
return roots.map((r) => path.resolve(r));
|
|
}
|
|
|
|
function isContainedInRoots(candidate, resolvedRoots) {
|
|
const resolved = path.resolve(candidate);
|
|
return resolvedRoots.some(
|
|
(root) => resolved === root || resolved.startsWith(root + path.sep)
|
|
);
|
|
}
|
|
|
|
// sqlite3's `.restore`/`.backup` are dot-commands parsed by sqlite3's OWN
|
|
// tokenizer, not the shell — spawn()'s argv separation (shell: false) does
|
|
// NOT protect against a single quote embedded in the path breaking out of
|
|
// the `.restore '<path>'` argument, since the whole `.restore '<path>'`
|
|
// string is one argv element that sqlite3 re-parses itself. sqlite3 offers
|
|
// no parameterized dot-command form, so constrain the path to a
|
|
// conservative safe charset before it is ever interpolated (GHSA-xfvx).
|
|
const SAFE_SQLITE_PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
function assertSafeSqlitePath(p) {
|
|
if (typeof p !== 'string' || !SAFE_SQLITE_PATH_RE.test(p)) {
|
|
throw new Error(`Refusing to run sqlite3 against an unsafe path: ${p}`);
|
|
}
|
|
}
|
|
|
|
// GHSA-xfvx: the layered candidate resolution for `manifest.database.backup_file`
|
|
// (see performDatabaseRestore), factored out so the containment rule can be
|
|
// pinned directly in tests without exercising the surrounding DB-swap/spawn
|
|
// side effects. `warn` is an optional `(msg, meta) => void` logger hook.
|
|
async function resolveContainedDbBackupCandidates(backupPath, dbBackupFile, warn) {
|
|
const allowedRoots = await getConfiguredBackupRoots(backupPath);
|
|
const rawCandidates = [
|
|
// (1) Honour absolute paths recorded by the dumper.
|
|
path.isAbsolute(dbBackupFile) ? dbBackupFile : null,
|
|
// (2) Relative-to-backupPath as-stored (no basename munging).
|
|
path.join(backupPath, dbBackupFile),
|
|
// (3) Legacy reconstruct. Inherently safe: path.basename() strips any
|
|
// directory component, so this candidate can never escape backupPath.
|
|
path.join(backupPath, 'database', path.basename(dbBackupFile)),
|
|
].filter(Boolean);
|
|
|
|
return rawCandidates.filter((candidate) => {
|
|
const contained = isContainedInRoots(candidate, allowedRoots);
|
|
if (!contained && warn) {
|
|
warn('Refusing database backup candidate outside configured backup roots', {
|
|
candidate, dbBackupFile,
|
|
});
|
|
}
|
|
return contained;
|
|
});
|
|
}
|
|
const { formatBytes } = require('../utils/formatBytes');
|
|
const os = require('os');
|
|
|
|
/**
|
|
* Restore Service with Extreme Safety Measures
|
|
*
|
|
* This service handles restoration of backups with multiple safety checks,
|
|
* validation, and rollback capabilities. It prioritizes data safety over speed.
|
|
*
|
|
* Features:
|
|
* - Pre-restore validation and integrity checks
|
|
* - Automatic pre-restore backup creation
|
|
* - Atomic operations where possible
|
|
* - Comprehensive rollback capability
|
|
* - Detailed logging of every action
|
|
* - Dry-run mode for testing
|
|
* - Multiple restore options (full, database-only, files-only, selective)
|
|
* - S3 support with resume capability
|
|
* - Post-restore verification
|
|
*
|
|
* @class RestoreService
|
|
*/
|
|
class RestoreService {
|
|
constructor() {
|
|
this.isRunning = false;
|
|
this.currentProgress = null;
|
|
this.restoreLog = [];
|
|
this.preRestoreBackupPath = null;
|
|
// Snapshot of operator-meta settings (e.g. `restore_allow_force`)
|
|
// captured by performDatabaseRestore BEFORE the DROP DATABASE.
|
|
// Drained by restore() AFTER post-restore verification passes so
|
|
// the replay doesn't inflate the row-count check. Reset per run
|
|
// via beforeRestore() to keep state from leaking across calls.
|
|
this.preservedMetaSnapshot = [];
|
|
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
|
|
this.tempDir = path.join(os.tmpdir(), 'picpeak-restore');
|
|
}
|
|
|
|
/**
|
|
* Main restore method with comprehensive safety checks
|
|
*
|
|
* @param {Object} options - Restore options
|
|
* @param {string} options.source - Backup source (file path or S3 URL)
|
|
* @param {string} options.manifestPath - Path to backup manifest
|
|
* @param {string} options.restoreType - 'full', 'database', 'files', or 'selective'
|
|
* @param {Array} options.selectedItems - For selective restore, array of items to restore
|
|
* @param {boolean} options.dryRun - If true, performs validation only
|
|
* @param {boolean} options.skipPreBackup - Skip automatic pre-restore backup (dangerous!)
|
|
* @param {boolean} options.force - Force restore even with warnings (dangerous!)
|
|
* @param {Object} options.s3Config - S3 configuration for S3-based backups
|
|
* @returns {Promise<Object>} - Restore result
|
|
*/
|
|
async restore(options) {
|
|
if (this.isRunning) {
|
|
throw new Error('Restore operation already in progress');
|
|
}
|
|
|
|
this.isRunning = true;
|
|
this.restoreLog = [];
|
|
this.preservedMetaSnapshot = []; // reset per run
|
|
const startTime = new Date();
|
|
let restoreRun = null;
|
|
|
|
try {
|
|
// Validate options
|
|
this.validateRestoreOptions(options);
|
|
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
|
|
|
|
// Create restore run record
|
|
const result = await db('restore_runs').insert({
|
|
started_at: startTime,
|
|
status: 'running',
|
|
restore_type: options.restoreType,
|
|
source: options.source,
|
|
manifest_path: options.manifestPath,
|
|
is_dry_run: options.dryRun || false
|
|
}).returning('id');
|
|
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
|
|
|
|
restoreRun = { id: runId };
|
|
|
|
// Step 1: Load and validate manifest
|
|
this.updateProgress('Loading and validating manifest...');
|
|
const manifest = await this.loadAndValidateManifest(options.manifestPath, options.s3Config);
|
|
this.log('info', 'Manifest loaded and validated', {
|
|
backupId: manifest.backup.id,
|
|
backupType: manifest.backup.type,
|
|
backupTime: manifest.backup.timestamp
|
|
});
|
|
|
|
// Step 2: Pre-restore validation
|
|
this.updateProgress('Performing pre-restore validation...');
|
|
const validation = await this.performPreRestoreValidation(manifest, options);
|
|
|
|
if (!validation.isValid && !options.force) {
|
|
throw new Error(`Pre-restore validation failed: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
if (validation.warnings.length > 0) {
|
|
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
|
|
// Only block actual restores (not dry runs/validations) on warnings
|
|
if (!options.force && !options.dryRun) {
|
|
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Step 3: Check disk space
|
|
this.updateProgress('Checking available disk space...');
|
|
const spaceCheck = await this.checkDiskSpace(manifest, options);
|
|
if (!spaceCheck.hasEnoughSpace) {
|
|
throw new Error(`Insufficient disk space. Required: ${spaceCheck.requiredFormatted}, Available: ${spaceCheck.availableFormatted}`);
|
|
}
|
|
|
|
// Dry run mode - stop here after validation
|
|
if (options.dryRun) {
|
|
this.log('info', 'Dry run completed successfully');
|
|
|
|
await db('restore_runs').where('id', runId).update({
|
|
completed_at: new Date(),
|
|
status: 'completed',
|
|
statistics: JSON.stringify({
|
|
dryRun: true,
|
|
validation,
|
|
spaceCheck,
|
|
manifest: {
|
|
backupId: manifest.backup.id,
|
|
fileCount: manifest.files.count,
|
|
totalSize: manifest.files.total_size
|
|
}
|
|
})
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
dryRun: true,
|
|
validation,
|
|
spaceCheck,
|
|
logs: this.restoreLog
|
|
};
|
|
}
|
|
|
|
// Step 4: Create pre-restore backup (unless explicitly skipped)
|
|
if (!options.skipPreBackup) {
|
|
this.updateProgress('Creating pre-restore safety backup...');
|
|
this.preRestoreBackupPath = await this.createPreRestoreBackup(options);
|
|
this.log('info', 'Pre-restore backup created', { path: this.preRestoreBackupPath });
|
|
} else {
|
|
this.log('warn', 'Pre-restore backup skipped at user request');
|
|
}
|
|
|
|
// Step 5: Download backup if from S3, or resolve the local root.
|
|
//
|
|
// The wizard passes `options.source = 'local'` (the SOURCE TYPE
|
|
// string) — not a path. The old code assigned that string to
|
|
// `localBackupPath` verbatim and every downstream `path.join(...)`
|
|
// ended up with junk like `local/database/<file>.sql.gz`. Caused
|
|
// the disaster-recovery restore flow to fail with
|
|
// `Database backup file not found: local/database/...` even when
|
|
// the manifest recorded the correct absolute path AND the file
|
|
// existed at exactly that path on disk.
|
|
//
|
|
// Resolve `'local'` to the configured backup destination root by
|
|
// reading `backup_destination_path` from app_settings. That's the
|
|
// same root the file-backup walker writes to, so every relative
|
|
// `file.path` in the manifest resolves correctly via
|
|
// `path.join(localBackupPath, file.path)` further down.
|
|
let localBackupPath = options.source;
|
|
if (options.source === 'local') {
|
|
try {
|
|
const row = await db('app_settings')
|
|
.where('setting_key', 'backup_destination_path')
|
|
.first();
|
|
if (row?.setting_value) {
|
|
let parsed;
|
|
try { parsed = JSON.parse(row.setting_value); } catch (_) { parsed = row.setting_value; }
|
|
if (parsed) localBackupPath = parsed;
|
|
}
|
|
} catch (err) {
|
|
this.log('warn', `Could not resolve backup_destination_path: ${err.message}`);
|
|
}
|
|
} else if (options.source.startsWith('s3://')) {
|
|
this.updateProgress('Downloading backup from S3...');
|
|
localBackupPath = await this.downloadFromS3(options.source, manifest, options);
|
|
}
|
|
|
|
// Step 6: Perform the actual restore based on type
|
|
let restoreResult;
|
|
// Set by the full/database branches; acted on after step 7c so the
|
|
// schema — and any data conversion those migrations perform — is in
|
|
// place before the live face worker can claim a row.
|
|
let needsFaceRequeue = false;
|
|
let migrationsApplied = true;
|
|
switch (options.restoreType) {
|
|
case 'full':
|
|
restoreResult = await this.performFullRestore(localBackupPath, manifest, options);
|
|
// Deferred to after step 7c — see the requeue there.
|
|
needsFaceRequeue = true;
|
|
break;
|
|
case 'database':
|
|
restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options);
|
|
needsFaceRequeue = true;
|
|
break;
|
|
case 'files':
|
|
restoreResult = await this.performFilesRestore(localBackupPath, manifest, options);
|
|
break;
|
|
case 'selective':
|
|
restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown restore type: ${options.restoreType}`);
|
|
}
|
|
|
|
// Step 7: Post-restore verification
|
|
this.updateProgress('Performing post-restore verification...');
|
|
const verification = await this.performPostRestoreVerification(manifest, options);
|
|
|
|
if (!verification.isValid) {
|
|
this.log('error', 'Post-restore verification failed', { errors: verification.errors });
|
|
// Attempt rollback
|
|
if (this.preRestoreBackupPath) {
|
|
await this.attemptRollback(this.preRestoreBackupPath);
|
|
}
|
|
throw new Error(`Post-restore verification failed: ${verification.errors.join(', ')}`);
|
|
}
|
|
|
|
// Step 7b: Replay operator-meta settings AFTER verification.
|
|
//
|
|
// `performDatabaseRestore` stashed the pre-DROP snapshot of
|
|
// operator-meta keys on `this.preservedMetaSnapshot`. We drain
|
|
// it here, AFTER verification has already confirmed the
|
|
// restored DB matches the backup's row counts. Running this
|
|
// upsert sequence here instead of inside performDatabaseRestore
|
|
// (where it used to live) prevents the replay from inflating
|
|
// the post-restore row count and tripping the verification
|
|
// check — see the round-3 PR #596 notes for the full story.
|
|
//
|
|
// UPSERT by setting_key: if the backup had the same key with a
|
|
// different value, we overwrite; if the row doesn't exist in
|
|
// the backup, we insert. Either way the operator's pre-restore
|
|
// policy survives. SQLite branch leaves the snapshot empty so
|
|
// this block is a no-op there.
|
|
if (this.preservedMetaSnapshot && this.preservedMetaSnapshot.length > 0) {
|
|
try {
|
|
for (const row of this.preservedMetaSnapshot) {
|
|
await db('app_settings')
|
|
.insert({
|
|
setting_key: row.setting_key,
|
|
setting_value: row.setting_value,
|
|
setting_type: row.setting_type || 'restore',
|
|
updated_at: new Date(),
|
|
})
|
|
.onConflict('setting_key')
|
|
.merge({
|
|
setting_value: row.setting_value,
|
|
updated_at: new Date(),
|
|
});
|
|
}
|
|
this.log('info', `Replayed ${this.preservedMetaSnapshot.length} restore-meta setting(s) post-verification`);
|
|
} catch (err) {
|
|
this.log('warn', `Could not replay restore-meta settings (admin may need to re-set them): ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Step 7c: Apply any post-backup migrations to the restored DB.
|
|
//
|
|
// The backup carries the schema state of whatever migrations had
|
|
// been applied at backup time. If the running image is NEWER —
|
|
// because the admin upgraded picpeak between when the backup
|
|
// was taken and when they restored — the restored DB ends up
|
|
// mismatched against the running code: queries fail, new
|
|
// columns are missing, new tables don't exist.
|
|
//
|
|
// Previously the comment said "deferred to next container
|
|
// restart" — but that left the running process serving a
|
|
// mismatched schema until the operator manually restarted.
|
|
// Not acceptable per the "backup must restore completely even
|
|
// when new features have been added in the meantime" contract.
|
|
//
|
|
// Implementation: shell out to `npm run migrate:safe`, which is
|
|
// the EXACT script wait-for-db.sh runs on boot. Running it as a
|
|
// subprocess means no risk to our reinit'd pool (subprocess gets
|
|
// its own knex instance, destroys it on exit; our parent pool
|
|
// is untouched). Idempotent — migrations already applied are
|
|
// tracked in the restored `migrations` table and get skipped.
|
|
//
|
|
// Failure is non-fatal: the restore data itself is in place,
|
|
// and the next container restart's wait-for-db.sh will retry.
|
|
// Surfacing the error gives the operator a chance to investigate
|
|
// proactively rather than discovering it on the next 500 from
|
|
// a missing column.
|
|
try {
|
|
this.log('info', 'Applying post-restore migrations to restored database...');
|
|
this.updateProgress('Applying any post-backup migrations...');
|
|
const backendRoot = path.join(__dirname, '..', '..');
|
|
// Invoked via node directly — the runtime image ships no npm
|
|
// (see Dockerfile), and an ENOENT here would be swallowed by the
|
|
// non-fatal catch below, silently skipping post-restore migrations.
|
|
const { stderr } = await spawnAsync('node', ['migrations/run-migrations-safe.js'], {
|
|
cwd: backendRoot,
|
|
env: { ...process.env },
|
|
});
|
|
if (stderr && stderr.trim()) {
|
|
this.log('info', `Post-restore migrate:safe stderr: ${stderr.slice(0, 500)}`);
|
|
}
|
|
this.log('info', 'Post-restore migrations applied');
|
|
} catch (migErr) {
|
|
// Also gates the face requeue below: a pre-#1163 backup whose
|
|
// migration 187 did not run still holds event-relative external
|
|
// paths, and queueing those hands the live worker rows it will
|
|
// resolve from the media root and mark 'failed' — a state the later
|
|
// retry does not clear.
|
|
migrationsApplied = false;
|
|
this.log('warn',
|
|
'Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ' +
|
|
`A container restart will retry via wait-for-db.sh. Error: ${migErr.message}`);
|
|
}
|
|
|
|
// Faces last (#1163). This used to run in step 6, before the migrations
|
|
// above. On a backup predating migration 187 that meant queueing rows
|
|
// whose external_relpath was still relative to events.external_path
|
|
// while the running code resolves from the media root — so the live
|
|
// worker resolved them against the wrong path and marked them 'failed',
|
|
// a state the later fold does not clear and only an explicit Re-scan
|
|
// does. The files are already in place by step 6, so deferring costs
|
|
// nothing and closes that window.
|
|
if (needsFaceRequeue && migrationsApplied) {
|
|
await this.requeueFaceScans();
|
|
} else if (needsFaceRequeue) {
|
|
this.log('warn', 'Skipping face requeue — post-restore migrations did not complete, so photo paths may be unconverted');
|
|
}
|
|
|
|
// Step 8: Clean up temporary files
|
|
if (localBackupPath !== options.source) {
|
|
await fs.unlink(localBackupPath).catch(err =>
|
|
this.log('warn', 'Failed to clean up temporary backup file', { error: err.message })
|
|
);
|
|
}
|
|
|
|
// Calculate duration
|
|
const endTime = new Date();
|
|
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
|
|
|
// Update restore run record
|
|
await db('restore_runs').where('id', runId).update({
|
|
completed_at: endTime,
|
|
status: 'completed',
|
|
// Default for the column is `false`. Without this line, every
|
|
// SUCCESSFUL restore ends up with `status='completed',
|
|
// was_successful=false` — which the BackupDashboard "last
|
|
// successful restore" widget then filters out, and any future
|
|
// audit query that gates on was_successful misses the row
|
|
// entirely. Cosmetic but enough to mislead an operator
|
|
// scanning restore history. Catches Ralf 2026-06-01 + maintainer
|
|
// PR #596 review note about the cosmetic.
|
|
was_successful: true,
|
|
duration_seconds: durationSeconds,
|
|
pre_restore_backup_path: this.preRestoreBackupPath,
|
|
statistics: JSON.stringify({
|
|
...restoreResult,
|
|
verification,
|
|
durationSeconds
|
|
})
|
|
});
|
|
|
|
// Send success notification
|
|
await this.sendRestoreNotification('success', {
|
|
restoreType: options.restoreType,
|
|
duration: durationSeconds,
|
|
filesRestored: restoreResult.filesRestored || 0,
|
|
backupId: manifest.backup.id
|
|
});
|
|
|
|
this.log('info', 'Restore completed successfully', {
|
|
duration: `${durationSeconds}s`,
|
|
result: restoreResult
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
duration: durationSeconds,
|
|
result: restoreResult,
|
|
verification,
|
|
preRestoreBackup: this.preRestoreBackupPath,
|
|
logs: this.restoreLog
|
|
};
|
|
|
|
} catch (error) {
|
|
this.log('error', 'Restore failed', { error: error.message, stack: error.stack });
|
|
|
|
// Always attempt rollback when a pre-restore backup exists.
|
|
// Historically rollback was only triggered when post-restore
|
|
// verification failed (inside the try block) — anything that
|
|
// threw earlier (path-resolution bugs, pg_restore failure, file
|
|
// copy errors) left the destination half-clobbered and forced
|
|
// the admin to do another reset-from-volume cycle before the
|
|
// next attempt could be honest. Fixing the rollback here closes
|
|
// the "every failed restore makes the next one worse" footgun.
|
|
let rollbackAttempted = false;
|
|
let rollbackSucceeded = false;
|
|
let rollbackError = null;
|
|
if (this.preRestoreBackupPath) {
|
|
rollbackAttempted = true;
|
|
try {
|
|
this.log('info', 'Attempting rollback from pre-restore safety backup', {
|
|
path: this.preRestoreBackupPath,
|
|
});
|
|
await this.attemptRollback(this.preRestoreBackupPath);
|
|
rollbackSucceeded = true;
|
|
this.log('info', 'Rollback completed');
|
|
} catch (rbErr) {
|
|
rollbackError = rbErr.message;
|
|
this.log('error', 'Rollback FAILED — install may be in a partial state',
|
|
{ error: rbErr.message, stack: rbErr.stack });
|
|
}
|
|
} else {
|
|
this.log('warn', 'No pre-restore backup available — cannot auto-rollback. ' +
|
|
'Destination may be in a partial state. Verify business-docs/ and the DB before retrying.');
|
|
}
|
|
|
|
// Update restore run record. We persist BOTH the original
|
|
// restore failure AND the rollback status so the admin can tell
|
|
// from a single SQL query which scenario they're in:
|
|
// - rollback succeeded → destination is back to pre-restore state, safe to retry
|
|
// - rollback failed → partial state, admin must inspect before next attempt
|
|
// - rollback skipped → user opted out via skipPreBackup; same as above
|
|
if (restoreRun) {
|
|
const failureMessage = rollbackAttempted
|
|
? (rollbackSucceeded
|
|
? `${error.message} (rolled back successfully to pre-restore state)`
|
|
: `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`)
|
|
: `${error.message} (no pre-restore backup available — destination may be partial)`;
|
|
await db('restore_runs').where('id', restoreRun.id).update({
|
|
completed_at: new Date(),
|
|
status: 'failed',
|
|
error_message: failureMessage,
|
|
was_rollback_attempted: rollbackAttempted,
|
|
restore_log: JSON.stringify(this.restoreLog)
|
|
});
|
|
}
|
|
|
|
// Send failure notification
|
|
await this.sendRestoreNotification('failure', {
|
|
error: error.message,
|
|
restoreType: options.restoreType,
|
|
rollbackAttempted,
|
|
rollbackSucceeded,
|
|
});
|
|
|
|
throw error;
|
|
|
|
} finally {
|
|
this.isRunning = false;
|
|
this.currentProgress = null;
|
|
|
|
// Clean up temp directory
|
|
try {
|
|
await fs.rmdir(this.tempDir, { recursive: true });
|
|
} catch (err) {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate restore options
|
|
*/
|
|
validateRestoreOptions(options) {
|
|
if (!options.source) {
|
|
throw new Error('Backup source is required');
|
|
}
|
|
|
|
if (!options.manifestPath) {
|
|
throw new Error('Manifest path is required');
|
|
}
|
|
|
|
if (!options.restoreType) {
|
|
throw new Error('Restore type is required');
|
|
}
|
|
|
|
const validTypes = ['full', 'database', 'files', 'selective'];
|
|
if (!validTypes.includes(options.restoreType)) {
|
|
throw new Error(`Invalid restore type. Must be one of: ${validTypes.join(', ')}`);
|
|
}
|
|
|
|
if (options.restoreType === 'selective' && (!options.selectedItems || options.selectedItems.length === 0)) {
|
|
throw new Error('Selected items are required for selective restore');
|
|
}
|
|
|
|
if (options.source.startsWith('s3://') && !options.s3Config) {
|
|
throw new Error('S3 configuration is required for S3-based backups');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and validate manifest
|
|
*/
|
|
async loadAndValidateManifest(manifestPath, s3Config) {
|
|
let manifest;
|
|
|
|
if (manifestPath.startsWith('s3://')) {
|
|
// Download manifest from S3
|
|
const tempManifestPath = path.join(this.tempDir, 'manifest.json');
|
|
await fs.mkdir(this.tempDir, { recursive: true });
|
|
await this.downloadFileFromS3(manifestPath, tempManifestPath, s3Config);
|
|
manifest = await backupManifest.loadManifest(tempManifestPath);
|
|
} else {
|
|
manifest = await backupManifest.loadManifest(manifestPath);
|
|
}
|
|
|
|
// Validate manifest
|
|
backupManifest.validateManifest(manifest);
|
|
|
|
return manifest;
|
|
}
|
|
|
|
/**
|
|
* Perform pre-restore validation
|
|
*/
|
|
async performPreRestoreValidation(manifest, options) {
|
|
const validation = {
|
|
isValid: true,
|
|
errors: [],
|
|
warnings: []
|
|
};
|
|
|
|
try {
|
|
// Check backup integrity. MUST delegate to verifyManifestChecksum rather
|
|
// than recomputing here — that helper owns the legacy-serialization and
|
|
// keyed/unkeyed fallbacks (GHSA-hgp8). Recomputing with the default
|
|
// canonical+keyed settings rejected every backup written before those
|
|
// changes, i.e. every existing one.
|
|
//
|
|
// Called UNCONDITIONALLY: the old `if (…total_checksum)` guard meant an
|
|
// attacker who could rewrite the backup store simply deleted the field
|
|
// to skip verification altogether. The helper owns that case now and
|
|
// rejects it.
|
|
const checksumResult = backupManifest.verifyManifestChecksum(manifest);
|
|
checksumResult.warnings.forEach((w) => this.log('warn', w));
|
|
if (!checksumResult.valid) {
|
|
validation.errors.push(checksumResult.error || 'Manifest checksum verification failed');
|
|
validation.isValid = false;
|
|
}
|
|
|
|
// Check backup age
|
|
const backupAge = Date.now() - new Date(manifest.backup.timestamp).getTime();
|
|
const ageInDays = backupAge / (1000 * 60 * 60 * 24);
|
|
if (ageInDays > 30) {
|
|
validation.warnings.push(`Backup is ${Math.round(ageInDays)} days old`);
|
|
}
|
|
|
|
// Check version compatibility
|
|
const currentVersion = require('../../package.json').version;
|
|
if (manifest.application.version !== currentVersion) {
|
|
validation.warnings.push(
|
|
`Version mismatch: backup from v${manifest.application.version}, current v${currentVersion}`
|
|
);
|
|
}
|
|
|
|
// Check database compatibility
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
const currentDbType = this.dbType;
|
|
if (manifest.database.type !== currentDbType) {
|
|
validation.errors.push(
|
|
`Database type mismatch: backup is ${manifest.database.type}, current is ${currentDbType}`
|
|
);
|
|
validation.isValid = false;
|
|
}
|
|
}
|
|
|
|
// Check if restoring would overwrite existing data.
|
|
//
|
|
// NOTE: pg-driver returns `count('* as count')` as a STRING (it
|
|
// serialises `bigint` to string to avoid JS precision loss for
|
|
// huge counts) — see PR #596 review for the `bigint`-as-string
|
|
// discussion. Both blocks below coerce to `Number` before
|
|
// comparing AND before interpolating into the warning text, so
|
|
// the count renders as `5` not `"5"` regardless of DB driver.
|
|
// Don't drop the `Number()` calls without also re-auditing the
|
|
// strict-equality call sites flagged in the same review.
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
const eventCount = await db('events').count('* as count').first();
|
|
const eventCountN = Number(eventCount?.count || 0);
|
|
if (eventCountN > 0) {
|
|
validation.warnings.push(`Database contains ${eventCountN} existing events that will be overwritten`);
|
|
}
|
|
}
|
|
|
|
// Check for active users (same coercion contract as above).
|
|
const activeUsers = await db('admin_users')
|
|
.where('is_active', formatBoolean(true))
|
|
.count('* as count')
|
|
.first();
|
|
const activeUsersN = Number(activeUsers?.count || 0);
|
|
if (activeUsersN > 0) {
|
|
validation.warnings.push(`There are ${activeUsersN} active admin users`);
|
|
}
|
|
|
|
} catch (error) {
|
|
validation.errors.push(`Validation error: ${error.message}`);
|
|
validation.isValid = false;
|
|
}
|
|
|
|
return validation;
|
|
}
|
|
|
|
/**
|
|
* Check available disk space
|
|
*/
|
|
async checkDiskSpace(manifest, options) {
|
|
try {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
|
|
// Calculate required space (with 20% buffer)
|
|
let requiredBytes = 0;
|
|
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
|
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
|
|
}
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
requiredBytes += (manifest.database?.size || 0) * 1.2;
|
|
}
|
|
|
|
// Try to get disk space using df command (works on Linux and macOS)
|
|
let availableBytes = 0;
|
|
let diskCheckSucceeded = false;
|
|
try {
|
|
// Use root path as fallback if storage path doesn't exist yet
|
|
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
|
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
|
|
// Parse df output: last line, 4th column is available KB
|
|
const lines = stdout.trim().split('\n');
|
|
const lastLine = lines[lines.length - 1];
|
|
const columns = lastLine.trim().split(/\s+/);
|
|
const parsed = parseInt(columns[3]);
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
availableBytes = parsed * 1024; // Convert from KB to bytes
|
|
diskCheckSucceeded = true;
|
|
}
|
|
} catch (dfError) {
|
|
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
|
|
}
|
|
|
|
// If disk check failed, return optimistic result
|
|
if (!diskCheckSucceeded) {
|
|
return {
|
|
hasEnoughSpace: true,
|
|
availableBytes: null, // null indicates unknown
|
|
requiredBytes,
|
|
availableFormatted: 'Unknown',
|
|
requiredFormatted: this.formatBytes(requiredBytes)
|
|
};
|
|
}
|
|
|
|
// Add space for pre-restore backup
|
|
if (!options.skipPreBackup) {
|
|
try {
|
|
const currentUsage = await this.calculateCurrentStorageUsage();
|
|
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
|
|
} catch (e) {
|
|
// Ignore errors calculating current usage
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasEnoughSpace: availableBytes > requiredBytes,
|
|
availableBytes,
|
|
requiredBytes,
|
|
availableFormatted: this.formatBytes(availableBytes),
|
|
requiredFormatted: this.formatBytes(requiredBytes)
|
|
};
|
|
|
|
} catch (error) {
|
|
// Fallback for any errors
|
|
this.log('warn', 'Could not check disk space', { error: error.message });
|
|
return {
|
|
hasEnoughSpace: true, // Assume we have space if we can't check
|
|
availableBytes: null,
|
|
requiredBytes: 0,
|
|
availableFormatted: 'Unknown',
|
|
requiredFormatted: 'Unknown'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create pre-restore backup
|
|
*/
|
|
async createPreRestoreBackup(options) {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const backupName = `pre-restore-${timestamp}`;
|
|
const backupPath = path.join(this.tempDir, backupName);
|
|
|
|
await fs.mkdir(backupPath, { recursive: true });
|
|
|
|
try {
|
|
// Backup database
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
this.log('info', 'Backing up current database...');
|
|
const dbBackupPath = path.join(backupPath, 'database.sql');
|
|
|
|
if (this.dbType === 'sqlite') {
|
|
const dbPath = knexConfig.connection.filename;
|
|
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
|
|
} else {
|
|
// PostgreSQL backup
|
|
const { host, port, user, password, database } = knexConfig.connection;
|
|
const env = { ...process.env, PGPASSWORD: password };
|
|
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
|
|
}
|
|
|
|
// Compress database backup
|
|
await this.compressFile(dbBackupPath, `${dbBackupPath}.gz`);
|
|
await fs.unlink(dbBackupPath);
|
|
}
|
|
|
|
// Backup files
|
|
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
|
this.log('info', 'Backing up current files...');
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
|
|
|
|
await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]);
|
|
}
|
|
|
|
// Create backup manifest
|
|
const backupManifest = {
|
|
timestamp: new Date().toISOString(),
|
|
type: 'pre-restore-safety-backup',
|
|
restoreOptions: this.sanitizeOptions(options),
|
|
contents: await fs.readdir(backupPath)
|
|
};
|
|
|
|
await fs.writeFile(
|
|
path.join(backupPath, 'backup-manifest.json'),
|
|
JSON.stringify(backupManifest, null, 2)
|
|
);
|
|
|
|
return backupPath;
|
|
|
|
} catch (error) {
|
|
// Clean up on failure
|
|
await fs.rmdir(backupPath, { recursive: true }).catch(() => {});
|
|
throw new Error(`Failed to create pre-restore backup: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download backup from S3
|
|
*/
|
|
async downloadFromS3(s3Url, manifest, options) {
|
|
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
|
if (!s3PathMatch) {
|
|
throw new Error('Invalid S3 URL format');
|
|
}
|
|
|
|
const [, bucket, prefix] = s3PathMatch;
|
|
const s3Client = new S3StorageAdapter({
|
|
...options.s3Config,
|
|
bucket
|
|
});
|
|
|
|
const localPath = path.join(this.tempDir, 'restore-download');
|
|
await fs.mkdir(localPath, { recursive: true });
|
|
|
|
try {
|
|
// Test S3 connection
|
|
await s3Client.testConnection();
|
|
|
|
// Download database backup if needed
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
if (manifest.database.backup_file) {
|
|
const dbS3Key = path.posix.join(prefix, 'database', path.basename(manifest.database.backup_file));
|
|
const localDbPath = path.join(localPath, 'database', path.basename(manifest.database.backup_file));
|
|
|
|
await fs.mkdir(path.dirname(localDbPath), { recursive: true });
|
|
|
|
this.log('info', 'Downloading database backup from S3...', { key: dbS3Key });
|
|
await s3Client.download(dbS3Key, localDbPath, {
|
|
onProgress: (loaded, total) => {
|
|
const percent = Math.round((loaded / total) * 100);
|
|
this.updateProgress(`Downloading database backup: ${percent}%`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Download files if needed
|
|
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
|
const filesToDownload = options.restoreType === 'selective'
|
|
? options.selectedItems
|
|
: manifest.files.manifest;
|
|
|
|
let downloaded = 0;
|
|
for (const file of filesToDownload) {
|
|
const s3Key = path.posix.join(prefix, file.path);
|
|
const localFilePath = path.join(localPath, file.path);
|
|
|
|
// Containment guard (GHSA-fm58): reject a manifest path that would
|
|
// write outside the download staging dir.
|
|
if (pathEscapes(localPath, localFilePath)) {
|
|
this.log('error', `Refusing unsafe manifest path on download: ${file.path}`);
|
|
continue;
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(localFilePath), { recursive: true });
|
|
|
|
try {
|
|
await s3Client.download(s3Key, localFilePath, {
|
|
onProgress: (loaded, total) => {
|
|
const filePercent = Math.round((loaded / total) * 100);
|
|
const totalPercent = Math.round(((downloaded + (loaded / total)) / filesToDownload.length) * 100);
|
|
this.updateProgress(`Downloading files: ${totalPercent}% (${file.path}: ${filePercent}%)`);
|
|
}
|
|
});
|
|
|
|
// Verify checksum if available
|
|
if (file.checksum) {
|
|
const downloadedChecksum = await this.calculateChecksum(localFilePath);
|
|
if (downloadedChecksum !== file.checksum) {
|
|
throw new Error(`Checksum mismatch for ${file.path}`);
|
|
}
|
|
}
|
|
|
|
downloaded++;
|
|
} catch (error) {
|
|
this.log('error', `Failed to download ${file.path}`, { error: error.message });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
return localPath;
|
|
|
|
} catch (error) {
|
|
// Clean up on failure
|
|
await fs.rmdir(localPath, { recursive: true }).catch(() => {});
|
|
throw new Error(`Failed to download from S3: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform full restore (database + files)
|
|
*/
|
|
/**
|
|
* Requeue face detection after a restore (#1074).
|
|
*
|
|
* Face data is deliberately excluded from backups — it is derived, and
|
|
* biometric data should not travel in an archive. But photos.face_status
|
|
* DOES restore, so without this the restored install claims every photo is
|
|
* scanned while photo_faces is empty, and the worker never picks them up
|
|
* because it only claims 'pending'. The gallery shows a finished scan and
|
|
* no people, forever, with nothing to indicate why.
|
|
*
|
|
* MUST run after the FILES are restored, not merely after the database.
|
|
* The face worker is live throughout a restore; queued earlier it races the
|
|
* file copy and either scans the previous instance's originals or marks
|
|
* photos failed for files that are not there yet — and nothing re-queues
|
|
* them afterwards.
|
|
*
|
|
* Only touches rows that had been scanned; NULL stays NULL, so this never
|
|
* switches the feature on for anyone.
|
|
*/
|
|
async requeueFaceScans() {
|
|
try {
|
|
const { db: restoredDb } = require('../database/db');
|
|
const requeued = await restoredDb('photos')
|
|
.whereNotNull('face_status')
|
|
.update({
|
|
face_status: 'pending',
|
|
face_count: null,
|
|
face_started_at: null,
|
|
face_error: null,
|
|
});
|
|
if (requeued > 0) {
|
|
this.log('info', `Requeued ${requeued} photo(s) for face detection after restore`);
|
|
}
|
|
} catch (err) {
|
|
// Pre-migration-177 backups have no such column; not an error.
|
|
this.log('info', `Face state reset skipped: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async performFullRestore(backupPath, manifest, options) {
|
|
const result = {
|
|
databaseRestored: false,
|
|
filesRestored: 0,
|
|
errors: []
|
|
};
|
|
|
|
try {
|
|
// Restore database first
|
|
const dbResult = await this.performDatabaseRestore(backupPath, manifest, options);
|
|
result.databaseRestored = dbResult.success;
|
|
|
|
// Then restore files
|
|
const filesResult = await this.performFilesRestore(backupPath, manifest, options);
|
|
result.filesRestored = filesResult.filesRestored;
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
result.errors.push(error.message);
|
|
throw new Error(`Full restore failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform database-only restore
|
|
*/
|
|
async performDatabaseRestore(backupPath, manifest, _options) {
|
|
this.updateProgress('Restoring database...');
|
|
|
|
const dbBackupFile = manifest.database.backup_file;
|
|
if (!dbBackupFile) {
|
|
throw new Error('No database backup file found in manifest');
|
|
}
|
|
|
|
// Layered resolution for the database dump path:
|
|
//
|
|
// 1. Manifest stores the absolute path the dumper wrote to
|
|
// (e.g. `/backup/database/picpeak-db-postgresql-<ts>.sql.gz`).
|
|
// That's the truth — try it first.
|
|
// 2. Some older manifests store a path RELATIVE to the file-backup
|
|
// destination root (`database/<file>.sql.gz`). Reconstruct that
|
|
// way as a fallback.
|
|
// 3. Final fallback: `<backupPath>/database/<basename>`, the
|
|
// historical reconstruction used before this fix. Preserved so
|
|
// no existing valid path breaks.
|
|
//
|
|
// The original code used (3) exclusively, which meant the restore
|
|
// service ignored the absolute path the manifest recorded and
|
|
// looked under a synthetic `<backupPath>/database/<file>` root —
|
|
// which on Ralf's install became `local/database/<file>` because
|
|
// `backupPath` was the source type string, not a directory. Caused
|
|
// the canonical disaster-recovery flow to fail with
|
|
// `Database backup file not found: local/database/...sql.gz`
|
|
// even though the file existed at exactly the path the manifest
|
|
// recorded.
|
|
// GHSA-xfvx: `dbBackupFile` comes straight out of the manifest, which is
|
|
// attacker-influenceable (hand-crafted or tampered backup). Neither
|
|
// candidate (1) nor (2) below used to be checked for containment, so a
|
|
// manifest could point `.restore` at an arbitrary file anywhere on disk
|
|
// (absolute path, or `../../` traversal through the path.join). Resolve
|
|
// each candidate and drop any that escape the configured backup roots
|
|
// BEFORE it's ever fs.access'd/candidate-listed. Candidate (3) is
|
|
// inherently safe (path.basename() strips any directory component) and
|
|
// is always inside `backupPath`, which is itself always one of the
|
|
// allowed roots below.
|
|
const candidates = await resolveContainedDbBackupCandidates(
|
|
backupPath, dbBackupFile, (msg, meta) => this.log('warn', msg, meta)
|
|
);
|
|
|
|
if (candidates.length === 0) {
|
|
throw new Error(
|
|
'Database backup file path is not inside a configured backup location. ' +
|
|
`Manifest recorded path: ${dbBackupFile}.`
|
|
);
|
|
}
|
|
|
|
let dbBackupPath = null;
|
|
for (const candidate of candidates) {
|
|
try {
|
|
await fs.access(candidate);
|
|
dbBackupPath = candidate;
|
|
break;
|
|
} catch (_) {
|
|
// try next candidate
|
|
}
|
|
}
|
|
|
|
if (!dbBackupPath) {
|
|
throw new Error(
|
|
`Database backup file not found. Tried: ${candidates.join(', ')}. ` +
|
|
`Manifest recorded path: ${dbBackupFile}. ` +
|
|
'Hint: this usually means the manifest\'s database.backup_file path no longer ' +
|
|
'exists on disk (deleted? moved? volume not mounted?). Check ' +
|
|
'~/<your-compose-dir>/backup/database/ on the host.'
|
|
);
|
|
}
|
|
|
|
// Decompress if needed
|
|
let restoreFile = dbBackupPath;
|
|
if (dbBackupPath.endsWith('.gz')) {
|
|
this.log('info', 'Decompressing database backup...');
|
|
const decompressedPath = dbBackupPath.replace('.gz', '');
|
|
await this.decompressFile(dbBackupPath, decompressedPath);
|
|
restoreFile = decompressedPath;
|
|
}
|
|
|
|
// Snapshot of operator-meta keys captured BEFORE the DROP.
|
|
// Stashed onto `this.preservedMetaSnapshot` so the parent
|
|
// `restore()` method can drain + apply it AFTER post-restore
|
|
// verification passes. Order matters here:
|
|
//
|
|
// - PR #596 round 1: lifted the declaration above the
|
|
// SQLite/PG split to fix a ReferenceError when the replay
|
|
// was inline at the bottom of this method.
|
|
// - PR #596 round 3: moved the REPLAY itself out of here and
|
|
// into restore(), because the round-1 in-method replay ran
|
|
// BEFORE post-restore verification — which then counted the
|
|
// replayed row and flagged
|
|
// Table app_settings row count mismatch: expected 190, got 191
|
|
// as a verification failure even though both Stage A and
|
|
// the replay had succeeded. Verification now sees the
|
|
// as-restored DB (matches the backup exactly), replay layers
|
|
// on top after verification has signed off.
|
|
//
|
|
// SQLite branch leaves `preservedMetaSnapshot` empty — verification
|
|
// and replay both no-op for it, unchanged behaviour.
|
|
const PRESERVED_META_KEYS = [
|
|
'restore_allow_force',
|
|
'restore_allow_force_auto_upgraded',
|
|
];
|
|
|
|
try {
|
|
if (this.dbType === 'sqlite') {
|
|
// SQLite restore
|
|
const dbPath = knexConfig.connection.filename;
|
|
|
|
// Close all database connections
|
|
await db.destroy();
|
|
|
|
// Backup current database
|
|
const currentBackup = `${dbPath}.restore-backup`;
|
|
await fs.copyFile(dbPath, currentBackup);
|
|
|
|
try {
|
|
// Restore from backup. `restoreFile` is contained-checked above,
|
|
// but the FILENAME component still comes from the manifest — a
|
|
// quote in it would break out of the `.restore '<path>'` dot-
|
|
// command sqlite3 parses (GHSA-xfvx). Charset-validate right
|
|
// before use as the final gate.
|
|
assertSafeSqlitePath(restoreFile);
|
|
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
|
|
|
// Verify integrity
|
|
const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']);
|
|
if (!integrityCheck.stdout.includes('ok')) {
|
|
throw new Error('Database integrity check failed after restore');
|
|
}
|
|
|
|
// Remove backup of previous database
|
|
await fs.unlink(currentBackup);
|
|
|
|
} catch (error) {
|
|
// Rollback on failure
|
|
await fs.copyFile(currentBackup, dbPath);
|
|
await fs.unlink(currentBackup);
|
|
throw error;
|
|
}
|
|
|
|
} else {
|
|
// PostgreSQL restore
|
|
const { host, port, user, password, database } = knexConfig.connection;
|
|
const env = { ...process.env, PGPASSWORD: password };
|
|
|
|
// Snapshot operator-meta settings BEFORE the DROP so we can
|
|
// restore them after the psql load. These keys are about how
|
|
// the operator wants the install to behave (force-restore
|
|
// permission, auto-upgrade tracking), not user-facing state —
|
|
// they should NOT be overwritten by whatever values the backup
|
|
// happens to contain.
|
|
//
|
|
// Chicken-and-egg this closes: `restore_allow_force` defaults
|
|
// to true (post tonight's migration 032 edit), but every
|
|
// restore would overwrite it with whatever the backup carried.
|
|
// Admin sets it to true → restores → wakes up with the row
|
|
// back to whatever was in the backup. Two consecutive restores
|
|
// needed the SQL workaround again. With this snapshot/replay,
|
|
// the operator's policy persists across restores.
|
|
//
|
|
// PRESERVED_META_KEYS is declared above the SQLite/PG split
|
|
// (~L820). The snapshot READ happens here in the PG branch
|
|
// (must run before DROP), but is stashed on
|
|
// `this.preservedMetaSnapshot` for the parent `restore()`
|
|
// method to consume AFTER verification — see the round-3
|
|
// notes there.
|
|
try {
|
|
this.preservedMetaSnapshot = await db('app_settings')
|
|
.whereIn('setting_key', PRESERVED_META_KEYS)
|
|
.select('setting_key', 'setting_value', 'setting_type');
|
|
this.log('info', `Snapshotted ${this.preservedMetaSnapshot.length} restore-meta setting(s) for post-restore replay`, {
|
|
keys: this.preservedMetaSnapshot.map(r => r.setting_key),
|
|
});
|
|
} catch (err) {
|
|
this.log('warn', `Could not snapshot restore-meta settings (continuing): ${err.message}`);
|
|
}
|
|
|
|
// `psql` with no `-d` defaults to a database whose name matches
|
|
// the connecting user, NOT a maintenance DB. So on installs
|
|
// where the user's home DB doesn't exist (e.g. user=`picpeak`,
|
|
// target DB=`picpeak_prod`, no `picpeak` DB), the next two
|
|
// statements failed with:
|
|
// FATAL: database "picpeak" does not exist
|
|
// even though the actual target DB was alive and connectable.
|
|
//
|
|
// Fix: explicitly connect to `postgres` (the maintenance DB
|
|
// every PG cluster ships with) for the DROP/CREATE. We can't
|
|
// connect to the target DB itself anyway — DROP DATABASE
|
|
// refuses to run while a connection is open to it.
|
|
//
|
|
// Use `DB_CHECK_DB` env var as an override hook (matches the
|
|
// pattern wait-for-db.sh already exposes) for installs where
|
|
// the `postgres` DB is restricted to superusers.
|
|
const maintenanceDb = process.env.DB_CHECK_DB || 'postgres';
|
|
|
|
// The backend's own knex pool holds N active connections to
|
|
// the target database (default 5-25 per knexfile.js). PostgreSQL
|
|
// refuses DROP DATABASE while any session is connected:
|
|
// ERROR: database "X" is being accessed by other users
|
|
// DETAIL: There are N other sessions using the database.
|
|
// We have to evict those sessions ourselves before issuing the
|
|
// DROP. Two-step approach:
|
|
// 1. Close knex's own pool so we don't fight ourselves.
|
|
// 2. pg_terminate_backend() the rest (other server replicas,
|
|
// pg_stat_activity stragglers, leftover idle txns).
|
|
//
|
|
// After CREATE DATABASE, knex will lazily re-open the pool on
|
|
// the next query — handled by db.js's connection retry logic.
|
|
this.log('warn', 'Closing knex pool before dropping target database...');
|
|
try { await db.destroy(); } catch (poolErr) {
|
|
this.log('warn', `Pool destroy threw (continuing): ${poolErr.message}`);
|
|
}
|
|
|
|
this.log('warn', 'Terminating any remaining sessions on target database...', {
|
|
target: database,
|
|
});
|
|
// pg_terminate_backend takes a pid. Kill every session against
|
|
// the target DB except our own connection (which is to the
|
|
// maintenance DB anyway). Wrapped in `SELECT ... FROM ... WHERE`
|
|
// so we get one psql round-trip instead of N.
|
|
await spawnAsync('psql', [
|
|
'-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
|
|
'-c',
|
|
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity ' +
|
|
`WHERE datname = '${database.replace(/'/g, '\'\'')}' AND pid <> pg_backend_pid()`,
|
|
], { env });
|
|
|
|
// Drop and recreate database (extremely dangerous!)
|
|
this.log('warn', 'Dropping and recreating PostgreSQL database...', {
|
|
target: database, via: maintenanceDb,
|
|
});
|
|
|
|
// WITH (FORCE) on Postgres 13+ kills any remaining connections
|
|
// atomically with the DROP. On older Postgres the FORCE option
|
|
// doesn't exist, so we fall back to plain DROP IF EXISTS — by
|
|
// which point pg_terminate_backend should have cleared the
|
|
// table. Try FORCE first, fall back to plain on syntax error.
|
|
try {
|
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
|
|
'-c', `DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`], { env });
|
|
} catch (forceErr) {
|
|
// PG < 13: WITH (FORCE) is a syntax error. Plain DROP after
|
|
// our pg_terminate_backend pass should now succeed.
|
|
this.log('info', 'DROP DATABASE WITH (FORCE) not supported — falling back to plain DROP', {
|
|
error: forceErr.message,
|
|
});
|
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
|
|
'-c', `DROP DATABASE IF EXISTS "${database}"`], { env });
|
|
}
|
|
|
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb, '-c', `CREATE DATABASE "${database}"`], { env });
|
|
|
|
// Restore from backup — this one DOES connect to the target DB.
|
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
|
|
|
|
// Re-sync every SERIAL / IDENTITY sequence in the public schema
|
|
// to MAX(id)+1 of its owning table. pg_dump emits setval()
|
|
// statements, but they don't always land cleanly when:
|
|
// - the dump has `--clean` (the setval may execute before
|
|
// the rebuilt rows, depending on dump ordering)
|
|
// - the in-process knex pool had a cached sequence value
|
|
// before db.destroy() (already mitigated, but defensive)
|
|
// - rows were inserted mid-restore (the pre-restore safety
|
|
// backup creates a database_backup_runs row before DROP)
|
|
// Result if skipped: every subsequent INSERT into a serial-id
|
|
// table fails with `duplicate key value violates unique
|
|
// constraint "<table>_pkey"`. Surfaced on Ralf's install as
|
|
// "A record with this value already exists" on every CRUD
|
|
// action AND `database_backup_runs_pkey` violation on the
|
|
// next Run Backup Now. Fix is a single DO block that walks
|
|
// pg_class + pg_attribute and setval()s each sequence to
|
|
// GREATEST(MAX(<col>), 1). Cheap (a few ms even on large
|
|
// schemas), safe (doesn't touch row data), idempotent.
|
|
this.log('info', 'Re-syncing PostgreSQL sequences to MAX(id) of each table...');
|
|
await spawnAsync('psql', [
|
|
'-h', host, '-p', String(port), '-U', user, '-d', database,
|
|
'-c',
|
|
`DO $$
|
|
DECLARE
|
|
r RECORD;
|
|
max_id BIGINT;
|
|
BEGIN
|
|
FOR r IN
|
|
SELECT n.nspname AS schema_name, t.relname AS table_name, a.attname AS column_name,
|
|
pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(t.relname), a.attname) AS seq_name
|
|
FROM pg_class t
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
JOIN pg_attribute a ON a.attrelid = t.oid
|
|
WHERE n.nspname = 'public'
|
|
AND t.relkind = 'r'
|
|
AND a.attnum > 0
|
|
AND NOT a.attisdropped
|
|
AND pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(t.relname), a.attname) IS NOT NULL
|
|
LOOP
|
|
EXECUTE format('SELECT COALESCE(MAX(%I), 0) FROM %I.%I', r.column_name, r.schema_name, r.table_name) INTO max_id;
|
|
EXECUTE format('SELECT setval(%L, %s, true)', r.seq_name, GREATEST(max_id, 1));
|
|
END LOOP;
|
|
END $$;`
|
|
], { env });
|
|
this.log('info', 'Sequence resync completed');
|
|
}
|
|
|
|
// Re-initialize the in-process knex pool. The DROP/CREATE
|
|
// DATABASE pair above destroyed our connections and the recreated
|
|
// database has a different pg_database OID — any pooled
|
|
// connection from before would either be dead or pointed at a
|
|
// ghost. Without explicit reinit, every query in the process
|
|
// after restore returns `Error: Unable to acquire a connection`
|
|
// until the container is manually restarted (and admin sees
|
|
// "An error occurred" on the login screen even after the restore
|
|
// technically succeeded). reinitPool destroys + rebuilds the
|
|
// pool and probes the new one with `SELECT 1` so failures here
|
|
// surface immediately instead of polluting the next request.
|
|
const { reinitPool } = require('../database/db');
|
|
this.log('info', 'Re-initializing knex pool against the restored database...');
|
|
await reinitPool();
|
|
this.log('info', 'Knex pool re-initialized');
|
|
|
|
|
|
// NOTE: we deliberately do NOT call `db.migrate.latest()` here.
|
|
//
|
|
// The picpeak migrations directory contains `helpers.js` (a
|
|
// shared helper module, not a migration), plus `core/` and
|
|
// `legacy/` subdirectories. Knex's built-in migrator scans the
|
|
// top-level directory and rejects any file without `up`/`down`
|
|
// exports — so `db.migrate.latest()` throws
|
|
// Invalid migration: helpers.js must have both an up and down function
|
|
// every time it runs in this codebase. The production code path
|
|
// uses `npm run migrate:safe` (run-migrations-safe.js) which
|
|
// knows to skip helpers.js + walks core/ explicitly.
|
|
//
|
|
// The safe runner gets invoked AFTER verification in restore()
|
|
// (see step 7c) to apply any post-backup migrations to the
|
|
// restored DB. This closes the contract "backup must restore
|
|
// completely even when new features have been added in the
|
|
// meantime" — without this step, restoring an old backup on a
|
|
// newer image would leave the running process serving a
|
|
// mismatched schema until the next container restart.
|
|
this.log('info', 'Schema migrations deferred to restore() step 7c (npm run migrate:safe subprocess)');
|
|
|
|
// NOTE: operator-meta REPLAY does NOT happen here any more.
|
|
// PR #596 round 3: if the replay runs inside performDatabaseRestore,
|
|
// it lands BEFORE post-restore verification — and verification
|
|
// then counts the replayed row as a mismatch (e.g. "expected 190,
|
|
// got 191" because the fresh-install seeded
|
|
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
|
|
// Replay is now drained by the parent `restore()` method AFTER
|
|
// verification passes. Snapshot lives on
|
|
// `this.preservedMetaSnapshot` for that drain.
|
|
|
|
return { success: true };
|
|
|
|
} catch (error) {
|
|
this.log('error', 'Database restore failed', { error: error.message });
|
|
throw error;
|
|
} finally {
|
|
// Clean up decompressed file
|
|
if (restoreFile !== dbBackupPath) {
|
|
await fs.unlink(restoreFile).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform files-only restore
|
|
*/
|
|
async performFilesRestore(backupPath, manifest, options) {
|
|
this.updateProgress('Restoring files...');
|
|
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const filesToRestore = options.restoreType === 'selective'
|
|
? options.selectedItems.filter(item => item.type === 'file')
|
|
: manifest.files.manifest;
|
|
|
|
let restoredCount = 0;
|
|
const errors = [];
|
|
|
|
for (const file of filesToRestore) {
|
|
try {
|
|
const sourcePath = path.join(backupPath, file.path);
|
|
const targetPath = path.join(storagePath, file.path);
|
|
|
|
// Containment guard (GHSA-fm58): a crafted manifest path like
|
|
// `../../etc/cron.d/x` would otherwise escape the storage root and
|
|
// overwrite arbitrary files. Skip any entry that escapes.
|
|
if (pathEscapes(backupPath, sourcePath) || pathEscapes(storagePath, targetPath)) {
|
|
errors.push(`Refusing unsafe manifest path: ${file.path}`);
|
|
continue;
|
|
}
|
|
|
|
// Check if source file exists
|
|
try {
|
|
await fs.access(sourcePath);
|
|
} catch (error) {
|
|
errors.push(`Source file not found: ${file.path}`);
|
|
continue;
|
|
}
|
|
|
|
// Create target directory
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
|
|
// Check if target exists and create backup
|
|
let targetBackup = null;
|
|
try {
|
|
await fs.access(targetPath);
|
|
targetBackup = `${targetPath}.restore-backup`;
|
|
await fs.copyFile(targetPath, targetBackup);
|
|
} catch (error) {
|
|
// Target doesn't exist, no backup needed
|
|
}
|
|
|
|
try {
|
|
// Copy file
|
|
await fs.copyFile(sourcePath, targetPath);
|
|
|
|
// Verify checksum if available
|
|
if (file.checksum) {
|
|
const restoredChecksum = await this.calculateChecksum(targetPath);
|
|
if (restoredChecksum !== file.checksum) {
|
|
throw new Error('Checksum verification failed');
|
|
}
|
|
}
|
|
|
|
// Set file permissions if available
|
|
if (file.permissions) {
|
|
await fs.chmod(targetPath, file.permissions);
|
|
}
|
|
|
|
// Set modification time
|
|
if (file.modified) {
|
|
const mtime = new Date(file.modified);
|
|
await fs.utimes(targetPath, mtime, mtime);
|
|
}
|
|
|
|
// Remove backup of previous file
|
|
if (targetBackup) {
|
|
await fs.unlink(targetBackup);
|
|
}
|
|
|
|
restoredCount++;
|
|
|
|
if (restoredCount % 100 === 0) {
|
|
this.updateProgress(`Restored ${restoredCount}/${filesToRestore.length} files`);
|
|
}
|
|
|
|
} catch (error) {
|
|
// Rollback on failure
|
|
if (targetBackup) {
|
|
await fs.copyFile(targetBackup, targetPath);
|
|
await fs.unlink(targetBackup);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
} catch (error) {
|
|
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
this.log('error', `Failed to restore file ${file.path}`, { error: error.message });
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0 && errors.length === filesToRestore.length) {
|
|
throw new Error('All file restorations failed');
|
|
}
|
|
|
|
return {
|
|
filesRestored: restoredCount,
|
|
totalFiles: filesToRestore.length,
|
|
errors
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Perform selective restore
|
|
*/
|
|
async performSelectiveRestore(backupPath, manifest, options) {
|
|
const result = {
|
|
itemsRestored: 0,
|
|
errors: []
|
|
};
|
|
|
|
// Separate selected items by type
|
|
const databaseItems = options.selectedItems.filter(item => item.type === 'database');
|
|
const fileItems = options.selectedItems.filter(item => item.type === 'file');
|
|
|
|
// Restore database items (tables)
|
|
if (databaseItems.length > 0) {
|
|
this.log('warn', 'Selective database restore not implemented yet');
|
|
result.errors.push('Selective database restore not implemented');
|
|
}
|
|
|
|
// Restore file items
|
|
if (fileItems.length > 0) {
|
|
const filesResult = await this.performFilesRestore(backupPath, manifest, {
|
|
...options,
|
|
selectedItems: fileItems
|
|
});
|
|
result.itemsRestored += filesResult.filesRestored;
|
|
result.errors.push(...filesResult.errors);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Perform post-restore verification
|
|
*/
|
|
async performPostRestoreVerification(manifest, options) {
|
|
const verification = {
|
|
isValid: true,
|
|
errors: [],
|
|
checksums: {}
|
|
};
|
|
|
|
try {
|
|
// Verify database
|
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
|
// Check database connectivity
|
|
try {
|
|
await db.raw('SELECT 1');
|
|
} catch (error) {
|
|
verification.errors.push('Database connection failed after restore');
|
|
verification.isValid = false;
|
|
}
|
|
|
|
// Compare table checksums if available
|
|
if (manifest.database.row_counts) {
|
|
for (const [table, expected] of Object.entries(manifest.database.row_counts)) {
|
|
try {
|
|
const result = await db(table).count('* as count').first();
|
|
// pg-driver serialises `bigint` as string to preserve
|
|
// precision for huge counts, so `result.count` on PG is
|
|
// e.g. `"16"` while the manifest's `expected.rowCount`
|
|
// is the JS number `16`. Strict `!==` flagged every
|
|
// match as a mismatch on PG. Caught on PR #596 review:
|
|
// `Table activity_logs row count mismatch:
|
|
// expected 16, got 16`
|
|
// every table, all "matching". Coerce both sides to
|
|
// Number to compare reliably across SQLite (number) and
|
|
// PG (string).
|
|
const actual = Number(result.count);
|
|
if (actual !== expected.rowCount) {
|
|
verification.errors.push(
|
|
`Table ${table} row count mismatch: expected ${expected.rowCount}, got ${actual}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
verification.errors.push(`Failed to verify table ${table}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify files
|
|
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const filesToVerify = options.restoreType === 'selective'
|
|
? options.selectedItems.filter(item => item.type === 'file')
|
|
: manifest.files.manifest.slice(0, 100); // Verify first 100 files for performance
|
|
|
|
for (const file of filesToVerify) {
|
|
const filePath = path.join(storagePath, file.path);
|
|
// Same containment guard as performFilesRestore: a traversal
|
|
// manifest entry (e.g. `../../etc/passwd`) was skipped during the
|
|
// restore, so it must not be fs.access'd/hashed here either —
|
|
// otherwise an existing outside file makes the skipped entry look
|
|
// "verified" (and we'd read an arbitrary file off disk).
|
|
if (pathEscapes(storagePath, filePath)) {
|
|
verification.errors.push(`Refusing unsafe manifest path on verification: ${file.path}`);
|
|
continue;
|
|
}
|
|
try {
|
|
await fs.access(filePath);
|
|
|
|
if (file.checksum) {
|
|
const actualChecksum = await this.calculateChecksum(filePath);
|
|
verification.checksums[file.path] = {
|
|
expected: file.checksum,
|
|
actual: actualChecksum,
|
|
match: actualChecksum === file.checksum
|
|
};
|
|
|
|
if (actualChecksum !== file.checksum) {
|
|
verification.errors.push(`Checksum mismatch for ${file.path}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
verification.errors.push(`File not found after restore: ${file.path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
verification.errors.push(`Verification error: ${error.message}`);
|
|
verification.isValid = false;
|
|
}
|
|
|
|
verification.isValid = verification.errors.length === 0;
|
|
return verification;
|
|
}
|
|
|
|
/**
|
|
* Attempt rollback using pre-restore backup
|
|
*/
|
|
async attemptRollback(preRestoreBackupPath) {
|
|
this.log('warn', 'Attempting rollback to pre-restore state...');
|
|
|
|
try {
|
|
// Read backup manifest
|
|
const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json');
|
|
// Parsed for its side effect: throws if the manifest is missing/corrupt.
|
|
JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
|
|
// Restore database if backed up
|
|
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
|
|
if (await fs.access(dbBackupPath).then(() => true).catch(() => false)) {
|
|
const decompressedPath = dbBackupPath.replace('.gz', '');
|
|
await this.decompressFile(dbBackupPath, decompressedPath);
|
|
|
|
if (this.dbType === 'sqlite') {
|
|
const dbPath = knexConfig.connection.filename;
|
|
// Defense in depth: same dot-command injection surface as the
|
|
// main restore path (GHSA-xfvx), even though this path is
|
|
// internally generated rather than manifest-controlled.
|
|
assertSafeSqlitePath(decompressedPath);
|
|
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
|
} else {
|
|
const { host, port, user, password, database } = knexConfig.connection;
|
|
const env = { ...process.env, PGPASSWORD: password };
|
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
|
|
}
|
|
|
|
await fs.unlink(decompressedPath);
|
|
}
|
|
|
|
// Restore files if backed up
|
|
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
|
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]);
|
|
}
|
|
|
|
this.log('info', 'Rollback completed successfully');
|
|
|
|
} catch (error) {
|
|
this.log('error', 'Rollback failed', { error: error.message });
|
|
throw new Error(`Rollback failed: ${error.message}. Manual intervention may be required.`);
|
|
}
|
|
}
|
|
|
|
// Utility methods
|
|
|
|
/**
|
|
* Calculate file checksum
|
|
*/
|
|
async calculateChecksum(filePath) {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = createReadStream(filePath);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
stream.on('data', data => hash.update(data));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compress file using gzip
|
|
*/
|
|
async compressFile(inputPath, outputPath) {
|
|
const gzip = zlib.createGzip({ level: 6 });
|
|
const source = createReadStream(inputPath);
|
|
const destination = createWriteStream(outputPath);
|
|
await pipeline(source, gzip, destination);
|
|
}
|
|
|
|
/**
|
|
* Decompress gzip file
|
|
*/
|
|
async decompressFile(inputPath, outputPath) {
|
|
// Bound the EXPANDED size (GHSA-h652). gunzip happily inflates a small
|
|
// crafted .gz into an unbounded stream, filling the disk before any later
|
|
// validation runs. Cap it and fail the pipeline the moment the limit is
|
|
// crossed. The default is deliberately generous — real database dumps are
|
|
// large — and overridable for installs with genuinely bigger data.
|
|
const configured = Number(process.env.RESTORE_MAX_DECOMPRESSED_BYTES);
|
|
const maxBytes = Number.isFinite(configured) && configured > 0
|
|
? configured
|
|
: 50 * 1024 * 1024 * 1024; // 50 GB
|
|
|
|
let written = 0;
|
|
const limiter = new Transform({
|
|
transform(chunk, _enc, cb) {
|
|
written += chunk.length;
|
|
if (written > maxBytes) {
|
|
return cb(new Error(
|
|
`Decompressed size exceeds limit of ${maxBytes} bytes — refusing to continue`
|
|
));
|
|
}
|
|
cb(null, chunk);
|
|
},
|
|
});
|
|
|
|
const gunzip = zlib.createGunzip();
|
|
const source = createReadStream(inputPath);
|
|
const destination = createWriteStream(outputPath);
|
|
await pipeline(source, gunzip, limiter, destination);
|
|
}
|
|
|
|
/**
|
|
* Download file from S3
|
|
*/
|
|
async downloadFileFromS3(s3Url, localPath, s3Config) {
|
|
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
|
if (!s3PathMatch) {
|
|
throw new Error('Invalid S3 URL format');
|
|
}
|
|
|
|
const [, bucket, key] = s3PathMatch;
|
|
const s3Client = new S3StorageAdapter({
|
|
...s3Config,
|
|
bucket
|
|
});
|
|
|
|
await s3Client.download(key, localPath);
|
|
}
|
|
|
|
/**
|
|
* Calculate current storage usage
|
|
*/
|
|
async calculateCurrentStorageUsage() {
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
|
|
let totalSize = 0;
|
|
async function calculateDirSize(dirPath) {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await calculateDirSize(fullPath);
|
|
} else if (entry.isFile()) {
|
|
const stats = await fs.stat(fullPath);
|
|
totalSize += stats.size;
|
|
}
|
|
}
|
|
}
|
|
|
|
await calculateDirSize(storagePath);
|
|
return totalSize;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable
|
|
*/
|
|
formatBytes(bytes, decimals = 2) {
|
|
return formatBytes(bytes, decimals);
|
|
}
|
|
|
|
/**
|
|
* Update progress
|
|
*/
|
|
updateProgress(message, details = {}) {
|
|
this.currentProgress = {
|
|
message,
|
|
details,
|
|
timestamp: new Date()
|
|
};
|
|
logger.info(`Restore progress: ${message}`, details);
|
|
}
|
|
|
|
/**
|
|
* Get current progress
|
|
*/
|
|
getProgress() {
|
|
return this.currentProgress;
|
|
}
|
|
|
|
/**
|
|
* Log message
|
|
*/
|
|
log(level, message, details = {}) {
|
|
const logEntry = {
|
|
timestamp: new Date().toISOString(),
|
|
level,
|
|
message,
|
|
details
|
|
};
|
|
|
|
this.restoreLog.push(logEntry);
|
|
logger[level](message, details);
|
|
}
|
|
|
|
/**
|
|
* Sanitize options for logging
|
|
*/
|
|
sanitizeOptions(options) {
|
|
const sanitized = { ...options };
|
|
if (sanitized.s3Config) {
|
|
sanitized.s3Config = {
|
|
...sanitized.s3Config,
|
|
accessKeyId: sanitized.s3Config.accessKeyId ? '***' : undefined,
|
|
secretAccessKey: sanitized.s3Config.secretAccessKey ? '***' : undefined
|
|
};
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Send restore notification
|
|
*/
|
|
async sendRestoreNotification(type, details) {
|
|
try {
|
|
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
|
|
|
for (const admin of admins) {
|
|
if (type === 'success') {
|
|
await queueEmail(null, admin.email, 'restore_completed', {
|
|
restore_type: details.restoreType,
|
|
duration: `${details.duration} seconds`,
|
|
files_restored: details.filesRestored,
|
|
backup_id: details.backupId,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} else {
|
|
await queueEmail(null, admin.email, 'restore_failed', {
|
|
restore_type: details.restoreType,
|
|
error_message: details.error,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.log('error', 'Failed to send restore notification', { error: error.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get restore history
|
|
*/
|
|
async getRestoreHistory(limit = 10) {
|
|
return await db('restore_runs')
|
|
.orderBy('started_at', 'desc')
|
|
.limit(limit);
|
|
}
|
|
|
|
/**
|
|
* Generate restore report
|
|
*/
|
|
generateRestoreReport(restoreResult) {
|
|
const report = [];
|
|
|
|
report.push('=== RESTORE OPERATION REPORT ===');
|
|
report.push(`Status: ${restoreResult.success ? 'SUCCESS' : 'FAILED'}`);
|
|
report.push(`Duration: ${restoreResult.duration}s`);
|
|
report.push(`Dry Run: ${restoreResult.dryRun ? 'Yes' : 'No'}`);
|
|
|
|
if (restoreResult.result) {
|
|
report.push('\n--- Restore Results ---');
|
|
report.push(`Database Restored: ${restoreResult.result.databaseRestored ? 'Yes' : 'No'}`);
|
|
report.push(`Files Restored: ${restoreResult.result.filesRestored || 0}`);
|
|
if (restoreResult.result.errors && restoreResult.result.errors.length > 0) {
|
|
report.push(`Errors: ${restoreResult.result.errors.length}`);
|
|
restoreResult.result.errors.forEach(err => report.push(` - ${err}`));
|
|
}
|
|
}
|
|
|
|
if (restoreResult.verification) {
|
|
report.push('\n--- Verification Results ---');
|
|
report.push(`Valid: ${restoreResult.verification.isValid ? 'Yes' : 'No'}`);
|
|
if (restoreResult.verification.errors.length > 0) {
|
|
report.push('Errors:');
|
|
restoreResult.verification.errors.forEach(err => report.push(` - ${err}`));
|
|
}
|
|
}
|
|
|
|
if (restoreResult.preRestoreBackup) {
|
|
report.push('\n--- Safety Backup ---');
|
|
report.push(`Location: ${restoreResult.preRestoreBackup}`);
|
|
}
|
|
|
|
report.push('\n--- Operation Log ---');
|
|
restoreResult.logs.forEach(log => {
|
|
report.push(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`);
|
|
});
|
|
|
|
return report.join('\n');
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
const restoreService = new RestoreService();
|
|
|
|
module.exports = {
|
|
restoreService,
|
|
RestoreService, // Export class for testing
|
|
// Exposed for tests: the manifest `database.backup_file` containment +
|
|
// sqlite dot-command charset rules (GHSA-xfvx) are worth pinning directly.
|
|
_internal: {
|
|
getConfiguredBackupRoots,
|
|
isContainedInRoots,
|
|
assertSafeSqlitePath,
|
|
pathEscapes,
|
|
resolveContainedDbBackupCandidates,
|
|
},
|
|
}; |