Files
picpeak/backend/src/services/backupService.js
T
Paul Nothaft a27c705e39 fix(backup): make backup settings actually apply (#871) (stable) (#875)
* fix(backup): make backup settings actually apply (#871) (stable)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

* fix(backup): close the review gaps in the settings wiring (stable)

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope. (stable)

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge. (stable)
2026-07-27 09:06:50 +02:00

1698 lines
60 KiB
JavaScript

const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const crypto = require('crypto');
const childProcess = require('child_process');
const os = require('os');
const { promisify } = require('util');
const cron = require('node-cron');
const cronParser = require('cron-parser');
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatBytes } = require('../utils/formatBytes');
const { formatBoolean } = require('../utils/dbCompat');
const backupManifest = require('./backupManifest');
const S3StorageAdapter = require('./storage/s3Storage');
const packageJson = require('../../package.json');
const service = {};
let backupJob = null;
let isRunning = false;
function ensureMockableExec() {
const current = childProcess.exec;
if (current && typeof current === 'function' && current._isMockFunction) {
return;
}
const original = current ? current.bind(childProcess) : (() => { throw new Error('child_process.exec unavailable'); });
const wrapper = (...args) => {
if (wrapper._queue && wrapper._queue.length) {
const impl = wrapper._queue.shift();
return impl(...args);
}
if (wrapper._impl) {
return wrapper._impl(...args);
}
return original(...args);
};
wrapper.mockImplementation = (impl) => {
wrapper._impl = impl;
return wrapper;
};
wrapper.mockImplementationOnce = (impl) => {
if (!wrapper._queue) {
wrapper._queue = [];
}
wrapper._queue.push(impl);
return wrapper;
};
wrapper.getMockImplementation = () => wrapper._impl || null;
wrapper.mockReset = wrapper.mockClear = () => {
wrapper._impl = null;
if (wrapper._queue) {
wrapper._queue.length = 0;
}
};
Object.defineProperty(wrapper, '_isMockFunction', { value: true });
childProcess.exec = wrapper;
}
ensureMockableExec();
const getExecAsync = () => promisify(childProcess.exec);
async function resolveConfigWithFallback() {
let config;
const getter = service.getBackupConfig;
if (getter && getter._isMockFunction) {
const impl = getter.getMockImplementation ? getter.getMockImplementation() : null;
if (impl) {
config = await getter();
} else {
config = await getBackupConfigInternal();
}
} else {
config = await getBackupConfigInternal();
}
const hasEnabled = config && Object.prototype.hasOwnProperty.call(config, 'backup_enabled');
const hasSchedule = config && (Object.prototype.hasOwnProperty.call(config, 'backup_schedule')
|| (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule')));
if (!config || !hasEnabled || !hasSchedule) {
const fallback = await getBackupConfigInternal();
if (!fallback) {
return config;
}
if (!config) {
return fallback;
}
const merged = { ...config };
Object.keys(fallback).forEach((key) => {
if (
!Object.prototype.hasOwnProperty.call(merged, key)
|| key === 'backup_schedule'
|| key === 'backup_enabled'
) {
merged[key] = fallback[key];
}
});
const rawCombined = { ...(fallback.__raw || {}), ...(config.__raw || {}) };
Object.defineProperty(merged, '__raw', {
value: rawCombined,
enumerable: false,
configurable: true
});
return merged;
}
return config;
}
function getStoragePath() {
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
function normalizeBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (trimmed === 'true') {
return true;
}
if (trimmed === 'false') {
return false;
}
}
return Boolean(value);
}
function parseSettingValue(raw) {
if (raw === null || raw === undefined) {
return raw;
}
if (typeof raw !== 'string') {
return raw;
}
const trimmed = raw.trim();
if (!trimmed.length) {
return trimmed;
}
try {
return JSON.parse(trimmed);
} catch (error) {
if (trimmed.toLowerCase() === 'true') {
return true;
}
if (trimmed.toLowerCase() === 'false') {
return false;
}
if (!Number.isNaN(Number(trimmed))) {
return Number(trimmed);
}
return raw;
}
}
async function calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = fsSync.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);
});
}
async function getCurrentSchemaVersion() {
try {
const record = await db('knex_migrations')
.orderBy('id', 'desc')
.first();
return record ? record.name : 'unknown';
} catch (error) {
logger.error('Failed to get schema version:', error);
return 'unknown';
}
}
async function getBackupConfigInternal() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
const raw = {};
settings.forEach(({ setting_key: key, setting_value: value }) => {
raw[key] = value;
config[key] = parseSettingValue(value);
});
Object.defineProperty(config, '__raw', {
value: raw,
enumerable: false,
configurable: true
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return null;
}
}
async function hasDatabaseChanged(sinceTime) {
try {
const tablesToCheck = [
'events',
'photos',
'admin_users',
'app_settings',
'email_queue',
'access_logs'
];
for (const table of tablesToCheck) {
try {
const updated = await db(table)
.where('updated_at', '>', sinceTime)
.limit(1)
.first();
if (updated) {
return true;
}
const created = await db(table)
.where('created_at', '>', sinceTime)
.limit(1)
.first();
if (created) {
return true;
}
} catch (innerError) {
logger.debug(`Skipping change detection for table ${table}:`, innerError.message);
}
}
return false;
} catch (error) {
logger.error('Failed to check database changes:', error);
return true;
}
}
/**
* Run an inline database dump (default ON) and then verify a usable dump
* is actually on disk before letting the file-backup proceed. Returns the
* verified `databaseInfo` so the caller can pass it straight into the
* manifest builder without re-querying.
*
* Why this lives here and not inline in `runBackupInternal`:
* - Encapsulates the "Run Backup Now must include DB" guarantee
* introduced when the silent files-only bug was discovered
* (2026-05-29 — admin lost CRM after `docker compose down -v`)
* - Lets the manifest path share the same `databaseInfo` object
* instead of doing a second `getDatabaseBackupInfo()` round-trip
* - Thrown errors bubble up to `runBackupInternal`'s catch, which
* marks the `backup_runs` row failed and queues the admin email
*
* Default-ON semantics: `backup_database_inline_dump` is only treated
* as disabled when explicitly set to false. `undefined` (the case on
* every existing install that predates the setting) falls through to
* the safe-default ON branch. `normalizeBoolean(undefined)` returns
* false, so a naive `!== false` check would silently disable the
* inline dump for every upgrading install.
*/
async function ensureDatabaseDumpForBackup(config) {
const inlineDumpExplicitlyOff = config.backup_database_inline_dump !== undefined
&& config.backup_database_inline_dump !== null
&& normalizeBoolean(config.backup_database_inline_dump) === false;
if (!inlineDumpExplicitlyOff) {
logger.info('Running inline database dump before file backup...');
const { databaseBackupService } = require('./databaseBackup');
const dumpResult = await databaseBackupService.backup({});
logger.info(`Inline database dump completed: ${dumpResult.path} ` +
`(${(dumpResult.size / 1024 / 1024).toFixed(2)} MB)`);
}
const databaseInfo = await service.getDatabaseBackupInfo();
if (!databaseInfo.backupFile) {
throw new Error(
'No database backup available to include in this file backup. ' +
'Either keep backup_database_inline_dump enabled (default) or configure ' +
'backup_database_schedule and let it run at least once first.'
);
}
let dumpStat;
try {
dumpStat = await fs.stat(databaseInfo.backupFile);
} catch (statErr) {
if (statErr.code === 'ENOENT') {
throw new Error(
`Database backup file at ${databaseInfo.backupFile} is missing from disk. ` +
'Refusing to proceed with file backup; configure backup_database_schedule or ' +
'keep backup_database_inline_dump enabled.'
);
}
throw statErr;
}
if (!dumpStat.size) {
throw new Error(
`Database backup file at ${databaseInfo.backupFile} is empty (0 bytes). ` +
'Refusing to proceed with file backup to avoid shipping a manifest with no DB content.'
);
}
return databaseInfo;
}
async function getDatabaseBackupInfoInternal() {
try {
const recent = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
if (recent && recent.file_path) {
const hasChanged = await hasDatabaseChanged(recent.completed_at);
// Postgres jsonb columns come back already parsed; sqlite TEXT comes
// back as a JSON string. Accept both.
const parseField = (v) => {
if (v == null) return null;
if (typeof v === 'object') return v;
try { return JSON.parse(v); } catch { return null; }
};
const stats = parseField(recent.statistics);
const checksums = parseField(recent.table_checksums);
return {
type: recent.backup_type || 'unknown',
backupFile: recent.file_path,
// file_size_bytes is a bigInteger column — node-postgres returns int8
// as a STRING, and `backedUpSize += size` then concatenates instead of
// adding (issue #871: "167.6 TB" dashboard size). Coerce at the source.
size: Number(recent.file_size_bytes) || 0,
checksum: recent.checksum,
hasChanged,
backupTime: recent.completed_at,
tables: (stats && stats.tables) || {},
rowCounts: checksums || {}
};
}
return {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backupFile: null,
size: 0,
checksum: null,
hasChanged: true,
tables: {},
rowCounts: {}
};
} catch (error) {
logger.error('Failed to get database backup info:', error);
return {
type: 'unknown',
backupFile: null,
size: 0,
checksum: null,
hasChanged: true,
tables: {},
rowCounts: {}
};
}
}
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.relative(basePath, fullPath);
const isExcluded = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
// Escape regex metacharacters before expanding the glob star — the
// raw replace turned '.nfs*' into /^.nfs.*$/ whose leading dot
// matched any character (e.g. 'anfs-photo.jpg' was excluded too).
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
const regex = new RegExp(`^${escaped}$`);
return regex.test(entry.name);
}
return entry.name === pattern;
});
if (isExcluded) {
continue;
}
if (entry.isDirectory()) {
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
fileList.push({
path: fullPath,
relativePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to scan directory ${dirPath}:`, error);
}
}
}
/**
* Hard-coded fallback when `backup_paths` is missing/empty. Mirrors
* the canonical seed in migration 109 — kept here as defense in depth
* so the walker can never silently degrade to "no directories scanned"
* because of a seed problem.
*
* Order matches the legacy behavior of the inlined sequence this
* function used to contain.
*/
const LEGACY_BACKUP_PATHS = [
{ path: 'events/active', feature_flag: null },
{ path: 'events/archived', feature_flag: 'backup_include_archived' },
{ path: 'thumbnails', feature_flag: null },
{ path: 'previews', feature_flag: null },
{ path: 'heroes', feature_flag: null },
{ path: 'uploads', feature_flag: null },
{ path: 'business-docs', feature_flag: null },
];
// "What to Backup" opt-OUT toggles written by BackupConfiguration.tsx.
// Default-ON semantics: only an explicit false excludes the path, so
// installs that never saved the backup form keep backing up everything
// (issue #871: unchecking Thumbnails had no effect because these keys
// were stored but never read).
const OPT_OUT_FLAGS = {
'events/active': 'backup_include_photos',
'thumbnails': 'backup_include_thumbnails',
};
// The UI "Archives" checkbox writes backup_include_archives (plural) while
// the feature_flag rows use backup_include_archived — accept both.
const FLAG_ALIASES = {
backup_include_archived: 'backup_include_archives',
};
// Filesystem noise that must never land in a backup: NFS silly-rename
// artifacts (issue #871 showed .nfs* files uploaded to S3) and OS metadata.
const DEFAULT_EXCLUDE_PATTERNS = ['.nfs*', '.DS_Store', 'Thumbs.db'];
/**
* Resolve the walker's target subdirectories from `backup_paths`.
*
* Layered fallback (defense in depth — no scenario where the walker
* silently scans nothing):
* 1. Read `backup_paths` rows where include_in_default = true,
* ordered by display_order.
* 2. If the table is missing OR returns zero rows, fall back to
* LEGACY_BACKUP_PATHS. Logged loudly so the admin sees it.
*
* Per-row gating: when `feature_flag` is set, the corresponding
* config key in `app_settings` must resolve truthy for that path to
* be included. Mirrors the historical `includeArchived` parameter,
* but now driven by data instead of a hard-coded boolean.
*
* @param {object} config resolved backup config (parseSettingValue'd).
* Used to evaluate feature_flag gates.
* @returns {Promise<Array<{ path: string, feature_flag: string|null }>>}
*/
async function loadBackupPathRows({ includeDisabled = false } = {}) {
try {
if (!(await db.schema.hasTable('backup_paths'))) {
logger.warn('backup_paths table missing — falling back to LEGACY_BACKUP_PATHS');
return LEGACY_BACKUP_PATHS;
}
let query = db('backup_paths')
.orderBy('display_order', 'asc')
.select('path', 'feature_flag', 'include_in_default');
if (!includeDisabled) {
query = query.where('include_in_default', formatBoolean(true));
}
const rows = await query;
if (!rows.filter((r) => normalizeBoolean(r.include_in_default)).length) {
logger.warn('backup_paths has no rows with include_in_default=true — falling back to LEGACY_BACKUP_PATHS');
return LEGACY_BACKUP_PATHS;
}
return rows;
} catch (err) {
logger.warn(`Failed to query backup_paths (${err.message}) — falling back to LEGACY_BACKUP_PATHS`);
return LEGACY_BACKUP_PATHS;
}
}
// Per-row gate. Applies the UI opt-out toggles first, then feature_flag
// gating: a row with feature_flag='backup_include_archived' requires the
// corresponding config key to be truthy (same semantics as the historical
// `includeArchived` parameter).
function backupPathIncluded(row, config) {
const optOutKey = OPT_OUT_FLAGS[row.path];
if (optOutKey && config) {
const optOutValue = config[optOutKey];
if (optOutValue !== undefined && optOutValue !== null && normalizeBoolean(optOutValue) === false) {
return false;
}
}
if (!row.feature_flag) return true;
let flagValue;
if (config) {
// The alias (backup_include_archives) is what the current UI writes;
// the canonical singular key is seeded true by migration on every
// install, so the UI value must take precedence or the checkbox can
// never turn the flag off.
const alias = FLAG_ALIASES[row.feature_flag];
if (alias && config[alias] !== undefined && config[alias] !== null) {
flagValue = config[alias];
} else {
flagValue = config[row.feature_flag];
}
}
return normalizeBoolean(flagValue);
}
// The raw config value the gate actually consulted for a row's feature
// flag (alias-aware) — the coverage report shows it next to the status,
// so it must not display the shadowed seeded key.
function effectiveFlagValue(row, config) {
if (!row.feature_flag || !config) return undefined;
const alias = FLAG_ALIASES[row.feature_flag];
if (alias && config[alias] !== undefined && config[alias] !== null) {
return config[alias];
}
return config[row.feature_flag];
}
async function resolveBackupPaths(config) {
return (await loadBackupPathRows()).filter((row) => backupPathIncluded(row, config));
}
// The rows the admin de-selected — the rsync destination needs them as
// --exclude filters because it syncs the whole storage root rather than
// the walker's file list. Includes rows with include_in_default=false,
// which the enabled-only loader would otherwise hide from rsync entirely.
async function resolveExcludedBackupPaths(config) {
const rows = await loadBackupPathRows({ includeDisabled: true });
return rows.filter((row) => {
const disabled = row.include_in_default !== undefined && !normalizeBoolean(row.include_in_default);
return disabled || !backupPathIncluded(row, config);
});
}
/**
* Bucket actually-backed-up files into their owning `backup_paths` row.
*
* Uses longest-prefix match — e.g. `events/active/E1/photo.jpg` matches
* `events/active` (length 13) rather than `events` (length 6, if that
* row existed). This handles the case where a future feature ships a
* nested backup_paths row that overlaps an existing one.
*
* @param {string[]} backedUpRelativePaths — paths that actually got
* copied / uploaded (post-incremental-filter). The exact list
* the destination implementation reports back.
* @param {Array<{relativePath, size}>} allFiles — the full file
* catalogue from the walker, used as a size lookup table.
* @returns {Promise<Record<string, { count: number, size: number }>>}
* keyed by `backup_paths.path` (e.g. 'events/active'). Paths
* with zero matches are omitted to keep the manifest compact.
*/
async function computePerPathStats(backedUpRelativePaths, allFiles) {
if (!backedUpRelativePaths || backedUpRelativePaths.length === 0) {
return {};
}
// Reuse the same source of truth the walker uses, so a row toggled
// off by include_in_default doesn't appear in the breakdown either.
let configuredPaths;
try {
if (await db.schema.hasTable('backup_paths')) {
configuredPaths = await db('backup_paths')
.where('include_in_default', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('path');
}
} catch (err) {
logger.warn(`Could not load backup_paths for per-path stats — falling back to legacy: ${err.message}`);
}
if (!configuredPaths || configuredPaths.length === 0) {
configuredPaths = LEGACY_BACKUP_PATHS.map((p) => ({ path: p.path }));
}
// Longest-prefix-first so nested paths win over their parents.
const sortedPaths = configuredPaths
.map((row) => row.path)
.sort((a, b) => b.length - a.length);
// Size lookup. relativePath uses OS path separators in `allFiles`
// (whatever scanDirectory built); the backup_paths rows always use
// forward slashes. Normalize the lookup key once.
const sizeByPath = new Map();
for (const f of allFiles || []) {
sizeByPath.set(f.relativePath.split(path.sep).join('/'), f.size || 0);
}
const stats = {};
for (const relativePath of backedUpRelativePaths) {
const norm = relativePath.split(path.sep).join('/');
// Find the longest configured path that this file's relativePath starts with.
const match = sortedPaths.find(
(p) => norm === p || norm.startsWith(`${p}/`)
);
if (!match) continue; // file outside any configured path (shouldn't happen)
if (!stats[match]) stats[match] = { count: 0, size: 0 };
stats[match].count += 1;
stats[match].size += sizeByPath.get(norm) || 0;
}
return stats;
}
async function getFilesToBackupInternal(configOrIncludeArchived = true) {
const files = [];
const storagePath = getStoragePath();
// Backward-compatible call signature:
// - Boolean `true|false` → legacy `includeArchived` argument. We
// forge a config-shaped object so the feature-flag gating
// resolves the same way the old code path did.
// - Object → full resolved backup config (preferred).
// - Anything else → treated as "include archived" (truthy).
let config;
if (typeof configOrIncludeArchived === 'object' && configOrIncludeArchived !== null) {
config = configOrIncludeArchived;
} else {
config = { backup_include_archived: normalizeBoolean(configOrIncludeArchived) };
}
const targets = await resolveBackupPaths(config);
// backup_exclude_patterns was only honored by the rsync destination
// (as --exclude args); the local/S3 walker ignored it. Merge it with
// the always-on noise filters here so every destination agrees.
const configuredExcludes = Array.isArray(config.backup_exclude_patterns)
? config.backup_exclude_patterns
: [];
const excludePatterns = [...new Set([...DEFAULT_EXCLUDE_PATTERNS, ...configuredExcludes])];
for (const target of targets) {
// CRM document estate is special-cased in the comment block below
// because it's the most expensive omission to recover from:
// - business-docs/quote/<year>/*.pdf
// - business-docs/contract/<year>/*.pdf (system-rendered + wet uploads)
// - business-docs/contract/signatures/<contract_id>/*.{png,jpg}
// (drawn signatures, forensic-preserved per Date.now() filename)
// - business-docs/invoice/<year>/*.pdf (issued invoices + Storno)
// - business-docs/invoice-imports/<year>/*.pdf (admin-imported
// historical invoices — irrecoverable if not backed up)
// Without this scan, the audit trail (signed_pdf_sha256, signed_*
// _ip, accepted_at, etc.) survives the restore but the documents
// those values refer to do not, leaving every CRM *_path column a
// broken FK. scanDirectory short-circuits on ENOENT so installs
// that never used CRM features won't error.
await scanDirectory(path.join(storagePath, target.path), files, storagePath, excludePatterns);
}
return files;
}
async function hasFileChanged(filePath, checksum) {
try {
const existing = await db('backup_file_states')
.where('file_path', filePath)
.first();
return !existing || existing.checksum !== checksum;
} catch (error) {
logger.error('Failed to check file state:', error);
return true;
}
}
async function updateFileState(filePath, checksum, size, modified) {
try {
const existing = await db('backup_file_states')
.where('file_path', filePath)
.first();
const payload = {
file_path: filePath,
checksum,
size_bytes: size,
last_modified: modified,
last_backed_up: new Date()
};
if (existing) {
await db('backup_file_states').where('id', existing.id).update(payload);
} else {
await db('backup_file_states').insert(payload);
}
} catch (error) {
logger.error('Failed to update file state:', error);
}
}
async function performLocalBackup(config, files) {
const destinationRoot = config.backup_destination_path || path.join(getStoragePath(), 'backups');
await fs.mkdir(destinationRoot, { recursive: true });
const backedUpFiles = [];
let backedUpSize = 0;
for (const file of files) {
try {
const maxSizeMb = config.backup_max_file_size_mb || 5000;
if (file.size > maxSizeMb * 1024 * 1024) {
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
continue;
}
const checksum = await calculateChecksum(file.path);
file.checksum = checksum;
const changed = await hasFileChanged(file.relativePath, checksum);
if (!changed && normalizeBoolean(config.backup_incremental) !== false) {
continue;
}
const destinationFile = path.join(destinationRoot, file.relativePath);
await fs.mkdir(path.dirname(destinationFile), { recursive: true });
await fs.copyFile(file.path, destinationFile);
await updateFileState(file.relativePath, checksum, file.size, file.modified);
backedUpFiles.push(file.relativePath);
backedUpSize += file.size;
} catch (error) {
logger.error(`Failed to backup file ${file.relativePath}:`, error);
}
}
return {
backedUpCount: backedUpFiles.length,
backedUpSize,
backedUpFiles,
backupPath: destinationRoot
};
}
function validateRsyncParam(value, label) {
if (!value || typeof value !== 'string') return null;
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
throw new Error(`Invalid ${label}: contains disallowed characters`);
}
if (value.length > 1024) {
throw new Error(`Invalid ${label}: too long`);
}
return value;
}
function buildRsyncArgs(config, extraExcludes = []) {
const storagePath = getStoragePath();
const host = validateRsyncParam(config.backup_rsync_host, 'host');
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// 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)) {
throw new Error('Invalid rsync host format');
}
const args = ['-avz', '--delete', '--stats'];
if (config.backup_rsync_ssh_key) {
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
const fs = require('fs');
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
throw new Error('SSH key file not found or is not a file');
}
// Pass SSH options as separate array elements to avoid shell interpretation
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
}
// Same noise filters as the walker, plus the de-selected backup paths
// (extraExcludes) — rsync syncs the whole storage root, so this is the
// only place the What-to-Backup selection can take effect for rsync.
const excludePatterns = [...new Set([
...DEFAULT_EXCLUDE_PATTERNS,
...(Array.isArray(config.backup_exclude_patterns) ? config.backup_exclude_patterns : []),
...extraExcludes,
])];
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
const source = `${storagePath}/`;
const user = config.backup_rsync_user;
if (user) {
validateRsyncParam(user, 'user');
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
throw new Error('Invalid rsync username format');
}
}
const destination = user
? `${user}@${host}:${remotePath}`
: `${host}:${remotePath}`;
args.push(source, destination);
return args;
}
function parseRsyncStats(output) {
const stats = {};
const filesMatch = output.match(/Number of files transferred: (\d+)/);
if (filesMatch) {
stats.filesTransferred = parseInt(filesMatch[1], 10);
}
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
if (sizeMatch) {
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''), 10);
}
return stats;
}
async function performRsyncBackup(config, files) {
const { spawnAsync } = require('../utils/safeExec');
// Anchored excludes for the de-selected What-to-Backup paths; rsync
// otherwise transfers the whole storage root regardless of the walker's
// file list (which only feeds manifests and file state).
const excludedPaths = await resolveExcludedBackupPaths(config);
const rsyncArgs = buildRsyncArgs(config, excludedPaths.map((row) => `/${row.path}/`));
const { stdout } = await spawnAsync('rsync', rsyncArgs);
const stats = parseRsyncStats(stdout);
const backedUpFiles = files.map(file => file.relativePath);
const totalSize = typeof stats.totalSize === 'number'
? stats.totalSize
: files.reduce((acc, file) => acc + file.size, 0);
for (const file of files) {
try {
const checksum = await calculateChecksum(file.path);
await updateFileState(file.relativePath, checksum, file.size, file.modified);
} catch (error) {
logger.error(`Failed to update rsync file state for ${file.relativePath}:`, error);
}
}
return {
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
backedUpSize: totalSize,
backedUpFiles,
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
};
}
async function performS3Backup(config, files) {
try {
const bucket = config.backup_s3_bucket;
if (!bucket || !config.backup_s3_access_key || !config.backup_s3_secret_key) {
throw new Error('S3 backup configuration incomplete: bucket, access key, and secret key are required');
}
const s3Config = {
bucket,
region: config.backup_s3_region || 'us-east-1',
endpoint: config.backup_s3_endpoint,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style),
sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled),
maxRetries: config.backup_s3_max_retries || 3,
retryDelay: config.backup_s3_retry_delay || 1000
};
const s3Client = new S3StorageAdapter(s3Config);
await s3Client.testConnection();
const now = new Date();
const datePrefix = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
const backupId = `backup-${now.getTime()}`;
const basePrefix = config.backup_s3_prefix ? config.backup_s3_prefix : 'backups';
const s3Prefix = path.posix.join(basePrefix, datePrefix, backupId);
const backedUpFiles = [];
let backedUpSize = 0;
for (const file of files) {
try {
const maxSizeMb = config.backup_max_file_size_mb || 5000;
if (file.size > maxSizeMb * 1024 * 1024) {
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
continue;
}
const checksum = await calculateChecksum(file.path);
file.checksum = checksum;
const changed = await hasFileChanged(file.relativePath, checksum);
if (!changed && normalizeBoolean(config.backup_incremental) !== false) {
continue;
}
const s3Key = path.posix.join(s3Prefix, file.relativePath);
await s3Client.upload(file.path, s3Key, {
metadata: {
'original-path': file.relativePath,
checksum,
'backup-id': backupId,
'backup-time': now.toISOString()
}
});
await updateFileState(file.relativePath, checksum, file.size, file.modified);
backedUpFiles.push(file.relativePath);
backedUpSize += file.size;
} catch (error) {
logger.error(`Failed to backup file ${file.relativePath} to S3:`, error);
}
}
let databaseInfo = null;
if (normalizeBoolean(config.backup_include_database) !== false) {
try {
databaseInfo = await service.getDatabaseBackupInfo();
if (databaseInfo.backupFile && await fs.stat(databaseInfo.backupFile).catch(() => null)) {
const dbKey = path.posix.join(s3Prefix, 'database', path.basename(databaseInfo.backupFile));
await s3Client.upload(databaseInfo.backupFile, dbKey, {
metadata: {
'backup-id': backupId,
'backup-type': 'database',
'database-type': databaseInfo.type,
checksum: databaseInfo.checksum || ''
}
});
backedUpFiles.push(path.posix.join('database', path.basename(databaseInfo.backupFile)));
backedUpSize += databaseInfo.size || 0;
} else {
logger.warn('No recent database backup found to include in S3 backup');
}
} catch (error) {
logger.error('Failed to include database backup in S3:', error);
}
}
try {
const summary = {
backupId,
timestamp: now.toISOString(),
bucket,
prefix: s3Prefix,
filesBackedUp: backedUpFiles.length,
totalSizeBytes: backedUpSize,
totalSizeFormatted: formatBytes(backedUpSize)
};
const summaryPath = path.join(getStoragePath(), `backup-summary-${backupId}.json`);
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
await s3Client.upload(summaryPath, path.posix.join(s3Prefix, 'backup-summary.json'), {
contentType: 'application/json'
});
await fs.unlink(summaryPath).catch(() => {});
} catch (error) {
logger.error('Failed to upload backup summary:', error);
}
logger.info(`S3 backup completed: ${backedUpFiles.length} files, ${formatBytes(backedUpSize)} uploaded to ${s3Prefix}`);
return {
backedUpCount: backedUpFiles.length,
backedUpSize,
backedUpFiles,
backupPath: `s3://${bucket}/${s3Prefix}`,
s3Prefix,
s3Bucket: bucket,
s3Client,
databaseInfo
};
} catch (error) {
logger.error('S3 backup failed:', error);
throw error;
}
}
async function getPreviousSuccessfulBackup(currentRunId) {
const record = await db('backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
if (record && record.id === currentRunId) {
return null;
}
return record || null;
}
function buildManifestFiles(backedUpFiles, allFiles) {
const fileMap = new Map();
allFiles.forEach(file => {
fileMap.set(file.relativePath, file);
});
return backedUpFiles.map(relativePath => {
const source = fileMap.get(relativePath) || {};
return {
path: relativePath,
size: source.size || null,
checksum: source.checksum || null
};
});
}
async function saveManifestToLocal(manifest, manifestFileName, config) {
const manifestDir = config.backup_manifest_path
|| path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
logger.info(`Backup manifest saved to ${manifestPath}`);
return manifestPath;
}
async function saveManifestToS3(manifest, manifestFileName, config, result) {
const tempDir = path.join(getStoragePath(), 'temp');
await fs.mkdir(tempDir, { recursive: true });
const tempManifestPath = path.join(tempDir, manifestFileName);
await backupManifest.saveManifest(manifest, tempManifestPath, config.backup_manifest_format || 'json');
const manifestKey = path.posix.join(result.s3Prefix, 'manifests', manifestFileName);
await result.s3Client.upload(tempManifestPath, manifestKey, {
contentType: config.backup_manifest_format === 'xml' ? 'application/xml' : 'application/json',
metadata: {
'backup-type': 'manifest',
'manifest-version': manifest.version,
'backup-id': manifest.backup?.id || ''
}
});
await fs.unlink(tempManifestPath).catch(() => {});
const manifestPath = `s3://${result.s3Bucket}/${manifestKey}`;
logger.info(`Backup manifest uploaded to S3: ${manifestPath}`);
return manifestPath;
}
async function runBackupInternal(isManual = false) {
if (isRunning) {
logger.warn('Backup already running, skipping');
return;
}
isRunning = true;
const startTime = new Date();
let runId = null;
try {
const config = await resolveConfigWithFallback();
// For scheduled backups, check if backup is enabled
// Manual backups should always be allowed (just need valid destination config)
if (!isManual && (!config || !normalizeBoolean(config.backup_enabled))) {
logger.info('Scheduled backup is disabled, skipping');
return;
}
// For manual backups, just ensure we have a destination configured
if (!config || !config.backup_destination_type) {
logger.warn('Backup destination not configured');
throw new Error('Backup destination not configured. Please configure backup settings first.');
}
const schemaVersion = await getCurrentSchemaVersion();
const insertResult = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: isManual ? 'manual' : 'scheduled',
app_version: packageJson.version,
node_version: process.version,
db_schema_version: schemaVersion
}).returning('id');
runId = insertResult[0]?.id || insertResult[0];
// Inline DB dump + fail-loud verification. The returned `databaseInfo`
// is reused at manifest-build time below so we don't pay a second
// `getDatabaseBackupInfo()` round-trip — see `ensureDatabaseDumpForBackup`
// for the full rationale.
const verifiedDatabaseInfo = await ensureDatabaseDumpForBackup(config);
// Pass the full config so the walker can evaluate any feature_flag
// gates declared in the backup_paths table (e.g. `events/archived`
// gated by `backup_include_archived`). Boolean signature is still
// supported for legacy callers and tests — see getFilesToBackupInternal.
const files = await service.getFilesToBackup(config);
logger.info(`Found ${files.length} files to check for backup`);
let result;
const destinationType = (config.backup_destination_type || 'local').toLowerCase();
if (destinationType === 'local') {
result = await performLocalBackup(config, files);
} else if (destinationType === 'rsync') {
result = await performRsyncBackup(config, files);
} else if (destinationType === 's3') {
result = await performS3Backup(config, files);
} else {
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
}
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
let manifestPath = null;
let manifestSummary = null;
try {
logger.info('Generating backup manifest...');
const previousBackup = await getPreviousSuccessfulBackup(runId);
const manifestFiles = buildManifestFiles(result.backedUpFiles, files);
// `verifiedDatabaseInfo` came from ensureDatabaseDumpForBackup at the
// top of this run — reuse it so manifest building doesn't pay a
// second `getDatabaseBackupInfo()` round-trip. The
// `result.databaseInfo` branch is kept for destination implementations
// (S3, future destinations) that override the local info on the result
// object; falls back to the verified copy otherwise.
const databaseInfo = result.databaseInfo || verifiedDatabaseInfo;
const manifestOptions = {
backupType: previousBackup ? 'incremental' : 'full',
backupPath: result.backupPath,
files: manifestFiles,
databaseInfo,
parentBackupId: previousBackup ? previousBackup.manifest_id : null,
format: config.backup_manifest_format || 'json',
customMetadata: {
backup_run_id: runId,
destination_type: destinationType,
retentionDays: config.backup_retention_days || 30
}
};
let manifest = await backupManifest.generateManifest(manifestOptions);
if (previousBackup && previousBackup.manifest_path) {
try {
const parentManifest = await loadManifestFromAnywhere(previousBackup.manifest_path, config);
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
} catch (error) {
logger.warn('Failed to load parent manifest, generating full manifest:', error);
}
}
if (result.s3Client) {
manifestPath = await saveManifestToS3(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config, result);
} else {
manifestPath = await saveManifestToLocal(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config);
}
try {
manifestSummary = backupManifest.generateSummaryReport
? backupManifest.generateSummaryReport(manifest)
: null;
} catch (error) {
logger.warn('Failed to generate manifest summary:', error);
}
} catch (error) {
logger.error('Failed to generate backup manifest:', error);
}
// Per-Stage-B-path stats — bucket the actually-backed-up files
// into their owning backup_paths row by longest-prefix match. Lets
// the Backup History detail pane render a true breakdown
// events/active: 142 files (3.2 GB)
// business-docs: 17 files (4.5 MB)
// thumbnails: 142 files (12.4 MB)
// instead of the legacy "Photos + Archives + Other" categorization
// that didn't reflect Stage B's data-driven walker. Falls back to
// an empty map if backup_paths is missing (defense in depth — the
// walker has the same fallback).
const perPath = await computePerPathStats(result.backedUpFiles, files);
await db('backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
files_backed_up: result.backedUpCount,
total_size_bytes: result.backedUpSize,
duration_seconds: durationSeconds,
manifest_path: manifestPath,
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
statistics: JSON.stringify({
// Use snake_case for frontend compatibility
files_processed: result.backedUpCount,
total_size: result.backedUpSize,
total_files_checked: files.length,
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType,
// Per-Stage-B-path breakdown — { [pathKey]: { count, size } }
per_path: perPath,
// Keep camelCase for backward compatibility
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
perPath
})
});
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
if (normalizeBoolean(config.backup_email_on_success)) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_completed', {
start_time: startTime.toISOString(),
duration: `${durationSeconds} seconds`,
files_count: String(result.backedUpCount),
total_size: formatBytes(result.backedUpSize),
backup_type: destinationType
});
}
}
} catch (error) {
logger.error('Backup failed:', error);
if (runId !== null) {
await db('backup_runs')
.where('id', runId)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
const config = await resolveConfigWithFallback();
if (config && normalizeBoolean(config.backup_email_on_failure)) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_failed', {
start_time: startTime.toISOString(),
backup_type: (config.backup_destination_type || 'unknown').toString(),
error_message: error.message
});
}
}
} finally {
isRunning = false;
}
}
// Two settings cooperate here:
// - backup_schedule — UI label like "daily" / "weekly" / "custom"
// - backup_schedule_cron — actual cron expression (custom schedules)
// Older startup code read backup_schedule and crashed when it found a label
// instead of a cron expression. Resolution order: explicit cron field, then
// map known labels, then fall back to default.
const NAMED_SCHEDULES = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 3 * * 0', // Sunday 03:00
monthly: '0 4 1 * *',
};
function resolveScheduleCron(config) {
const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s);
const readSetting = (key) => {
if (config && Object.prototype.hasOwnProperty.call(config, key)) {
return String(config[key] ?? '').trim();
}
if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) {
return String(parseSettingValue(config.__raw[key]) ?? '').trim();
}
return '';
};
let schedule = '0 2 * * *';
const cronCandidate = readSetting('backup_schedule_cron');
const labelCandidate = readSetting('backup_schedule');
// A named label wins over the cron field: the UI always used to send its
// default cron ('0 3 * * *') alongside e.g. backup_schedule='weekly', which
// silently turned weekly schedules into daily ones (issue #871). The cron
// field only applies for 'custom' (or when no known label is set).
if (labelCandidate && labelCandidate.toLowerCase() !== 'custom' && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) {
schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()];
} else if (cronCandidate && isCronExpression(cronCandidate)) {
schedule = cronCandidate;
} else if (labelCandidate && isCronExpression(labelCandidate)) {
// Back-compat: a deployment that wrote a cron expression directly into
// backup_schedule (no _cron field) still works.
schedule = labelCandidate;
}
return schedule;
}
async function startBackupService() {
try {
const config = await resolveConfigWithFallback();
if (!config || !normalizeBoolean(config.backup_enabled)) {
if (backupJob) {
backupJob.stop();
backupJob = null;
}
logger.info('Backup service is disabled');
return;
}
if (backupJob) {
backupJob.stop();
backupJob = null;
}
const schedule = resolveScheduleCron(config);
backupJob = cron.schedule(schedule, async () => {
logger.info('Starting scheduled backup');
await service.runBackup();
});
logger.info(`Backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start backup service:', error);
}
}
function stopBackupService() {
if (backupJob) {
backupJob.stop();
backupJob = null;
logger.info('Backup service stopped');
}
}
async function triggerManualBackup() {
logger.info('Starting manual backup');
await service.runBackup(true); // Pass flag to indicate manual backup
}
async function getBackupStatus(limit = 10) {
try {
const rawRuns = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
// Transform runs to add frontend-compatible field aliases
const runs = rawRuns.map(run => {
// Parse and transform statistics to snake_case for frontend compatibility
let statistics = run.statistics;
if (statistics) {
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
let stats = statistics;
if (typeof statistics === 'string') {
try {
stats = JSON.parse(statistics);
} catch (e) {
stats = {};
}
}
// Add snake_case aliases for frontend
statistics = {
...stats,
files_processed: stats.filesBackedUp || stats.files_processed || 0,
total_size: stats.totalSize || stats.total_size || 0,
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
average_file_size: stats.averageFileSize || stats.average_file_size || 0
};
}
return {
...run,
created_at: run.started_at, // Alias for frontend compatibility
statistics
};
});
const lastRun = runs[0];
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
// Use validateBackupManifest which handles both local and S3 paths
const result = await validateBackupManifest(lastRun.manifest_path);
manifestValid = result.valid;
if (!result.valid) {
logger.warn('Manifest validation failed:', result.error);
}
} catch (error) {
logger.warn('Manifest validation failed:', error.message);
}
}
const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null;
// Separate "most recent attempt" from "most recent SUCCESS" so the
// dashboard widget can distinguish:
// - last attempt failed → red, "Last attempt failed at X"
// - last attempt running → blue spinner, "In progress since X"
// - never succeeded → critical, "No successful backup yet"
// - last attempt succeeded → green tick, "Last backup X ago"
// Previously the widget showed the most-recent row with a generic
// green tick regardless of status, so a crashed run from 5 minutes
// ago looked identical to a successful one. Same "silent failure
// not surfaced" class Stage A was designed to fight.
const lastSuccessful = runs.find(r => r.status === 'completed') || null;
const config = await getBackupConfigInternal();
const nextRun = getNextScheduledRun(config);
// Detect zombie running rows (started >30min ago, never updated)
// — these are processes that died without writing a completed_at.
// Surface them so the admin can tell at a glance vs a live run.
const ZOMBIE_THRESHOLD_MS = 30 * 60 * 1000;
const zombieRuns = runs.filter(r =>
r.status === 'running'
&& r.started_at
&& (Date.now() - new Date(r.started_at).getTime()) > ZOMBIE_THRESHOLD_MS
);
return {
isRunning,
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRunWithManifest,
lastBackup: lastRunWithManifest, // Alias for frontend compatibility
lastSuccessfulBackup: lastSuccessful, // NEW — see comment above
zombieRuns, // NEW — running >30min, likely crashed
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
totalBackups: runs.filter(r => r.status === 'completed').length,
nextScheduledRun: nextRun,
nextBackup: nextRun // BackupManagement.tsx reads this name
};
} catch (error) {
logger.error('Failed to get backup status:', error);
return {
isRunning,
isHealthy: false,
error: error.message
};
}
}
function getNextScheduledRun(config) {
// null → the UI shows "Not scheduled". Only a real, enabled schedule
// produces a date (issue #871: this used to be a hardcoded "tomorrow
// 02:00" that ignored the configured schedule entirely).
if (!config || !normalizeBoolean(config.backup_enabled)) {
return null;
}
try {
return cronParser.parseExpression(resolveScheduleCron(config)).next().toISOString();
} catch (error) {
logger.warn(`Could not compute next backup run: ${error.message}`);
return null;
}
}
async function cleanupOldBackupRuns(retentionDays = 30) {
try {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
const deleted = await db('backup_runs')
.where('started_at', '<', cutoff)
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} old backup runs`);
}
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
}
}
/**
* Load a backup manifest regardless of whether it lives on the local
* filesystem or in S3. Used by both the public getBackupManifest API
* and the incremental-manifest path in runBackupInternal — previously
* the latter called loadManifest() with an s3:// URI directly, which
* tried fs.readFile on the literal string and threw ENOENT, silently
* downgrading every incremental backup to a full manifest.
*/
async function loadManifestFromAnywhere(manifestPath, config) {
if (!manifestPath) {
throw new Error('Manifest path is required');
}
if (!manifestPath.startsWith('s3://')) {
return backupManifest.loadManifest(manifestPath);
}
const cfg = config || (await resolveConfigWithFallback());
const accessKey = cfg?.backup_s3_access_key
?? (cfg?.__raw && Object.prototype.hasOwnProperty.call(cfg.__raw, 'backup_s3_access_key')
? parseSettingValue(cfg.__raw.backup_s3_access_key)
: undefined)
?? process.env.BACKUP_S3_ACCESS_KEY;
const secretKey = cfg?.backup_s3_secret_key
?? (cfg?.__raw && Object.prototype.hasOwnProperty.call(cfg.__raw, 'backup_s3_secret_key')
? parseSettingValue(cfg.__raw.backup_s3_secret_key)
: undefined)
?? process.env.BACKUP_S3_SECRET_KEY;
if (!accessKey || !secretKey) {
throw new Error('S3 credentials not configured for manifest retrieval');
}
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
if (!match) {
throw new Error('Invalid S3 manifest path');
}
const [, bucket, key] = match;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-'));
// Preserve the original extension so loadManifest's format detection
// picks the right parser.
const ext = path.extname(key) || '.json';
const tempPath = path.join(tempDir, `manifest-${Date.now()}${ext}`);
const s3Client = new S3StorageAdapter({
bucket,
region: (cfg && cfg.backup_s3_region) || 'us-east-1',
endpoint: cfg && cfg.backup_s3_endpoint,
accessKeyId: accessKey,
secretAccessKey: secretKey,
forcePathStyle: cfg ? normalizeBoolean(cfg.backup_s3_force_path_style) : false,
sslEnabled: cfg && cfg.backup_s3_ssl_enabled !== undefined
? normalizeBoolean(cfg.backup_s3_ssl_enabled)
: true,
});
try {
await s3Client.download(key, tempPath);
return await backupManifest.loadManifest(tempPath);
} finally {
await fs.unlink(tempPath).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
async function getBackupManifest(backupRunId) {
const run = await db('backup_runs')
.where('id', backupRunId)
.first();
if (!run || !run.manifest_path) {
throw new Error('Backup manifest not found');
}
if (!run.manifest_path.startsWith('s3://')) {
const manifest = await backupManifest.loadManifest(run.manifest_path);
return {
manifest,
summary: backupManifest.generateSummaryReport
? backupManifest.generateSummaryReport(manifest)
: null
};
}
const config = await resolveConfigWithFallback();
const accessKey = config?.backup_s3_access_key
?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_access_key')
? parseSettingValue(config.__raw.backup_s3_access_key)
: undefined)
?? process.env.BACKUP_S3_ACCESS_KEY;
const secretKey = config?.backup_s3_secret_key
?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_secret_key')
? parseSettingValue(config.__raw.backup_s3_secret_key)
: undefined)
?? process.env.BACKUP_S3_SECRET_KEY;
if (!accessKey || !secretKey) {
throw new Error('S3 credentials not configured for manifest retrieval');
}
const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/);
if (!match) {
throw new Error('Invalid S3 manifest path');
}
const [, bucket, key] = match;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-'));
const tempPath = path.join(tempDir, `manifest-${backupRunId}.json`);
const s3Client = new S3StorageAdapter({
bucket,
region: (config && config.backup_s3_region) || 'us-east-1',
endpoint: config && config.backup_s3_endpoint,
accessKeyId: accessKey,
secretAccessKey: secretKey,
forcePathStyle: config ? normalizeBoolean(config.backup_s3_force_path_style) : false,
sslEnabled: config && config.backup_s3_ssl_enabled !== undefined
? normalizeBoolean(config.backup_s3_ssl_enabled)
: true
});
await s3Client.download(key, tempPath);
const manifest = await backupManifest.loadManifest(tempPath);
await fs.unlink(tempPath).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
return {
manifest,
summary: backupManifest.generateSummaryReport
? backupManifest.generateSummaryReport(manifest)
: null
};
}
async function validateBackupManifest(manifestPath) {
try {
let manifest;
if (manifestPath.startsWith('s3://')) {
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
if (!match) {
throw new Error('Invalid S3 manifest path');
}
const [, bucket, key] = match;
const config = await service.getBackupConfig();
if (!config || !config.backup_s3_access_key || !config.backup_s3_secret_key) {
throw new Error('S3 credentials not configured for manifest validation');
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-'));
const tempPath = path.join(tempDir, `validate-${Date.now()}.json`);
const s3Client = new S3StorageAdapter({
bucket,
region: config.backup_s3_region || 'us-east-1',
endpoint: config.backup_s3_endpoint,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style),
sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled)
});
await s3Client.download(key, tempPath);
manifest = await backupManifest.loadManifest(tempPath);
await fs.unlink(tempPath).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
} else {
manifest = await backupManifest.loadManifest(manifestPath);
}
if (backupManifest.validateManifest) {
backupManifest.validateManifest(manifest);
}
return { valid: true, manifest };
} catch (error) {
return { valid: false, error: error.message };
}
}
service.getBackupConfig = getBackupConfigInternal;
service.getDatabaseBackupInfo = getDatabaseBackupInfoInternal;
service.getFilesToBackup = getFilesToBackupInternal;
service.runBackup = runBackupInternal;
service.startBackupService = startBackupService;
service.stopBackupService = stopBackupService;
service.triggerManualBackup = triggerManualBackup;
service.getBackupStatus = getBackupStatus;
service.cleanupOldBackupRuns = cleanupOldBackupRuns;
service.getBackupManifest = getBackupManifest;
service.validateBackupManifest = validateBackupManifest;
service.resolveBackupPaths = resolveBackupPaths;
service.resolveExcludedBackupPaths = resolveExcludedBackupPaths;
service.backupPathIncluded = backupPathIncluded;
service.effectiveFlagValue = effectiveFlagValue;
service.normalizeBoolean = normalizeBoolean;
service.buildRsyncArgs = buildRsyncArgs;
service.resolveScheduleCron = resolveScheduleCron;
service.getNextScheduledRun = getNextScheduledRun;
module.exports = service;