Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup

# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
This commit is contained in:
Paul Nothaft
2026-07-03 11:54:01 +02:00
55 changed files with 4019 additions and 154 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
*
* Responsibilities:
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
* Google Authenticator / Authy / 1Password all work);
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
* yield working authenticator seeds;
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
* - build the otpauth:// URI + QR data-URL for enrollment.
*
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets —
* the same blast radius as rotating JWT_SECRET already has for sessions, and
* `reset-admin-mfa.js` is the recovery path.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
authenticator.options = { window: 1 };
const ISSUER = 'PicPeak';
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
const RECOVERY_BCRYPT_ROUNDS = 10;
const ENC_ALGO = 'aes-256-gcm';
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
function getEncryptionKey() {
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
if (!material) {
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
}
return crypto.scryptSync(material, ENC_SALT, 32);
}
/** Generate a fresh base32 TOTP secret. */
function generateSecret() {
return authenticator.generateSecret();
}
/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */
function encryptSecret(plainSecret) {
const key = getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
}
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
function decryptSecret(stored) {
const key = getEncryptionKey();
const [ivB64, tagB64, ctB64] = String(stored).split('.');
if (!ivB64 || !tagB64 || !ctB64) {
throw new Error('mfaService: malformed encrypted secret');
}
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
return pt.toString('utf8');
}
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
function verifyTotp(code, plainSecret) {
if (!code || !plainSecret) return false;
try {
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
} catch {
return false;
}
}
/** Verify a code against a STORED (encrypted) secret. */
function verifyTotpEncrypted(code, storedSecret) {
try {
return verifyTotp(code, decryptSecret(storedSecret));
} catch {
return false;
}
}
/** otpauth:// URI for an authenticator app. */
function buildOtpauthUri(accountName, plainSecret) {
return authenticator.keyuri(accountName, ISSUER, plainSecret);
}
/** QR code (PNG data URL) for the otpauth URI. */
async function buildQrDataUrl(otpauthUri) {
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
}
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
function formatRecoveryCode(raw) {
return raw.match(/.{1,4}/g).join('-');
}
/**
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
*/
async function generateRecoveryCodes() {
const plain = [];
const hashed = [];
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
// base32-ish, lowercase, no ambiguous chars
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
.toString('base64')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.slice(0, 10);
const code = formatRecoveryCode(raw);
plain.push(code);
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
}
return { plain, hashed };
}
function normalizeRecoveryInput(code) {
return String(code || '').trim().toLowerCase();
}
/**
* Check a submitted recovery code against the stored hash array. On match,
* returns the remaining hashes (matched one removed — single use). On miss,
* matched:false and the array unchanged.
*
* @param {string[]} storedHashes
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
*/
async function consumeRecoveryCode(code, storedHashes) {
const input = normalizeRecoveryInput(code);
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
if (!input) return { matched: false, remainingHashes: hashes };
for (let i = 0; i < hashes.length; i++) {
// eslint-disable-next-line no-await-in-loop
if (await bcrypt.compare(input, hashes[i])) {
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
return { matched: true, remainingHashes: remaining };
}
}
return { matched: false, remainingHashes: hashes };
}
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
function isEnrolled(admin) {
const v = admin && admin.two_factor_enabled;
return v === true || v === 1 || v === '1';
}
/** Parse the DB column (JSON text) into an array of hashes. */
function parseRecoveryCodes(raw) {
if (!raw) return [];
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
module.exports = {
generateSecret,
encryptSecret,
decryptSecret,
verifyTotp,
verifyTotpEncrypted,
buildOtpauthUri,
buildQrDataUrl,
generateRecoveryCodes,
consumeRecoveryCode,
parseRecoveryCodes,
isEnrolled,
formatRecoveryCode,
ISSUER,
RECOVERY_CODE_COUNT,
};
@@ -0,0 +1,231 @@
'use strict';
// Portable ".picpeak" export — a single, self-describing archive that can be
// downloaded from one instance and re-uploaded to another via the web UI only
// (see picpeakImportService for the receiving half).
//
// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup
// (which can only ever restore into the same engine and version), each table is
// written as NDJSON. The target rebuilds its own schema by running migrations,
// then loads these rows into it — so an older backup restores cleanly onto a
// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work.
//
// This module is purely additive: it introduces a new artifact and touches no
// existing backup/restore path.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const archiver = require('archiver');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const logger = require('../utils/logger');
const packageJson = require('../../package.json');
// Bump only on a breaking change to the on-disk layout below.
const PICPEAK_FORMAT_VERSION = 1;
// Never exported as data — the target owns these (its own migrations set them).
const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']);
// Storage subdirs holding non-recalculable blobs — always included.
const DOC_DIRS = ['business-docs', 'uploads'];
// Original gallery photos — only when includePhotos is true (large; otherwise
// the admin re-uploads originals per gallery and previews are re-rendered).
const PHOTO_DIRS = ['events/active', 'events/archived'];
const isPostgres = () => knexConfig.client === 'pg';
// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite.
const rawRows = (result) => (isPostgres() ? result.rows : result);
// All user tables, minus knex bookkeeping. Introspected at runtime so the
// export never rots as tables are added (no hardcoded list to maintain).
async function listDataTables() {
let names;
if (isPostgres()) {
const result = await db.raw(`
SELECT table_name AS name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
names = rawRows(result).map((r) => r.name);
} else {
const result = await db.raw(`
SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`);
names = rawRows(result).map((r) => r.name);
}
return names.filter((n) => !EXCLUDED_TABLES.has(n));
}
// The latest applied migration — recorded in the manifest so the importer can
// refuse a backup that is NEWER than the target (forward-only guarantee).
async function getLatestMigration() {
try {
const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1);
return rows[0]?.name || null;
} catch (_) {
return null;
}
}
// Write one table to <dataDir>/<table>.ndjson (one JSON object per line).
// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises
// Dates to ISO strings, which re-import cleanly on both engines.
//
// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls
// in the optional `pg-query-stream` dependency (not bundled), so it throws on
// pg. A select works on both engines with no extra dependency. Rows are DB
// metadata (blobs live on disk under files/), so holding a table in memory is
// fine for the instance sizes PicPeak targets.
async function writeTableNdjson(table, dataDir) {
const outPath = path.join(dataDir, `${table}.ndjson`);
const hash = crypto.createHash('sha256');
const rows = await db(table).select('*');
const lines = rows.map((row) => {
const line = JSON.stringify(row);
hash.update(`${line}\n`);
return line;
});
await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
return { rowCount: rows.length, checksum: hash.digest('hex') };
}
// Recursively collect files under a storage subdir as { abs, rel } where rel is
// relative to the storage root (so the importer restores the same layout).
async function collectDir(subdir, storageRoot, acc) {
const abs = path.join(storageRoot, subdir);
let entries;
try {
entries = await fsp.readdir(abs, { withFileTypes: true });
} catch (_) {
return; // subdir may not exist on this install — skip silently
}
for (const entry of entries) {
const childRel = path.join(subdir, entry.name);
if (entry.isDirectory()) {
await collectDir(childRel, storageRoot, acc);
} else if (entry.isFile()) {
acc.push({ abs: path.join(storageRoot, childRel), rel: childRel });
}
}
}
async function collectFiles(includePhotos) {
const storageRoot = getStoragePath();
const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS];
const acc = [];
for (const d of dirs) {
await collectDir(d, storageRoot, acc);
}
return acc;
}
/**
* Build a .picpeak archive.
* @param {Object} opts
* @param {boolean} [opts.includePhotos=false] include original gallery photos
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
* @returns {Promise<{ filePath: string, manifest: object }>}
*/
async function createPicpeak({ includePhotos = false, outDir } = {}) {
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
const dataDir = path.join(staging, 'data');
await fsp.mkdir(dataDir, { recursive: true });
try {
// 1. Dump every table to NDJSON, tracking counts + checksums.
const tables = await listDataTables();
const tableMeta = {};
for (const table of tables) {
tableMeta[table] = await writeTableNdjson(table, dataDir);
}
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
// optionally original photos).
const files = await collectFiles(includePhotos);
// 3. Manifest — everything the importer needs to validate + reconstruct.
const manifest = {
format: PICPEAK_FORMAT_VERSION,
kind: 'picpeak-backup',
created_at: new Date().toISOString(),
app_version: packageJson.version || null,
database: {
engine: isPostgres() ? 'pg' : 'sqlite',
latest_migration: await getLatestMigration(),
},
options: { includePhotos: !!includePhotos },
tables: tableMeta,
file_count: files.length,
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
// text — the download surface must warn about this.
contains_secrets: true,
};
await fsp.writeFile(
path.join(staging, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8'
);
// 4. Zip staging (manifest + data/) plus the blobs under files/. The final
// .picpeak lands in outDir (caller-managed) or a fresh temp dir; either
// way the NDJSON scratch (which holds plaintext secrets) is always
// removed in `finally` below.
const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-')));
await fsp.mkdir(targetDir, { recursive: true });
const stamp = manifest.created_at.replace(/[:.]/g, '-');
const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`);
try {
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(filePath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
// Surface archiver warnings (e.g. a file vanished mid-run) instead of
// silently shipping an incomplete archive.
archive.on('warning', (err) => reject(err));
archive.pipe(output);
archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' });
archive.directory(dataDir, 'data');
for (const f of files) {
archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) });
}
archive.finalize();
});
} catch (err) {
// Archiver failed → the partial .picpeak holds plaintext secrets and is
// useless; remove our own temp out dir so it isn't orphaned. A
// caller-supplied outDir is left untouched.
if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
logger.info(
`[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})`
);
return { filePath, manifest };
} finally {
// Always remove the NDJSON scratch dir — it contains a plaintext dump of
// every table (secrets included). The final .picpeak is elsewhere.
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
PICPEAK_FORMAT_VERSION,
EXCLUDED_TABLES,
createPicpeak,
// exported for reuse/testing
listDataTables,
collectFiles,
};
@@ -0,0 +1,271 @@
'use strict';
// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced
// by picpeakExportService and restores it onto THIS instance.
//
// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and
// replaced by the backup's rows — EXCEPT the current logged-in admin account,
// which is preserved so the operator is never locked out. A backup admin whose
// email collides with the current account is overwritten with the current
// account's credentials (so the operator's known password keeps working).
//
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
// restores onto a newer instance; a newer backup is refused). The target's own
// schema is used as-is — we never replay the backup's DDL.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const StreamZip = require('node-stream-zip');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
const isPostgres = () => knexConfig.client === 'pg';
// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …).
function migrationOrder(name) {
const m = String(name || '').match(/^(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}
async function readManifestFromZip(picpeakPath) {
const zip = new StreamZip.async({ file: picpeakPath });
try {
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
} finally {
await zip.close();
}
}
// Returns an array of human-readable blockers ([] = OK to restore).
async function validateManifest(manifest) {
const errors = [];
if (!manifest || manifest.kind !== 'picpeak-backup') {
return ['This file is not a PicPeak backup (.picpeak).'];
}
if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) {
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
}
const engine = isPostgres() ? 'pg' : 'sqlite';
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
}
// Forward-only: the target schema must be at least as new as the backup's.
let targetLatest = null;
try {
const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1);
targetLatest = applied[0] ? applied[0].name : null;
} catch (_) {
// No knex_migrations table (e.g. some test harnesses) — skip the check.
}
const backupLatest = manifest.database ? manifest.database.latest_migration : null;
if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) {
errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.');
}
return errors;
}
function parseNdjson(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs
.readFileSync(filePath, 'utf8')
.split('\n')
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l));
}
// Re-insert the operator's account inside the restore transaction so they keep
// working credentials. If the backup already loaded an admin with the same
// email, overwrite that row's credentials with the current account's (current
// creds win); otherwise insert the snapshot with a fresh id.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return;
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
if (existing) {
await trx('admin_users').where({ id: existing.id }).update({
password_hash: currentAdmin.password_hash,
is_active: currentAdmin.is_active,
must_change_password: currentAdmin.must_change_password,
});
} else {
const row = { ...currentAdmin };
delete row.id; // let the engine assign a fresh id to avoid collision
await trx('admin_users').insert(row);
}
}
// The json/jsonb columns of a table (Postgres only). The pg driver returns
// jsonb as parsed JS values, so on re-insert they must be serialised back to
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
// unquoted and pg rejects it ("invalid input syntax for type json").
async function jsonColumnsFor(trx, table) {
if (!isPostgres()) return new Set();
const res = await trx.raw(
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
[table]
);
return new Set(res.rows.map((r) => r.column_name));
}
function serialiseJsonColumns(rows, jsonCols) {
if (!jsonCols.size) return rows;
return rows.map((row) => {
const out = { ...row };
for (const col of jsonCols) {
if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]);
}
return out;
});
}
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
await trx.raw("SET session_replication_role = 'replica'");
} catch (_) {
// session_replication_role requires a Postgres SUPERUSER. The bundled
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
// app users usually are not. Fail fast with a clear message BEFORE any
// rows are deleted — the transaction rolls back, so nothing is wiped.
const err = new Error(
'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instances database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.'
);
err.statusCode = 400;
throw err;
}
} else {
await trx.raw('PRAGMA defer_foreign_keys = ON');
}
for (const table of tables) {
await trx(table).del();
}
for (const table of tables) {
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
if (!rows.length) continue;
const jsonCols = await jsonColumnsFor(trx, table);
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
});
}
// Copy the archive's files/ tree into storage, overwriting existing files.
async function restoreFiles(stagingDir) {
const src = path.join(stagingDir, 'files');
if (!fs.existsSync(src)) return 0;
const storageRoot = getStoragePath();
let count = 0;
async function walk(rel) {
const abs = path.join(src, rel);
for (const entry of await fsp.readdir(abs, { withFileTypes: true })) {
const childRel = path.join(rel, entry.name);
if (entry.isDirectory()) {
await walk(childRel);
} else if (entry.isFile()) {
const dest = path.join(storageRoot, childRel);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.copyFile(path.join(src, childRel), dest);
count += 1;
}
}
}
await walk('');
return count;
}
// Does the restored data reference an external-media library? If so the caller
// shows a banner telling the admin to (re)configure the external-media mount on
// this instance — those files are NOT in the backup by design.
async function detectExternalMedia() {
try {
if (await hasColumnCached('events', 'external_path')) {
const row = await db('events').whereNotNull('external_path').first();
if (row) return true;
}
if (await hasColumnCached('photos', 'external_relpath')) {
const row = await db('photos').whereNotNull('external_relpath').first();
if (row) return true;
}
} catch (_) {
// Best-effort — a detection miss is not worth failing the restore.
}
return false;
}
/**
* Restore a .picpeak onto this instance.
* @param {Object} opts
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
*/
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const manifest = await readManifestFromZip(picpeakPath);
const blockers = await validateManifest(manifest);
if (blockers.length) {
const err = new Error(blockers[0]);
err.statusCode = 400;
err.validation = blockers;
throw err;
}
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
const zip = new StreamZip.async({ file: picpeakPath });
try {
await zip.extract(null, staging);
} finally {
await zip.close();
}
const dataDir = path.join(staging, 'data');
// Only touch tables that (a) the uploaded manifest lists AND (b) actually
// exist as real tables in THIS database. listDataTables() already excludes
// knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted
// .picpeak can never make the restore delete the migration bookkeeping — or
// any table that isn't a genuine data table here.
const dbTables = new Set(await listDataTables());
const manifestTables = Object.keys(manifest.tables || {});
const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl));
const skipped = manifestTables.filter((tbl) => !tables.includes(tbl));
if (skipped.length) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
logger.info(
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
);
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
} finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
};