Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
This commit is contained in:
@@ -33,11 +33,34 @@ exports.up = async function(knex) {
|
||||
// Try to save credentials to file, but don't fail if we can't
|
||||
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||
const setupInfoPath = path.join(dataDir, 'ADMIN_CREDENTIALS.txt');
|
||||
|
||||
|
||||
// Detect a pending install-from-backup trigger. If one exists,
|
||||
// these credentials are about to be obsoleted by the restore —
|
||||
// the backup's admin row replaces this fresh-install one a few
|
||||
// seconds from now. We still write the file (in case the
|
||||
// restore fails and the fresh admin is the only way in) but
|
||||
// annotate the top so admins reading the file after restore
|
||||
// don't waste time trying credentials that no longer exist.
|
||||
// Flagged on PR #596 review.
|
||||
const fsSync = require('fs');
|
||||
const backupRoot = process.env.BACKUP_ROOT || '/backup';
|
||||
const triggerWillFire = fsSync.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))
|
||||
|| fsSync.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL.txt'));
|
||||
const restoreNotice = triggerWillFire ? `
|
||||
|
||||
⚠️ RESTORE_ON_INSTALL TRIGGER DETECTED ⚠️
|
||||
These credentials are temporary. An install-from-backup run is queued
|
||||
to fire on the next server start, which will REPLACE this admin row
|
||||
with the one from the backup. After the restore completes, log in
|
||||
with your ORIGINAL pre-disaster credentials — not the ones below.
|
||||
If the restore fails for some reason, the credentials below remain
|
||||
valid as a fallback recovery path.
|
||||
` : '';
|
||||
|
||||
const setupInfo = `
|
||||
========================================
|
||||
PicPeak Admin Credentials
|
||||
========================================
|
||||
========================================${restoreNotice}
|
||||
|
||||
Your admin account has been created with these credentials:
|
||||
|
||||
|
||||
@@ -100,8 +100,18 @@ exports.up = async function(knex) {
|
||||
// Add restore-related settings to app_settings
|
||||
const restoreSettings = [
|
||||
{
|
||||
// Default ON so fresh installs can recover from disaster
|
||||
// without the catch-22 documented in _restoreSettingsBoot.js
|
||||
// (fresh-install admin user trips the "1 active admin" warning,
|
||||
// which can only be overridden with force=true, which the wizard
|
||||
// refused if this setting was false — exactly the moment an
|
||||
// admin can least afford a SQL incantation). Flipped from false
|
||||
// to true 2026-05-30. Existing installs that ran this migration
|
||||
// with the OLD value will get auto-upgraded once by the boot
|
||||
// self-heal in _restoreSettingsBoot.js — see the
|
||||
// `restore_allow_force_auto_upgraded` guard there.
|
||||
setting_key: 'restore_allow_force',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'restore'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Migration 109 — config-driven backup walker.
|
||||
*
|
||||
* (Originally numbered 108 on bugfix/crm-backup. Renumbered to 109
|
||||
* before merge because upstream/beta independently shipped
|
||||
* 108_seed_sl_email_template_translations.js. The createTable is
|
||||
* idempotent via `hasTable` guard and the seed uses
|
||||
* `onConflict('path').ignore()`, so beta installs that ran the
|
||||
* 108-named version of this file get a harmless no-op when 109
|
||||
* runs against the already-seeded table.)
|
||||
*
|
||||
* Stage B of the three-stage backup-hardening plan. The file-backup
|
||||
* walker (`getFilesToBackupInternal` in backupService.js) historically
|
||||
* hard-coded its list of subdirectories: events/active, events/archived,
|
||||
* thumbnails, previews, heroes, uploads, business-docs.
|
||||
*
|
||||
* That list is a footgun every time a new feature lands that drops
|
||||
* artefacts under STORAGE_PATH/<something>/ — the maintainer has to
|
||||
* remember to edit the walker, and there's no schema-level record of
|
||||
* what *should* be backed up. The CRM rollout missed `business-docs`
|
||||
* for ~6 months (#XXX) for exactly this reason.
|
||||
*
|
||||
* This migration introduces a `backup_paths` table that the walker
|
||||
* reads at runtime. New features add a row; the walker picks them up
|
||||
* automatically. The `feature_flag` column gates scans behind an
|
||||
* existing app_settings boolean (e.g. `backup_include_archived`),
|
||||
* mirroring how the previous `includeArchived` parameter worked.
|
||||
*
|
||||
* Columns:
|
||||
* - path : relative to STORAGE_PATH, unique
|
||||
* - include_in_default : on/off without deleting the row (so
|
||||
* audit trail of "we used to back this up"
|
||||
* is preserved)
|
||||
* - feature_flag : nullable; when set, walker checks the
|
||||
* same-named app_settings boolean before
|
||||
* scanning. Matches the existing pattern
|
||||
* used by `backup_include_archived`.
|
||||
* - display_order : controls admin-UI listing order
|
||||
* - description : human-readable purpose, shown in admin UI
|
||||
*
|
||||
* Defense-in-depth: the walker also keeps a hard-coded LEGACY_DEFAULTS
|
||||
* fallback so that if this table is somehow empty (failed migration on
|
||||
* an existing install, manual truncation), backups still cover the
|
||||
* historical set instead of silently shipping nothing. The boot-time
|
||||
* self-heal in `_backupPathsBoot.js` re-seeds missing default rows on
|
||||
* every startup so newly-added defaults reach already-deployed
|
||||
* installs without a follow-up migration.
|
||||
*
|
||||
* Idempotent: skips the createTable if it already exists, and the
|
||||
* seed uses `onConflict('path').ignore()` so re-runs don't duplicate.
|
||||
*/
|
||||
|
||||
const DEFAULT_PATHS = [
|
||||
{
|
||||
path: 'events/active',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 10,
|
||||
description: 'Active gallery photo originals',
|
||||
},
|
||||
{
|
||||
path: 'events/archived',
|
||||
include_in_default: true,
|
||||
feature_flag: 'backup_include_archived',
|
||||
display_order: 20,
|
||||
description: 'Archived gallery photo originals (gated by backup_include_archived)',
|
||||
},
|
||||
{
|
||||
path: 'thumbnails',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 30,
|
||||
description: 'Generated gallery thumbnails',
|
||||
},
|
||||
{
|
||||
path: 'previews',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 40,
|
||||
description: 'Lightbox preview tier (#492)',
|
||||
},
|
||||
{
|
||||
path: 'heroes',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 50,
|
||||
description: 'Gallery hero header images',
|
||||
},
|
||||
{
|
||||
path: 'uploads',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 60,
|
||||
description: 'Direct uploads root (wet-signature contracts, imported invoices, etc.)',
|
||||
},
|
||||
{
|
||||
path: 'business-docs',
|
||||
include_in_default: true,
|
||||
feature_flag: null,
|
||||
display_order: 70,
|
||||
description: 'CRM PDFs, signature artefacts, admin-imported historical invoices',
|
||||
},
|
||||
];
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const exists = await knex.schema.hasTable('backup_paths');
|
||||
if (!exists) {
|
||||
await knex.schema.createTable('backup_paths', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('path', 256).notNullable().unique();
|
||||
t.boolean('include_in_default').notNullable().defaultTo(true);
|
||||
t.string('feature_flag', 64).nullable();
|
||||
t.integer('display_order').notNullable().defaultTo(100);
|
||||
t.string('description', 256).nullable();
|
||||
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Seed defaults — `onConflict('path').ignore()` so already-seeded rows
|
||||
// (manual edits by admins, prior partial runs) survive untouched.
|
||||
await knex('backup_paths')
|
||||
.insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})))
|
||||
.onConflict('path')
|
||||
.ignore();
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('backup_paths');
|
||||
};
|
||||
|
||||
// Exported so the self-heal boot helper can reuse the same authoritative
|
||||
// list without re-declaring it. Tests also import this to assert the
|
||||
// walker is reading from this source.
|
||||
exports.DEFAULT_PATHS = DEFAULT_PATHS;
|
||||
Reference in New Issue
Block a user