* fix(external-media): store external paths from the media root (#1163) Importing a second folder into an event silently invalidated every photo already in it. photos.external_relpath was stored relative to events.external_path, and every import overwrites that column — so the older rows were rebased onto the new folder and their originals resolved to paths that do not exist. Nothing errored, and the grid still looked intact: thumbnails are written to local storage during the import while the base path is still correct. Only what needs the original broke — preview generation, the lightbox, downloads — which presents as a gallery that looks slow rather than one that is broken. The reporter had 7547 of 8004 rows pointing into the void and spent a while chasing it as a CPU problem. - external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is self-describing and nothing an admin does to the event afterwards can move an already-imported photo. - migration 187 folds each event's base path into its rows. Where the current resolution is missing on disk it walks up the base path for an ancestor under which the file IS there — the already-rebased case — and where it finds nothing it leaves the row resolving exactly where it resolves today. Skipped entirely when the media root is unmounted, since every file looks missing then. - the fold also runs after a .picpeak restore: knex_migrations is excluded from the archive, so a pre-#1163 backup would otherwise land base-relative rows on a migrated instance. - drops the duplicate-leaf-segment guess in photoResolver. It papered over this same double-prefixing and actively corrupts a root-relative path whose first segment legitimately repeats (base 'Trip', row 'Trip/x.jpg'). * fix(external-media): verify provenance and fold atomically (#1163) External review found four real defects in the fold. Repair could adopt the wrong file. Existence alone was accepted as proof that an ancestor candidate was the row's original — so a row whose file an admin simply deleted would adopt any same-named file one directory up (base `Trip/Sub`, relpath `photo.jpg`, an unrelated `Trip/photo.jpg`), and downloads would then serve a different photo. Worse than a dead link. An ancestor must now also match photos.size_bytes, which the import recorded from the very file the row describes; rows carrying no size are never repaired from an ancestor. The CURRENT base is still accepted on existence alone, because nothing is being inferred there — that is where the row already resolves. The fold was not atomic. Every UPDATE committed independently and the marker came last, so a process killed mid-fold left converted and unconverted rows with no marker — and the next run folded the converted ones a second time, putting every original one directory deeper with no undo. Probing is now a read-only first phase (so a slow cold NAS does not hold a write transaction open), and every rewrite plus the marker commit together. Failed rewrites certified a partial conversion. The per-row catch counted any error as a collision, carried on, and wrote the marker anyway — leaving that row in the old format for a resolver that now reads it differently. It also could not tell a genuine duplicate from a SQLite lock or I/O fault. Target collisions are now resolved in the planning phase, where they can be identified honestly, and a write that fails rolls the whole fold back. Restore ordering. The fold ran after the face requeue, with the worker live — so a worker could claim an external row while it was still base-relative, resolve it against the wrong path, and burn it to 'failed', a state only an explicit Re-scan clears. The fold now runs first, for the same reason the requeue already sat after restoreFiles. * fix(external-media): close the fold's remaining stranding paths (#1163) Second review round, three findings. A collision loser was left stranded. When an event imported one file through both `Trip` and `Trip/Sub`, two rows folded to the same path and the loser was skipped — keeping a base-relative value that the root-only resolver then reads as `<root>/<relpath>`, permanently wrong, with the marker claiming conversion was complete. It is a duplicate by construction, so it now goes through migration 186's deleteDuplicatePhotos, which reparents its feedback and marks and reconciles the face clusters instead of orphaning them. This branch is rebased onto #1162 for that helper. The other restore path had the same face-ordering bug. restoreService queued face scans in step 6, before step 7c runs pending migrations — so a pre-187 full or database restore handed the live worker rows whose paths were still event-relative, and it burned them to 'failed', a state the later fold does not clear. The requeue now happens after the migrations, where the files already are. A failed conversion was reported as a clean restore. The fold is transactional, so a failure leaves every external path in the old format under a resolver that reads from the media root — every original unreachable. It was logged as a warning and the restore returned success. It now returns externalPathsConverted/externalPathError, and suppresses the face requeue, which would otherwise mark those photos failed on top. * fix(external-media): make the fold safe against its own intermediate states (#1163) Third review round, four findings. A one-pass rewrite could collide with itself. Every FINAL path is distinct, but a final value can equal another row's CURRENT one — `photo.jpg` repairing to `Trip/photo.jpg` while the row already holding `Trip/photo.jpg` folds deeper — so the update violated migration 186's unique index halfway through. On Postgres that surfaces as 23505, which run-migrations-safe.js mistakes for "schema already exists" and records 187 as applied after the rollback, leaving every path unconverted with nothing to retry. Rows now park on a per-row staging value first, and migration 187 re-throws without the driver's code so the runner cannot misread it. The bulk update targeted rows the plan never saw. Phase 1 probes outside the transaction and can run for minutes; an import finishing in that window inserts an already root-relative row, and `where event_id` prefixed it again with the stale base. It now updates by the ids phase 1 captured. The restore UI never showed a conversion failure. The API carried externalPathsConverted, but PicpeakBackupCard neither declared nor read it and showed a green success either way — so an admin whose external originals were all unreachable was told the restore worked. restoreService requeued faces even when the migrations failed. The step 7c catch is deliberately non-fatal, so a pre-187 backup whose fold never ran still handed the live worker event-relative paths to burn to 'failed'. * fix(external-media): the fold's staging value must be storable on Postgres (#1163) External review of the stable twin caught this, and it was on both branches. The two-pass rewrite parks each row on a temporary value, and that value was written with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00" — so migration 187 rolled back on exactly the installs that need the two-pass repair, and only on the engine most of them run. Restores hit the same wall and reported the conversion as failed. The prefix is ordinary text now. It still cannot collide with a real relative path and is still obviously wrong if a crash leaves one behind. Adds a gated Postgres test alongside the existing picpeakRestorePg one, because a SQLite-only suite structurally cannot catch this class: restoring the NUL makes exactly the two-pass repair case fail with that error, and nothing else. --------- Co-authored-by: Paul Nothaft <[email protected]>
1166 lines
40 KiB
JavaScript
1166 lines
40 KiB
JavaScript
const express = require('express');
|
|
const { db } = require('../database/db');
|
|
const { adminAuth } = require('../middleware/auth');
|
|
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
|
|
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
|
const { revokeToken } = require('../utils/tokenRevocation');
|
|
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
|
const logger = require('../utils/logger');
|
|
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
|
const { formatBytes } = require('../utils/formatBytes');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const archiver = require('archiver');
|
|
const S3StorageAdapter = require('../services/storage/s3Storage');
|
|
|
|
const router = express.Router();
|
|
|
|
// Get backup configuration
|
|
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const settings = await db('app_settings')
|
|
.where('setting_type', 'backup')
|
|
.select('setting_key', 'setting_value');
|
|
|
|
const config = {};
|
|
settings.forEach(setting => {
|
|
try {
|
|
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
|
} catch (e) {
|
|
config[setting.setting_key] = setting.setting_value;
|
|
}
|
|
});
|
|
|
|
// Never return the stored credentials — mask like the email/WhatsApp
|
|
// config endpoints do. The PUT below skips the mask sentinel, so the
|
|
// form round-trips without clobbering the real values.
|
|
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
|
|
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
|
|
|
|
res.json(config);
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to get backup configuration');
|
|
}
|
|
});
|
|
|
|
// Update backup configuration
|
|
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
|
try {
|
|
const updates = req.body;
|
|
|
|
// Validate required fields based on destination type
|
|
if (updates.backup_destination_type) {
|
|
switch (updates.backup_destination_type) {
|
|
case 'local':
|
|
if (!updates.backup_destination_path) {
|
|
return res.status(400).json({ error: 'Local backup requires destination path' });
|
|
}
|
|
break;
|
|
case 'rsync':
|
|
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
|
|
return res.status(400).json({ error: 'Rsync backup requires host and path' });
|
|
}
|
|
break;
|
|
case 's3':
|
|
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
|
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
|
|
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// SSRF: validate an S3 endpoint whenever one is supplied — NOT only when
|
|
// the payload also flips backup_destination_type to 's3'. The PUT
|
|
// persists every backup_* field independently, so with S3 already
|
|
// selected a caller could PATCH just backup_s3_endpoint to a
|
|
// private-resolving host; the management ops (manifest, bucket/file
|
|
// browse, cleanup, test-upload) then connect without going through
|
|
// testConnection. Prod-only; dev points at localhost MinIO deliberately.
|
|
if (process.env.NODE_ENV === 'production'
|
|
&& updates.backup_s3_endpoint && updates.backup_s3_endpoint !== '••••••••') {
|
|
const rawEndpoint = updates.backup_s3_endpoint;
|
|
const withProto = /^https?:\/\//.test(rawEndpoint) ? rawEndpoint : `https://${rawEndpoint}`;
|
|
let epHost = null;
|
|
try { epHost = new URL(withProto).hostname; } catch { epHost = null; }
|
|
const { isHostAllowed } = require('../utils/networkValidation');
|
|
if (!epHost || !(await isHostAllowed(epHost))) {
|
|
return res.status(400).json({ error: 'S3 endpoint resolves to a private or internal network address' });
|
|
}
|
|
}
|
|
|
|
// Update settings
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
// An unchanged secret round-trips as the GET mask sentinel — keep the
|
|
// stored value instead of overwriting it with bullets.
|
|
if (value === '••••••••') {
|
|
continue;
|
|
}
|
|
if (key.startsWith('backup_')) {
|
|
await db('app_settings')
|
|
.insert({
|
|
setting_key: key,
|
|
setting_value: JSON.stringify(value),
|
|
setting_type: 'backup',
|
|
updated_at: new Date()
|
|
})
|
|
.onConflict('setting_key')
|
|
.merge({
|
|
setting_value: JSON.stringify(value),
|
|
updated_at: new Date()
|
|
});
|
|
}
|
|
}
|
|
|
|
// Restart backup service if enabled status changed
|
|
if ('backup_enabled' in updates) {
|
|
const { startBackupService, stopBackupService } = require('../services/backupService');
|
|
if (updates.backup_enabled) {
|
|
await startBackupService();
|
|
} else {
|
|
stopBackupService();
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, message: 'Backup configuration updated' });
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to update backup configuration');
|
|
}
|
|
});
|
|
|
|
// Get backup status and history
|
|
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { limit } = getPagination(req, { limit: 10 });
|
|
const status = await getBackupStatus(limit);
|
|
|
|
res.json(status);
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to get backup status');
|
|
}
|
|
});
|
|
|
|
// Trigger manual backup
|
|
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
|
try {
|
|
// Check if backup is already running
|
|
const status = await getBackupStatus();
|
|
if (status.isRunning) {
|
|
return res.status(409).json({ error: 'Backup is already running' });
|
|
}
|
|
|
|
// Start backup in background
|
|
triggerManualBackup().catch(error => {
|
|
logger.error('Manual backup failed:', error);
|
|
});
|
|
|
|
res.json({ success: true, message: 'Backup started' });
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to trigger backup');
|
|
}
|
|
});
|
|
|
|
// Generate + download a portable ".picpeak" export — an engine-neutral logical
|
|
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
|
|
// another instance via the web UI. `?includePhotos=true` also bundles original
|
|
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
|
|
//
|
|
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
|
// hashes, API keys). The download UI must warn before offering it. We surface
|
|
// the flag as a response header too so the client can double-confirm.
|
|
// Full-instance export dumps every table unredacted — bcrypt password
|
|
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
|
|
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
|
|
// but is denied this data everywhere else (config APIs mask secrets as
|
|
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
|
|
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
|
|
const fsSync = require('fs');
|
|
try {
|
|
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
|
const { createPicpeak } = require('../services/picpeakExportService');
|
|
const { filePath } = await createPicpeak({ includePhotos });
|
|
const filename = path.basename(filePath);
|
|
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
|
|
res.download(filePath, filename, (err) => {
|
|
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
|
|
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
|
|
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
|
|
});
|
|
} catch (error) {
|
|
logger.error('[picpeak-export] failed to create export', { error: error.message });
|
|
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
|
|
}
|
|
});
|
|
|
|
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
|
|
// auth so an unauthenticated request can't push a large file to disk.
|
|
const os = require('os');
|
|
const multer = require('multer');
|
|
const picpeakUpload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
|
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
|
}),
|
|
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
|
});
|
|
|
|
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
|
// all data except the current logged-in account (the client shows an explicit
|
|
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
|
|
// prompt the admin to reconfigure the external-media mount afterwards.
|
|
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
|
|
const fsSync = require('fs');
|
|
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
|
|
const picpeakPath = req.file.path;
|
|
try {
|
|
const { importFromPicpeak } = require('../services/picpeakImportService');
|
|
// adminAuth populates req.admin, not req.user. Passing req.user.id here
|
|
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
|
|
// to preserve and the admin_users table was fully replaced by the backup —
|
|
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
|
|
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
|
|
|
|
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
|
|
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
|
|
// every JWT issued before the restore — admin, customer, gallery — now fails
|
|
// auth. Here we additionally give the importing admin an immediate, clean
|
|
// logout: revoke this token and clear the cookie so their browser drops the
|
|
// session at once rather than on the next 401. Cookie clear is the
|
|
// unconditional guarantee; revokeToken() swallows DB errors and returns
|
|
// false, so check the result and log loudly if the denylist write didn't
|
|
// land (the operator still re-logs-in, which the cookie clear forces).
|
|
let tokenRevoked = false;
|
|
try {
|
|
if (req.token) {
|
|
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
|
|
}
|
|
} catch (revokeErr) {
|
|
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
|
|
}
|
|
if (req.token && !tokenRevoked) {
|
|
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
|
|
}
|
|
clearAdminAuthCookie(res);
|
|
|
|
res.json({
|
|
success: true,
|
|
tables: result.tables,
|
|
filesRestored: result.filesRestored,
|
|
usesExternalMedia: result.usesExternalMedia,
|
|
crossEngine: result.crossEngine,
|
|
// False when the pre-#1163 external-path conversion failed. The rows and
|
|
// files are in place, but no external original resolves until it is
|
|
// retried — the UI must say so rather than showing a plain success.
|
|
externalPathsConverted: result.externalPathsConverted !== false,
|
|
externalPathError: result.externalPathError || null,
|
|
sessionInvalidated: true,
|
|
});
|
|
} catch (error) {
|
|
const status = error.statusCode || 500;
|
|
logger.error('[picpeak-import] restore failed', { error: error.message });
|
|
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
|
|
} finally {
|
|
fsSync.unlink(picpeakPath, () => {});
|
|
}
|
|
});
|
|
|
|
// Get backup run details
|
|
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const run = await db('backup_runs')
|
|
.where('id', id)
|
|
.first();
|
|
|
|
if (!run) {
|
|
return res.status(404).json({ error: 'Backup run not found' });
|
|
}
|
|
|
|
// Parse JSON fields
|
|
if (run.statistics) {
|
|
try {
|
|
run.statistics = JSON.parse(run.statistics);
|
|
} catch (e) {
|
|
// Keep as string if parsing fails
|
|
}
|
|
}
|
|
|
|
res.json(run);
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to get backup run details');
|
|
}
|
|
});
|
|
|
|
// Get file states (for debugging/monitoring)
|
|
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { page = 1, limit = 50, search = '' } = req.query;
|
|
const offset = (page - 1) * limit;
|
|
|
|
let query = db('backup_file_states');
|
|
|
|
if (search) {
|
|
query = query.where('file_path', 'like', `%${search}%`);
|
|
}
|
|
|
|
const [files, totalCount] = await Promise.all([
|
|
query
|
|
.orderBy('last_backed_up', 'desc')
|
|
.limit(limit)
|
|
.offset(offset),
|
|
db('backup_file_states').count('* as count').first()
|
|
]);
|
|
|
|
res.json({
|
|
files,
|
|
pagination: {
|
|
page: parseInt(page),
|
|
limit: parseInt(limit),
|
|
total: totalCount.count,
|
|
pages: Math.ceil(totalCount.count / limit)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to get file states');
|
|
}
|
|
});
|
|
|
|
// Clean up old backup runs
|
|
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
|
try {
|
|
const { days = 30 } = req.body;
|
|
|
|
await cleanupOldBackupRuns(days);
|
|
|
|
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to cleanup backup runs');
|
|
}
|
|
});
|
|
|
|
// Test backup destination connectivity
|
|
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
|
try {
|
|
const { destination_type, ...config } = req.body;
|
|
|
|
switch (destination_type) {
|
|
case 'local':
|
|
// Test local path access
|
|
const fs = require('fs').promises;
|
|
try {
|
|
await fs.access(config.path, fs.constants.W_OK);
|
|
res.json({ success: true, message: 'Local path is writable' });
|
|
} catch (error) {
|
|
logger.warn('Local backup path not writable', {
|
|
path: config.path,
|
|
error: error.message
|
|
});
|
|
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
|
}
|
|
break;
|
|
|
|
case 'rsync':
|
|
// Test rsync connection using spawn with argument arrays to prevent command injection
|
|
const { spawn } = require('child_process');
|
|
|
|
// Validate and sanitize inputs to prevent command injection
|
|
const sanitizeInput = (input) => {
|
|
if (!input || typeof input !== 'string') return null;
|
|
// Remove any shell metacharacters and limit length
|
|
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
|
};
|
|
|
|
const host = sanitizeInput(config.host);
|
|
const user = sanitizeInput(config.user);
|
|
const sshKeyPath = sanitizeInput(config.ssh_key);
|
|
|
|
if (!host) {
|
|
res.json({ success: false, message: 'Invalid host specified' });
|
|
break;
|
|
}
|
|
|
|
// Validate host format (hostname or IP only)
|
|
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
|
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
|
res.json({ success: false, message: 'Invalid host format' });
|
|
break;
|
|
}
|
|
|
|
// SSRF protection: resolve the host and block any private/internal
|
|
// address. ssh does its own DNS at connect time, so a literal-only
|
|
// check let a hostname resolving to an internal IP through (#GHSA-4jh8).
|
|
const { isHostAllowed } = require('../utils/networkValidation');
|
|
if (!(await isHostAllowed(host))) {
|
|
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
|
break;
|
|
}
|
|
|
|
// Validate username format if provided
|
|
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
|
res.json({ success: false, message: 'Invalid username format' });
|
|
break;
|
|
}
|
|
|
|
// Build SSH arguments as array (safe from injection)
|
|
const sshArgs = [];
|
|
if (sshKeyPath) {
|
|
// Validate SSH key path exists and is a file
|
|
const fsSync = require('fs');
|
|
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
|
res.json({ success: false, message: 'SSH key file not found' });
|
|
break;
|
|
}
|
|
sshArgs.push('-i', sshKeyPath);
|
|
}
|
|
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
|
sshArgs.push('-o', 'ConnectTimeout=10');
|
|
sshArgs.push('-o', 'BatchMode=yes');
|
|
|
|
// Add target (user@host or just host)
|
|
const target = user ? `${user}@${host}` : host;
|
|
sshArgs.push(target);
|
|
sshArgs.push('echo', 'Connection successful');
|
|
|
|
try {
|
|
const result = await new Promise((resolve, reject) => {
|
|
const sshProcess = spawn('ssh', sshArgs, {
|
|
timeout: 15000,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
|
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
|
|
|
sshProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ success: true, stdout });
|
|
} else {
|
|
reject(new Error(stderr || `SSH exited with code ${code}`));
|
|
}
|
|
});
|
|
|
|
sshProcess.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
res.json({ success: true, message: 'Rsync connection successful' });
|
|
} catch (error) {
|
|
logger.warn('Rsync connection test failed', {
|
|
destination: host,
|
|
error: error.message
|
|
});
|
|
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
|
}
|
|
break;
|
|
|
|
case 's3':
|
|
// Test S3 connection (would need AWS SDK)
|
|
res.json({ success: false, message: 'S3 testing not implemented yet' });
|
|
break;
|
|
|
|
default:
|
|
res.status(400).json({ error: 'Invalid destination type' });
|
|
}
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to test connection');
|
|
}
|
|
});
|
|
|
|
// Get backup manifest for a specific backup run
|
|
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { backupRunId } = req.params;
|
|
const result = await getBackupManifest(backupRunId);
|
|
|
|
res.json({
|
|
backupRunId,
|
|
manifest: result.manifest,
|
|
summary: result.summary
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to get backup manifest:', error);
|
|
res.status(404).json({ error: 'Backup manifest not found' });
|
|
}
|
|
});
|
|
|
|
// Validate a backup manifest
|
|
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { manifestPath } = req.body;
|
|
|
|
if (!manifestPath) {
|
|
return res.status(400).json({ error: 'manifestPath is required' });
|
|
}
|
|
|
|
// Prevent path traversal — manifest must be within backup directory
|
|
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
|
const safePath = safePathJoin(backupBasePath, manifestPath);
|
|
|
|
const result = await validateBackupManifest(safePath);
|
|
|
|
res.json({
|
|
valid: result.valid,
|
|
error: result.error,
|
|
manifestPath
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to validate manifest');
|
|
}
|
|
});
|
|
|
|
// Download backup manifest
|
|
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { backupRunId } = req.params;
|
|
const { format = 'json' } = req.query;
|
|
|
|
const result = await getBackupManifest(backupRunId);
|
|
|
|
// Set appropriate headers
|
|
const filename = `backup-manifest-${backupRunId}.${format}`;
|
|
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
|
|
// Send the manifest in requested format
|
|
if (format === 'yaml') {
|
|
const yaml = require('js-yaml');
|
|
res.send(yaml.dump(result.manifest, {
|
|
indent: 2,
|
|
lineWidth: -1,
|
|
noRefs: true,
|
|
sortKeys: true
|
|
}));
|
|
} else {
|
|
res.json(result.manifest);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to download backup manifest:', error);
|
|
res.status(404).json({ error: 'Backup manifest not found' });
|
|
}
|
|
});
|
|
|
|
// Get manifest for specific backup
|
|
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { backupId } = req.params;
|
|
const result = await getBackupManifest(backupId);
|
|
|
|
res.json({
|
|
backupId,
|
|
manifest: result.manifest,
|
|
summary: result.summary
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to get backup manifest:', error);
|
|
res.status(404).json({ error: 'Backup manifest not found' });
|
|
}
|
|
});
|
|
|
|
// Download manifest file
|
|
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { backupId } = req.params;
|
|
const { format = 'json' } = req.query;
|
|
|
|
const result = await getBackupManifest(backupId);
|
|
|
|
// Set appropriate headers
|
|
const filename = `backup-manifest-${backupId}.${format}`;
|
|
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
|
|
// Send the manifest in requested format
|
|
if (format === 'yaml') {
|
|
const yaml = require('js-yaml');
|
|
res.send(yaml.dump(result.manifest, {
|
|
indent: 2,
|
|
lineWidth: -1,
|
|
noRefs: true,
|
|
sortKeys: true
|
|
}));
|
|
} else {
|
|
res.json(result.manifest);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to download backup manifest:', error);
|
|
res.status(404).json({ error: 'Backup manifest not found' });
|
|
}
|
|
});
|
|
|
|
// Validate a manifest
|
|
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { manifestPath, manifestData } = req.body;
|
|
|
|
if (!manifestPath && !manifestData) {
|
|
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
|
|
}
|
|
|
|
if (manifestData) {
|
|
// Validate provided manifest data directly
|
|
const validationResult = await validateManifestData(manifestData);
|
|
return res.json(validationResult);
|
|
}
|
|
|
|
// Prevent path traversal — manifest must be within backup directory
|
|
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
|
const safePath = safePathJoin(backupBasePath, manifestPath);
|
|
|
|
// Use existing validation function for path
|
|
const result = await validateBackupManifest(safePath);
|
|
|
|
res.json({
|
|
valid: result.valid,
|
|
error: result.error,
|
|
manifestPath
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to validate manifest');
|
|
}
|
|
});
|
|
|
|
// List S3 buckets
|
|
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const config = await getBackupConfig();
|
|
|
|
if (config.backup_destination_type !== 's3') {
|
|
return res.status(400).json({ error: 'S3 backup not configured' });
|
|
}
|
|
|
|
const s3Adapter = new S3StorageAdapter({
|
|
endpoint: config.backup_s3_endpoint,
|
|
bucket: config.backup_s3_bucket,
|
|
accessKeyId: config.backup_s3_access_key,
|
|
secretAccessKey: config.backup_s3_secret_key,
|
|
region: config.backup_s3_region || 'us-east-1',
|
|
forcePathStyle: config.backup_s3_force_path_style || false
|
|
});
|
|
|
|
// List buckets using the S3 client
|
|
const { ListBucketsCommand } = require('@aws-sdk/client-s3');
|
|
const result = await s3Adapter.s3Client.send(new ListBucketsCommand({}));
|
|
|
|
res.json({
|
|
buckets: result.Buckets || [],
|
|
owner: result.Owner || null
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to list S3 buckets');
|
|
}
|
|
});
|
|
|
|
// List files in S3 backup location
|
|
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
|
|
const config = await getBackupConfig();
|
|
|
|
if (config.backup_destination_type !== 's3') {
|
|
return res.status(400).json({ error: 'S3 backup not configured' });
|
|
}
|
|
|
|
const s3Adapter = new S3StorageAdapter({
|
|
endpoint: config.backup_s3_endpoint,
|
|
bucket: config.backup_s3_bucket,
|
|
accessKeyId: config.backup_s3_access_key,
|
|
secretAccessKey: config.backup_s3_secret_key,
|
|
region: config.backup_s3_region || 'us-east-1',
|
|
forcePathStyle: config.backup_s3_force_path_style || false
|
|
});
|
|
|
|
const result = await s3Adapter.list(prefix, {
|
|
maxKeys: parseInt(maxKeys),
|
|
continuationToken
|
|
});
|
|
|
|
res.json({
|
|
files: result.objects || [],
|
|
directories: result.directories || [],
|
|
isTruncated: result.isTruncated,
|
|
nextContinuationToken: result.nextContinuationToken,
|
|
prefix: prefix
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to list S3 files');
|
|
}
|
|
});
|
|
|
|
// Clean up old S3 backups
|
|
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
|
try {
|
|
const { retentionDays = 30, dryRun = false } = req.body;
|
|
const config = await getBackupConfig();
|
|
|
|
if (config.backup_destination_type !== 's3') {
|
|
return res.status(400).json({ error: 'S3 backup not configured' });
|
|
}
|
|
|
|
const s3Adapter = new S3StorageAdapter({
|
|
endpoint: config.backup_s3_endpoint,
|
|
bucket: config.backup_s3_bucket,
|
|
accessKeyId: config.backup_s3_access_key,
|
|
secretAccessKey: config.backup_s3_secret_key,
|
|
region: config.backup_s3_region || 'us-east-1',
|
|
forcePathStyle: config.backup_s3_force_path_style || false
|
|
});
|
|
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
|
|
// List all backup files
|
|
const backupFiles = await s3Adapter.list('backups/', { maxKeys: 1000 });
|
|
const filesToDelete = [];
|
|
let totalSize = 0;
|
|
|
|
for (const file of backupFiles.objects || []) {
|
|
if (file.lastModified && new Date(file.lastModified) < cutoffDate) {
|
|
filesToDelete.push(file.key);
|
|
totalSize += file.size || 0;
|
|
}
|
|
}
|
|
|
|
if (dryRun) {
|
|
return res.json({
|
|
wouldDelete: filesToDelete.length,
|
|
totalSize: totalSize,
|
|
files: filesToDelete.slice(0, 100), // Limit preview
|
|
message: 'Dry run completed - no files were deleted'
|
|
});
|
|
}
|
|
|
|
// Delete files in batches
|
|
const deleteResult = await s3Adapter.deleteMany(filesToDelete);
|
|
const deletedCount = deleteResult.Deleted ? deleteResult.Deleted.length : 0;
|
|
|
|
// Also clean up database records
|
|
await cleanupOldBackupRuns(retentionDays);
|
|
|
|
res.json({
|
|
success: true,
|
|
deletedCount: deletedCount,
|
|
totalSize: totalSize,
|
|
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to cleanup S3 backups');
|
|
}
|
|
});
|
|
|
|
// Test S3 upload functionality
|
|
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
|
try {
|
|
const config = await getBackupConfig();
|
|
|
|
if (config.backup_destination_type !== 's3') {
|
|
return res.status(400).json({ error: 'S3 backup not configured' });
|
|
}
|
|
|
|
const s3Adapter = new S3StorageAdapter({
|
|
endpoint: config.backup_s3_endpoint,
|
|
bucket: config.backup_s3_bucket,
|
|
accessKeyId: config.backup_s3_access_key,
|
|
secretAccessKey: config.backup_s3_secret_key,
|
|
region: config.backup_s3_region || 'us-east-1',
|
|
forcePathStyle: config.backup_s3_force_path_style || false
|
|
});
|
|
|
|
// Create test content
|
|
const testKey = `test/backup-test-${Date.now()}.txt`;
|
|
const testContent = `PicPeak S3 backup test\nTimestamp: ${new Date().toISOString()}\nEndpoint: ${config.backup_s3_endpoint || 'AWS'}\nBucket: ${config.backup_s3_bucket}`;
|
|
|
|
// Test upload
|
|
const uploadStart = Date.now();
|
|
await s3Adapter.upload(testKey, Buffer.from(testContent));
|
|
const uploadTime = Date.now() - uploadStart;
|
|
|
|
// Test download
|
|
const downloadStart = Date.now();
|
|
const downloadedContent = await s3Adapter.download(testKey);
|
|
const downloadTime = Date.now() - downloadStart;
|
|
|
|
// Verify content
|
|
const contentMatch = downloadedContent.toString() === testContent;
|
|
|
|
// Test deletion
|
|
await s3Adapter.delete(testKey);
|
|
|
|
res.json({
|
|
success: true,
|
|
testKey: testKey,
|
|
uploadTime: uploadTime,
|
|
downloadTime: downloadTime,
|
|
contentMatch: contentMatch,
|
|
message: 'S3 upload test completed successfully'
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'S3 upload test failed');
|
|
}
|
|
});
|
|
|
|
// Download entire backup
|
|
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { backupId } = req.params;
|
|
|
|
// Get backup run details
|
|
const backupRun = await db('backup_runs')
|
|
.where('id', backupId)
|
|
.first();
|
|
|
|
if (!backupRun) {
|
|
return res.status(404).json({ error: 'Backup not found' });
|
|
}
|
|
|
|
if (backupRun.status !== 'completed') {
|
|
return res.status(400).json({ error: 'Backup is not completed' });
|
|
}
|
|
|
|
const config = await getBackupConfig();
|
|
|
|
// Handle different backup types
|
|
switch (config.backup_destination_type) {
|
|
case 'local':
|
|
// Stream local backup as zip
|
|
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
|
|
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
|
|
archive.pipe(res);
|
|
|
|
// Add backup directory contents
|
|
archive.directory(backupPath, false);
|
|
|
|
// Add manifest if exists
|
|
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
|
|
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
|
|
}
|
|
|
|
await archive.finalize();
|
|
break;
|
|
|
|
case 's3':
|
|
// For S3, provide pre-signed URLs or stream files
|
|
const s3Adapter = new S3StorageAdapter({
|
|
endpoint: config.backup_s3_endpoint,
|
|
bucket: config.backup_s3_bucket,
|
|
accessKeyId: config.backup_s3_access_key,
|
|
secretAccessKey: config.backup_s3_secret_key,
|
|
region: config.backup_s3_region || 'us-east-1',
|
|
forcePathStyle: config.backup_s3_force_path_style || false
|
|
});
|
|
|
|
// List all files for this backup
|
|
const prefix = `backups/${backupRun.id}/`;
|
|
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
|
|
|
|
// Generate pre-signed URLs
|
|
const urls = [];
|
|
for (const file of files.objects || []) {
|
|
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
|
|
urls.push({
|
|
key: file.key,
|
|
size: file.size,
|
|
url: url
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
backupId: backupRun.id,
|
|
type: 's3',
|
|
files: urls,
|
|
expiresIn: 3600,
|
|
message: 'Use the provided URLs to download individual files'
|
|
});
|
|
break;
|
|
|
|
case 'rsync':
|
|
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
|
|
|
default:
|
|
return res.status(400).json({ error: 'Unknown backup type' });
|
|
}
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to download backup');
|
|
}
|
|
});
|
|
|
|
// Get current file checksums
|
|
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { path: targetPath = '', recursive = true } = req.query;
|
|
const checksums = {};
|
|
|
|
// Get storage path
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
let basePath = storagePath;
|
|
if (targetPath) {
|
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
|
basePath = safePathJoin(storagePath, targetPath);
|
|
}
|
|
|
|
// Calculate checksums for files
|
|
async function calculateDirChecksums(dirPath, relative = '') {
|
|
try {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
const relativePath = path.join(relative, entry.name);
|
|
|
|
if (entry.isDirectory() && recursive) {
|
|
await calculateDirChecksums(fullPath, relativePath);
|
|
} else if (entry.isFile()) {
|
|
const hash = crypto.createHash('sha256');
|
|
const stream = require('fs').createReadStream(fullPath);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
stream.on('data', data => hash.update(data));
|
|
stream.on('end', () => {
|
|
checksums[relativePath] = {
|
|
checksum: hash.digest('hex'),
|
|
size: entry.size,
|
|
modified: entry.mtime
|
|
};
|
|
resolve();
|
|
});
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
|
|
}
|
|
}
|
|
|
|
await calculateDirChecksums(basePath);
|
|
|
|
// Also get database checksums from backup_file_states
|
|
const dbChecksums = await db('backup_file_states')
|
|
.select('file_path', 'checksum', 'size_bytes', 'last_modified');
|
|
|
|
res.json({
|
|
currentChecksums: checksums,
|
|
totalFiles: Object.keys(checksums).length,
|
|
databaseChecksums: dbChecksums.reduce((acc, row) => {
|
|
acc[row.file_path] = {
|
|
checksum: row.checksum,
|
|
size: row.size_bytes,
|
|
modified: row.last_modified
|
|
};
|
|
return acc;
|
|
}, {}),
|
|
path: targetPath || '/'
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to get file checksums');
|
|
}
|
|
});
|
|
|
|
// Estimate backup size before running
|
|
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
|
try {
|
|
const { includeArchived = true } = req.body;
|
|
|
|
// Get storage path
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
let totalSize = 0;
|
|
let fileCount = 0;
|
|
const breakdown = {};
|
|
|
|
// Estimate size for each directory
|
|
async function estimateDir(dirPath, category) {
|
|
let dirSize = 0;
|
|
let dirCount = 0;
|
|
|
|
try {
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
const subResult = await estimateDir(fullPath, category);
|
|
dirSize += subResult.size;
|
|
dirCount += subResult.count;
|
|
} else if (entry.isFile()) {
|
|
const stats = await fs.stat(fullPath);
|
|
dirSize += stats.size;
|
|
dirCount++;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
logger.error(`Failed to estimate ${dirPath}:`, error);
|
|
}
|
|
}
|
|
|
|
return { size: dirSize, count: dirCount };
|
|
}
|
|
|
|
// Estimate each category
|
|
const categories = [
|
|
{ path: 'events/active', name: 'Active Events' },
|
|
{ path: 'thumbnails', name: 'Thumbnails' },
|
|
{ path: 'uploads', name: 'Uploads' }
|
|
];
|
|
|
|
if (includeArchived) {
|
|
categories.push({ path: 'events/archived', name: 'Archived Events' });
|
|
}
|
|
|
|
for (const category of categories) {
|
|
const result = await estimateDir(path.join(storagePath, category.path), category.name);
|
|
breakdown[category.name] = {
|
|
size: result.size,
|
|
sizeFormatted: formatBytes(result.size),
|
|
fileCount: result.count
|
|
};
|
|
totalSize += result.size;
|
|
fileCount += result.count;
|
|
}
|
|
|
|
// Estimate database size
|
|
const dbPath = process.env.DB_TYPE === 'postgresql'
|
|
? null
|
|
: path.join(__dirname, '../../database.sqlite');
|
|
|
|
if (dbPath) {
|
|
try {
|
|
const dbStats = await fs.stat(dbPath);
|
|
breakdown['Database'] = {
|
|
size: dbStats.size,
|
|
sizeFormatted: formatBytes(dbStats.size),
|
|
fileCount: 1
|
|
};
|
|
totalSize += dbStats.size;
|
|
fileCount += 1;
|
|
} catch (error) {
|
|
logger.error('Failed to get database size:', error);
|
|
}
|
|
}
|
|
|
|
// Estimate compression ratio (typically 20-40% for mixed media)
|
|
const estimatedCompressedSize = Math.round(totalSize * 0.7);
|
|
|
|
res.json({
|
|
totalSize: totalSize,
|
|
totalSizeFormatted: formatBytes(totalSize),
|
|
estimatedCompressedSize: estimatedCompressedSize,
|
|
estimatedCompressedSizeFormatted: formatBytes(estimatedCompressedSize),
|
|
fileCount: fileCount,
|
|
breakdown: breakdown,
|
|
includeArchived: includeArchived,
|
|
estimatedDuration: Math.max(60, Math.round(totalSize / (50 * 1024 * 1024))), // Estimate 50MB/s
|
|
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
|
|
});
|
|
} catch (error) {
|
|
errorResponse(res, error, 500, 'Failed to estimate backup size');
|
|
}
|
|
});
|
|
|
|
// Helper function to format bytes
|
|
// Helper function to get backup configuration
|
|
async function getBackupConfig() {
|
|
try {
|
|
const settings = await db('app_settings')
|
|
.where('setting_type', 'backup')
|
|
.select('setting_key', 'setting_value');
|
|
|
|
const config = {};
|
|
settings.forEach(setting => {
|
|
try {
|
|
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
|
} catch (e) {
|
|
config[setting.setting_key] = setting.setting_value;
|
|
}
|
|
});
|
|
|
|
return config;
|
|
} catch (error) {
|
|
logger.error('Failed to get backup configuration:', error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Helper function to validate manifest data
|
|
async function validateManifestData(manifestData) {
|
|
try {
|
|
// Check required fields
|
|
const requiredFields = ['version', 'backupId', 'timestamp', 'files'];
|
|
const missingFields = requiredFields.filter(field => !manifestData[field]);
|
|
|
|
if (missingFields.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: `Missing required fields: ${missingFields.join(', ')}`,
|
|
details: { missingFields }
|
|
};
|
|
}
|
|
|
|
// Validate version
|
|
if (manifestData.version !== '1.0') {
|
|
return {
|
|
valid: false,
|
|
error: `Unsupported manifest version: ${manifestData.version}`,
|
|
details: { version: manifestData.version }
|
|
};
|
|
}
|
|
|
|
// Validate files array
|
|
if (!Array.isArray(manifestData.files)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Files must be an array',
|
|
details: { filesType: typeof manifestData.files }
|
|
};
|
|
}
|
|
|
|
// Validate each file entry
|
|
const invalidFiles = [];
|
|
for (let i = 0; i < manifestData.files.length; i++) {
|
|
const file = manifestData.files[i];
|
|
if (!file.path || !file.checksum || typeof file.size !== 'number') {
|
|
invalidFiles.push({ index: i, file });
|
|
}
|
|
}
|
|
|
|
if (invalidFiles.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: `Invalid file entries: ${invalidFiles.length}`,
|
|
details: { invalidFiles: invalidFiles.slice(0, 10) } // Limit to first 10
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
details: {
|
|
version: manifestData.version,
|
|
backupId: manifestData.backupId,
|
|
timestamp: manifestData.timestamp,
|
|
fileCount: manifestData.files.length,
|
|
totalSize: manifestData.files.reduce((sum, f) => sum + (f.size || 0), 0)
|
|
}
|
|
};
|
|
} catch (error) {
|
|
logger.error('Manifest validation error', { error: error.message });
|
|
return {
|
|
valid: false,
|
|
error: 'Validation error encountered while processing manifest',
|
|
details: { hint: 'See server logs for diagnostic details.' }
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = router;
|