From 38b3aef63d0d4f0878eddeb93f16319ef87f2372 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:13:58 +0200 Subject: [PATCH 01/12] feat(backup): .picpeak portable export (engine-neutral logical snapshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the GUI-only backup roundtrip. Adds a self-describing ".picpeak" archive that can be downloaded from one instance and (later) re-uploaded to another via the web UI only. - picpeakExportService.createPicpeak(): dumps every table as NDJSON (tables introspected at runtime — no hardcoded list, won't rot), plus a manifest (format version, app version, DB engine, latest migration, per-table row counts + checksums, includePhotos, contains_secrets), plus files/ (business-docs + uploads always; original gallery photos only when includePhotos). NDJSON is engine-neutral so the target rebuilds schema via migrations then loads rows — enabling pg↔pg / sqlite↔sqlite and forward-only auto-migrate. - GET /admin/backup/picpeak/export?includePhotos= streams the file and sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass, admin hashes, API keys — the UI must warn). - Purely additive: no existing backup/restore path is touched. Integration test proves the archive shape, knex-table exclusion, and row-count/NDJSON consistency (85 tables on the seed schema). --- .../integration/picpeakExport.test.js | 94 ++++++++ backend/src/routes/adminBackup.js | 27 +++ backend/src/services/picpeakExportService.js | 228 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 backend/__tests__/integration/picpeakExport.test.js create mode 100644 backend/src/services/picpeakExportService.js diff --git a/backend/__tests__/integration/picpeakExport.test.js b/backend/__tests__/integration/picpeakExport.test.js new file mode 100644 index 00000000..bb443d6c --- /dev/null +++ b/backend/__tests__/integration/picpeakExport.test.js @@ -0,0 +1,94 @@ +'use strict'; + +// Validates the engine-neutral .picpeak export: it must produce a real zip with +// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo +// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const StreamZip = require('node-stream-zip'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let createPicpeak; + +// bootCrmDb MUST run before requiring the service (which transitively requires +// db.js) so the export reads this test's DB, not the default path. +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir + ({ createPicpeak } = require('../../src/services/picpeakExportService')); +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +async function readZip(filePath) { + const zip = new StreamZip.async({ file: filePath }); + const entries = Object.keys(await zip.entries()); + const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8')); + await zip.close(); + return { entries, manifest }; +} + +describe('picpeak export (.picpeak logical export)', () => { + it('produces a .picpeak with a manifest and per-table NDJSON', async () => { + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + expect(filePath.endsWith('.picpeak')).toBe(true); + expect(fs.existsSync(filePath)).toBe(true); + + expect(manifest.format).toBe(1); + expect(manifest.kind).toBe('picpeak-backup'); + expect(manifest.database.engine).toBe('sqlite'); + expect(manifest.options.includePhotos).toBe(false); + expect(manifest.contains_secrets).toBe(true); + // Migrations seed real tables (e.g. app_settings) — expect several. + expect(Object.keys(manifest.tables).length).toBeGreaterThan(0); + expect(Object.keys(manifest.tables)).toContain('app_settings'); + + const { entries, manifest: zipped } = await readZip(filePath); + expect(entries).toContain('manifest.json'); + expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true); + expect(entries).toContain('data/app_settings.ndjson'); + // Manifest inside the zip matches the returned one. + expect(zipped.tables).toEqual(manifest.tables); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('never exports knex bookkeeping tables', async () => { + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + const names = Object.keys(manifest.tables); + expect(names).not.toContain('knex_migrations'); + expect(names).not.toContain('knex_migrations_lock'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('row counts in the manifest match the NDJSON line counts', async () => { + // Insert a couple of settings so at least one table is non-empty. + await db('app_settings') + .insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' }) + .onConflict('setting_key').merge(); + + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + const zip = new StreamZip.async({ file: filePath }); + const buf = await zip.entryData('data/app_settings.ndjson'); + await zip.close(); + const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0); + expect(lines.length).toBe(manifest.tables.app_settings.rowCount); + expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 7a030edc..e7904a2a 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -131,6 +131,33 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r } }); +// Generate + download a portable ".picpeak" export — an engine-neutral logical +// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to +// another instance via the web UI. `?includePhotos=true` also bundles original +// gallery photos (larger); otherwise the admin re-uploads them per gallery. +// +// SECURITY: the file contains plaintext secrets (SMTP password, admin password +// hashes, API keys). The download UI must warn before offering it. We surface +// the flag as a response header too so the client can double-confirm. +router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => { + const fsSync = require('fs'); + try { + const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1'; + const { createPicpeak } = require('../services/picpeakExportService'); + const { filePath } = await createPicpeak({ includePhotos }); + const filename = path.basename(filePath); + res.setHeader('X-Picpeak-Contains-Secrets', 'true'); + res.download(filePath, filename, (err) => { + // Best-effort cleanup of the temp .picpeak (and its temp dir) after send. + fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {}); + if (err) logger.error('[picpeak-export] download failed', { error: err.message }); + }); + } catch (error) { + logger.error('[picpeak-export] failed to create export', { error: error.message }); + if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' }); + } +}); + // Get backup run details router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try { diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js new file mode 100644 index 00000000..663ca252 --- /dev/null +++ b/backend/src/services/picpeakExportService.js @@ -0,0 +1,228 @@ +'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; + } +} + +// Stream one table to /.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. +async function writeTableNdjson(table, dataDir) { + const outPath = path.join(dataDir, `${table}.ndjson`); + const out = fs.createWriteStream(outPath, { encoding: 'utf8' }); + const hash = crypto.createHash('sha256'); + let rowCount = 0; + + await new Promise((resolve, reject) => { + out.on('error', reject); + const stream = db(table).stream(); + stream.on('error', reject); + stream.on('data', (row) => { + const line = `${JSON.stringify(row)}\n`; + hash.update(line); + rowCount += 1; + if (!out.write(line)) { + stream.pause(); + out.once('drain', () => stream.resume()); + } + }); + stream.on('end', () => out.end(resolve)); + }); + + return { rowCount, 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`); + + 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(); + }); + + 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, + createPicpeak, + // exported for reuse/testing + listDataTables, + collectFiles, +}; From 422dfe1cc88ae277dc170e95b4746e507c31b23a Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:31:10 +0200 Subject: [PATCH 02/12] feat(setup): add "How will you use PicPeak?" feature-selection step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the admin account is created (and we're logged in), the wizard now shows an opt-in feature step instead of jumping straight to the dashboard. Grouped ticks (Client management / Accounting / Automation) map to the existing feature flags; galleries/analytics/userManagement stay always-on and are noted, not listed. - Selection is saved via the existing authenticated PUT /admin/feature-flags, whose server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in Accounting) — the wizard only sends raw ticks. - Labels/descriptions reuse settings.features..title/description so translations stay in sync (en + de verified for all 14 features). - Saving is best-effort: on failure the admin still enters the app and can set features later in Settings. - New en/de strings for the usage step. Option A (lean wizard): this is the feature-selection foundation; per-feature hard-required config steps + the restore-from-backup branch come next. --- frontend/src/i18n/locales/de.json | 9 +++ frontend/src/i18n/locales/en.json | 9 +++ frontend/src/pages/SetupPage.tsx | 111 ++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c231bd27..9842f66b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3467,6 +3467,15 @@ "subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen", "tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein", "accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto", + "usageSubtitle": "Wie möchten Sie PicPeak nutzen?", + "usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.", + "usageGroupCrm": "Kundenverwaltung", + "usageGroupAccounting": "Buchhaltung", + "usageGroupAutomation": "Automatisierung & Versand", + "usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.", + "usageSkip": "Nur mit Galerien fortfahren", + "finish": "Einrichtung abschließen", + "featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.", "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e5c89510..403b4f9d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3363,6 +3363,15 @@ "subtitle": "Create your administrator account to get started", "tokenStepSubtitle": "First, enter your one-time setup token", "accountStepSubtitle": "Now create your administrator account", + "usageSubtitle": "How will you use PicPeak?", + "usageAlwaysOn": "Galleries, analytics and user management are always included. Pick any extras below — you can change these anytime in Settings.", + "usageGroupCrm": "Client management", + "usageGroupAccounting": "Accounting", + "usageGroupAutomation": "Automation & delivery", + "usageDepsNote": "Invoices automatically enable the Accounting area.", + "usageSkip": "Continue with galleries only", + "finish": "Finish setup", + "featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.", "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 789a8db4..4cca4fa0 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Button, Input, Card, Loading } from '../components/common'; import { useAdminAuth } from '../contexts'; import { setupService } from '../services/setup.service'; +import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -16,6 +17,19 @@ import type { AdminUser } from '../types'; const SETUP_DOCS_URL = 'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account'; +// "How will you use PicPeak?" — the opt-in feature groups shown after the admin +// account is created. galleries/analytics/userManagement are always on and not +// listed. Labels/descriptions reuse the existing Settings→Features i18n keys +// (`settings.features..title/description`) so translations stay in sync. +// Server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in +// Accounting) when we PUT the selection, so we only send the raw ticks. +const USAGE_GROUPS: { id: string; titleKey: string; features: FeatureKey[] }[] = [ + { id: 'crm', titleKey: 'setup.usageGroupCrm', features: ['quotes', 'contracts', 'bills', 'hoursLogging', 'customerPortal', 'calendar'] }, + { id: 'accounting', titleKey: 'setup.usageGroupAccounting', features: ['taxReport', 'incomingInvoices', 'expenses'] }, + { id: 'automation', titleKey: 'setup.usageGroupAutomation', features: ['reminderEmails', 'slideshow', 'workflows', 'whatsapp', 'incomingMail'] }, +]; +const ALL_USAGE_FEATURES: FeatureKey[] = USAGE_GROUPS.flatMap((g) => g.features); + // First-run screen. Reached on a fresh instance where no admin account exists // yet — creates the first (super_admin) account from the browser using the // one-time setup token printed to the server logs. Once an admin exists the @@ -37,13 +51,15 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isVerifyingToken, setIsVerifyingToken] = useState(false); const [copied, setCopied] = useState(false); const [errors, setErrors] = useState>({}); + const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); + const [isSavingFeatures, setIsSavingFeatures] = useState(false); if (statusLoading) { return ; @@ -144,7 +160,10 @@ export const SetupPage: React.FC = () => { }; login('', adminUser); toast.success(t('setup.success')); - navigate('/admin/dashboard', { replace: true }); + // Admin now exists and we're logged in (cookie set) — advance to the + // opt-in "How will you use PicPeak?" step rather than jumping straight to + // the dashboard. Authenticated calls (feature flags) work from here. + setStep('usage'); } catch (error: any) { const httpStatus = error.response?.status; const data = error.response?.data; @@ -184,7 +203,33 @@ export const SetupPage: React.FC = () => { } }; - const stepNumber = step === 'token' ? 1 : 2; + const toggleFeature = (key: FeatureKey) => { + setSelectedFeatures((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // Persist the feature selection, then enter the app. Saving is best-effort — + // if it fails the admin can still flip features later in Settings, so we don't + // trap them on the setup screen. + const finishSetup = async () => { + setIsSavingFeatures(true); + try { + const flags: Partial = {}; + for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key); + await featureFlagsService.update(flags); + } catch (_) { + toast.warn(t('setup.featuresSaveFailed')); + } finally { + setIsSavingFeatures(false); + navigate('/admin/dashboard', { replace: true }); + } + }; + + const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return (
@@ -204,10 +249,14 @@ export const SetupPage: React.FC = () => { })()}

{t('setup.title')}

- {step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')} + {step === 'token' + ? t('setup.tokenStepSubtitle') + : step === 'account' + ? t('setup.accountStepSubtitle') + : t('setup.usageSubtitle')}

- {t('setup.stepOf', { current: stepNumber, total: 2 })} + {t('setup.stepOf', { current: stepNumber, total: 3 })}

@@ -271,7 +320,7 @@ export const SetupPage: React.FC = () => { {t('setup.continue')} - ) : ( + ) : step === 'account' ? (
+ ) : ( +
+

+ {t('setup.usageAlwaysOn')} +

+ + {USAGE_GROUPS.map((group) => ( +
+

{t(group.titleKey)}

+
+ {group.features.map((key) => ( + + ))} +
+
+ ))} + + {selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && ( +

{t('setup.usageDepsNote')}

+ )} + + +
)} From 2920d82186d16d43674703d09370aeecec6a0da0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:35:48 +0200 Subject: [PATCH 03/12] feat(backup): .picpeak import/restore (full override, keeps current account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiving half of the roundtrip. picpeakImportService.importFromPicpeak(): - Validates the manifest: rejects non-picpeak files, a newer format, an engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER schema than this instance (forward-only). knex_migrations absence is tolerated (test harnesses). - Snapshots the current logged-in admin, then wipes + reloads every table from the backup NDJSON in one transaction with FK enforcement suspended (pg: session_replication_role=replica reset before commit; sqlite: defer_foreign_keys). knex_migrations is never touched, so the target's schema/migration state is preserved. - Re-injects the current account so the operator is never locked out; a backup admin colliding on email is overwritten with the current creds. - Restores files/ into storage and detects external-media references so the caller can prompt to reconfigure the mount. Roundtrip integration test proves: backup data restored, current account survives a full override (different email → added), and the email-collision case keeps the operator's password. --- .../integration/picpeakRoundtrip.test.js | 119 ++++++++++ backend/src/services/picpeakImportService.js | 219 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 backend/__tests__/integration/picpeakRoundtrip.test.js create mode 100644 backend/src/services/picpeakImportService.js diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js new file mode 100644 index 00000000..e31ee1d7 --- /dev/null +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -0,0 +1,119 @@ +'use strict'; + +// Full .picpeak roundtrip on a temp SQLite DB: +// 1. seed a "backup" instance (admin A + a marker setting) +// 2. export → .picpeak +// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data +// 4. import the backup with currentAdminId = B +// 5. assert the backup data is restored AND the current account (B) survives, +// while the backup's admin (A) is also present (different email → added). +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let createPicpeak; +let importFromPicpeak; +let superAdminRoleId; + +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; + ({ createPicpeak } = require('../../src/services/picpeakExportService')); + ({ importFromPicpeak } = require('../../src/services/picpeakImportService')); + const role = await db('roles').where({ name: 'super_admin' }).first(); + superAdminRoleId = role.id; +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +const adminRow = (email, hash) => ({ + username: email, + email, + password_hash: hash, + role_id: superAdminRoleId, + is_active: true, + must_change_password: false, + created_at: new Date(), + updated_at: new Date(), +}); + +async function setMarker(value) { + await db('app_settings') + .insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' }) + .onConflict('setting_key').merge(); +} +async function getMarker() { + const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first(); + return row ? JSON.parse(row.setting_value) : null; +} + +describe('.picpeak roundtrip (export → import)', () => { + it('restores backup data and preserves the current account', async () => { + // 1. Seed the "source" instance. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A')); + await setMarker('from_backup'); + + // 2. Export. + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // 3. Simulate a reinstall: fresh current admin B, mutated data. + await db('admin_users').del(); + const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id'); + const currentAdminId = typeof bId === 'object' ? bId.id : bId; + await setMarker('mutated_after_backup'); + + // 4. Import, preserving the current admin. + const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId }); + expect(result.restored).toBe(true); + expect(result.tables).toBeGreaterThan(0); + + // 5a. Backup data restored (marker reverted to the backup value). + expect(await getMarker()).toBe('from_backup'); + + // 5b. The backup's admin is present (different email → added). + const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first(); + expect(a).toBeTruthy(); + expect(a.password_hash).toBe('HASH_A'); + + // 5c. The current account SURVIVES the override, with its own credentials. + const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first(); + expect(b).toBeTruthy(); + expect(b.password_hash).toBe('HASH_B'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('overwrites a backup admin that collides with the current account email', async () => { + // Source has an admin at the SAME email the current operator will use. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH')); + await setMarker('collision_case'); + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // Reinstall: current admin uses the same email but a NEW password. + await db('admin_users').del(); + const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id'); + const currentAdminId = typeof id === 'object' ? id.id : id; + + await importFromPicpeak({ picpeakPath: filePath, currentAdminId }); + + // Exactly one admin at that email, and it keeps the CURRENT password. + const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']); + expect(rows).toHaveLength(1); + expect(rows[0].password_hash).toBe('NEW_HASH'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js new file mode 100644 index 00000000..4b61391f --- /dev/null +++ b/backend/src/services/picpeakImportService.js @@ -0,0 +1,219 @@ +'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 } = 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); + } +} + +// 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()) await trx.raw("SET session_replication_role = 'replica'"); + 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) await trx.batchInsert(table, rows, 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'); + const tables = Object.keys(manifest.tables || {}); + + 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, +}; From 2b66f6d889f94848202faf02116efa94ade9bd12 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:37:17 +0200 Subject: [PATCH 04/12] feat(backup): upload + restore endpoint for .picpeak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/backup/picpeak/import — multipart upload of a .picpeak, streamed to a temp file (after auth, so unauthenticated requests can't push a large file to disk), then restored via picpeakImportService with currentAdminId = the logged-in operator (preserved across the override). Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to reconfigure the external-media mount. Temp upload is always unlinked. Completes the backend half of the GUI-only roundtrip (export download + import upload). Multipart is already allowed by the CSRF content-type guard. --- backend/src/routes/adminBackup.js | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index e7904a2a..d58a7751 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -158,6 +158,44 @@ router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), asy } }); +// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER +// auth so an unauthenticated request can't push a large file to disk. +const os = require('os'); +const multer = require('multer'); +const picpeakUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, os.tmpdir()), + filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`), + }), + limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large +}); + +// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of +// all data except the current logged-in account (the client shows an explicit +// confirmation before calling this). Returns `usesExternalMedia` so the UI can +// prompt the admin to reconfigure the external-media mount afterwards. +router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => { + const fsSync = require('fs'); + if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' }); + const picpeakPath = req.file.path; + try { + const { importFromPicpeak } = require('../services/picpeakImportService'); + const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id }); + res.json({ + success: true, + tables: result.tables, + filesRestored: result.filesRestored, + usesExternalMedia: result.usesExternalMedia, + }); + } catch (error) { + const status = error.statusCode || 500; + logger.error('[picpeak-import] restore failed', { error: error.message }); + res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation }); + } finally { + fsSync.unlink(picpeakPath, () => {}); + } +}); + // Get backup run details router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try { From 66d61c87cad1ac1994d61e81c8c06739aa0cb8c5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:45:16 +0200 Subject: [PATCH 05/12] feat(backup): .picpeak download + upload-restore UI in Backup Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a self-contained "Portable backup (.picpeak)" card to the Restore tab, completing the GUI-only roundtrip: - Download: optional "include original photos" toggle + a prominent plaintext-secrets warning, streams the file via a blob download. - Restore: file picker → destructive confirmation modal ("replaces ALL data except your current account, cannot be undone") → multipart upload to /admin/backup/picpeak/import → success summary. If the backup uses external media, shows a banner to reconfigure the mount, with a docs link. Kept separate from the legacy RestoreWizard (different format/flow). en+de strings added; dark-mode variants throughout. --- .../components/admin/PicpeakBackupCard.tsx | 210 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 22 +- frontend/src/i18n/locales/en.json | 22 +- frontend/src/pages/admin/BackupManagement.tsx | 6 +- 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/PicpeakBackupCard.tsx diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx new file mode 100644 index 00000000..de8072f3 --- /dev/null +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -0,0 +1,210 @@ +import React, { useRef, useState } from 'react'; +import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +import { Button, Card } from '../common'; +import { api } from '../../config/api'; + +interface RestoreResult { + tables: number; + filesRestored: number; + usesExternalMedia: boolean; +} + +// Portable ".picpeak" roundtrip: download a self-contained backup here, upload +// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data +// replaced) that keeps only the current account — hence the explicit confirm. +export const PicpeakBackupCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [includePhotos, setIncludePhotos] = useState(false); + const [downloading, setDownloading] = useState(false); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + + const handleDownload = async () => { + setDownloading(true); + try { + const res = await api.get('/admin/backup/picpeak/export', { + params: { includePhotos }, + responseType: 'blob', + }); + const cd = (res.headers['content-disposition'] as string) || ''; + const match = cd.match(/filename="?([^"]+)"?/); + const filename = (match && match[1]) || 'picpeak-backup.picpeak'; + const url = window.URL.createObjectURL(res.data as Blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (_) { + toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.')); + } finally { + setDownloading(false); + } + }; + + const onFilePick = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) setPendingFile(f); + e.target.value = ''; // let the user re-pick the same file after cancelling + }; + + const confirmRestore = async () => { + if (!pendingFile) return; + setRestoring(true); + try { + const fd = new FormData(); + fd.append('backup', pendingFile); + const res = await api.post('/admin/backup/picpeak/import', fd); + setResult(res.data); + setPendingFile(null); + toast.success(t('backup.picpeak.restoreDone', 'Backup restored.')); + } catch (e: any) { + const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.'); + toast.error(msg); + setPendingFile(null); + } finally { + setRestoring(false); + } + }; + + return ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ + {/* Download */} +
+ +
+ +

+ {t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')} +

+
+ +
+ +
+ + {/* Restore */} +
+

+ {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} +

+

+ {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} +

+ + + + {result && ( +
+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + + +

+ )} + +
+
+
+ )} +
+ + {/* Destructive confirmation */} + {pendingFile && ( +
+
+
+ +
+

+ {t('backup.picpeak.confirmTitle', 'Restore will delete all current data')} +

+

+ {t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')} +

+

{pendingFile.name}

+
+
+
+ + +
+
+
+ )} +
+ ); +}; + +PicpeakBackupCard.displayName = 'PicpeakBackupCard'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9842f66b..326f8ac1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1779,7 +1779,27 @@ "title": "E-Mail-Einstellungen" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portables Backup (.picpeak)", + "intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.", + "includePhotos": "Original-Galeriefotos einschließen (größere Datei)", + "secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.", + "download": ".picpeak herunterladen", + "downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.", + "restoreTitle": "Aus einer .picpeak wiederherstellen", + "restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.", + "chooseFile": ".picpeak-Datei auswählen…", + "restoreDone": "Backup wiederhergestellt.", + "restoreFailed": "Wiederherstellung fehlgeschlagen.", + "restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.", + "externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.", + "externalMediaLink": "Einrichtungsanleitung", + "reload": "App neu laden", + "confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten", + "confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.", + "confirmRestore": "Löschen & wiederherstellen" + } }, "branding": { "title": "Branding" diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 403b4f9d..84ce2a13 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1335,7 +1335,27 @@ "title": "Email Settings" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portable backup (.picpeak)", + "intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.", + "includePhotos": "Include original gallery photos (larger file)", + "secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.", + "download": "Download .picpeak", + "downloadFailed": "Could not create the backup file.", + "restoreTitle": "Restore from a .picpeak", + "restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.", + "chooseFile": "Choose .picpeak file…", + "restoreDone": "Backup restored.", + "restoreFailed": "Restore failed.", + "restoreSummary": "{{tables}} tables and {{files}} files restored.", + "externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.", + "externalMediaLink": "Setup guide", + "reload": "Reload app", + "confirmTitle": "Restore will delete all current data", + "confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.", + "confirmRestore": "Delete & restore" + } }, "branding": { "title": "Branding" diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index 1e6532e8..d0d74586 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; +import { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -225,7 +226,10 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( - setActiveTab('integrity')} /> +
+ + setActiveTab('integrity')} /> +
)} {activeTab === 'integrity' && ( From f57462f7984c64356a9f8acb63c7df3f93909e6e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:13:18 +0200 Subject: [PATCH 06/12] fix(backup): make .picpeak roundtrip work on Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests passed because neither reproduces on SQLite): - Export: knex `.stream()` pulls in the optional `pg-query-stream` module (not bundled) and throws on pg. Switched to a plain per-table `select` — works on both engines, no new dependency. Rows are DB metadata (blobs live under files/), so holding a table in memory is fine. - Import: the pg driver returns json/jsonb columns as parsed JS values, so re-inserting a scalar like the string "PicPeak" sent it unquoted and pg rejected it ("invalid input syntax for type json"). Now introspects each table's json/jsonb columns and re-serialises those values before insert (pg only; SQLite stores json as TEXT and round-trips as-is). Verified end-to-end on docker Postgres: export 85 tables, full-override import, current account preserved, post-backup data removed. --- backend/src/services/picpeakExportService.js | 34 ++++++++------------ backend/src/services/picpeakImportService.js | 28 +++++++++++++++- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js index 663ca252..0fb8f469 100644 --- a/backend/src/services/picpeakExportService.js +++ b/backend/src/services/picpeakExportService.js @@ -76,32 +76,26 @@ async function getLatestMigration() { } } -// Stream one table to /
.ndjson (one JSON object per line). +// Write one table to /
.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 out = fs.createWriteStream(outPath, { encoding: 'utf8' }); const hash = crypto.createHash('sha256'); - let rowCount = 0; - - await new Promise((resolve, reject) => { - out.on('error', reject); - const stream = db(table).stream(); - stream.on('error', reject); - stream.on('data', (row) => { - const line = `${JSON.stringify(row)}\n`; - hash.update(line); - rowCount += 1; - if (!out.write(line)) { - stream.pause(); - out.once('drain', () => stream.resume()); - } - }); - stream.on('end', () => out.end(resolve)); + const rows = await db(table).select('*'); + const lines = rows.map((row) => { + const line = JSON.stringify(row); + hash.update(`${line}\n`); + return line; }); - - return { rowCount, checksum: hash.digest('hex') }; + 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 diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index 4b61391f..ae37455d 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -99,6 +99,30 @@ async function reinjectCurrentAdmin(trx, currentAdmin) { } } +// 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 @@ -113,7 +137,9 @@ async function replaceAllTables(tables, dataDir, currentAdmin) { } for (const table of tables) { const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`)); - if (rows.length) await trx.batchInsert(table, rows, 100); + if (!rows.length) continue; + const jsonCols = await jsonColumnsFor(trx, table); + await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100); } await reinjectCurrentAdmin(trx, currentAdmin); From cc79b3d9ec6ff412f69ab04d24af77f9285e738d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:37:35 +0200 Subject: [PATCH 07/12] refactor(backup): move .picpeak download to the Dashboard tab Downloading a portable backup is a "make a backup" action, so it belongs next to "Run Backup Now" on the Dashboard, not under Restore. Split the combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard (Restore). The manifest stays bundled inside the .picpeak, so there is no separate manifest-only download for the portable format. --- .../components/admin/PicpeakBackupCard.tsx | 199 +++++++++--------- frontend/src/pages/admin/BackupManagement.tsx | 19 +- 2 files changed, 115 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx index de8072f3..10ae1e60 100644 --- a/frontend/src/components/admin/PicpeakBackupCard.tsx +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -6,23 +6,23 @@ import { useTranslation } from 'react-i18next'; import { Button, Card } from '../common'; import { api } from '../../config/api'; +// Portable ".picpeak" roundtrip, split across two Backup Manager tabs: +// - PicpeakExportCard → Dashboard (making a backup) +// - PicpeakRestoreCard → Restore (restoring a backup) +// The manifest is bundled inside the .picpeak, so there is no separate +// "manifest only" download here. + interface RestoreResult { tables: number; filesRestored: number; usesExternalMedia: boolean; } -// Portable ".picpeak" roundtrip: download a self-contained backup here, upload -// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data -// replaced) that keeps only the current account — hence the explicit confirm. -export const PicpeakBackupCard: React.FC = () => { +// ── Download half (Dashboard) ──────────────────────────────────────────────── +export const PicpeakExportCard: React.FC = () => { const { t } = useTranslation(); - const fileRef = useRef(null); const [includePhotos, setIncludePhotos] = useState(false); const [downloading, setDownloading] = useState(false); - const [pendingFile, setPendingFile] = useState(null); - const [restoring, setRestoring] = useState(false); - const [result, setResult] = useState(null); const handleDownload = async () => { setDownloading(true); @@ -49,6 +49,55 @@ export const PicpeakBackupCard: React.FC = () => { } }; + return ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ +
+ +
+ +

+ {t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')} +

+
+ +
+
+ ); +}; + +PicpeakExportCard.displayName = 'PicpeakExportCard'; + +// ── Restore half (Restore tab) ─────────────────────────────────────────────── +export const PicpeakRestoreCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + const onFilePick = (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (f) setPendingFile(f); @@ -77,99 +126,59 @@ export const PicpeakBackupCard: React.FC = () => { return (

- {t('backup.picpeak.title', 'Portable backup (.picpeak)')} + {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}

- {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} + {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}

+ + - {/* Download */} -
- -
- -

- {t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')} -

-
- -
- -
- - {/* Restore */} -
-

- {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} -

-

- {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} -

- - - - {result && ( -
-
- -
-

- {t('backup.picpeak.restoreDone', 'Backup restored.')} + {result && ( +

+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + +

-

- {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { - tables: result.tables, - files: result.filesRestored, - })} -

- {result.usesExternalMedia && ( -

- - - {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} - - {t('backup.picpeak.externalMediaLink', 'Setup guide')} - - - -

- )} - -
+ )} +
- )} -
+
+ )} {/* Destructive confirmation */} {pendingFile && ( @@ -207,4 +216,4 @@ export const PicpeakBackupCard: React.FC = () => { ); }; -PicpeakBackupCard.displayName = 'PicpeakBackupCard'; +PicpeakRestoreCard.displayName = 'PicpeakRestoreCard'; diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index d0d74586..b72d3acd 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,7 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; -import { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard'; +import { PicpeakExportCard, PicpeakRestoreCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -205,12 +205,15 @@ export const BackupManagement: React.FC = () => { {/* Tab Content */}
{activeTab === 'dashboard' && ( - manualBackupMutation.mutate()} - isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} - /> +
+ manualBackupMutation.mutate()} + isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} + /> + +
)} {activeTab === 'configuration' && ( @@ -227,7 +230,7 @@ export const BackupManagement: React.FC = () => { {activeTab === 'restore' && (
- + setActiveTab('integrity')} />
)} From d4b143f313d00f2a30abcbc1a880c140d7455e7b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:29:47 +0200 Subject: [PATCH 08/12] fix(setup): keep the first-run wizard light regardless of dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup page background used var(--color-background), which flips to #0a0a0a under the .dark class while the wizard card stays hardcoded light — giving a dark page + light card mismatch in dark mode. Pin the first-run screen to its intended light branded look (fixed #fafafa bg / #171717 text) so all three steps render consistently. --- frontend/src/pages/SetupPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 4cca4fa0..c2747f0a 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -232,7 +232,7 @@ export const SetupPage: React.FC = () => { const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return ( -
+
{/* On a fresh instance there are no branding settings yet, so use the @@ -247,15 +247,15 @@ export const SetupPage: React.FC = () => {
); })()} -

{t('setup.title')}

-

+

{t('setup.title')}

+

{step === 'token' ? t('setup.tokenStepSubtitle') : step === 'account' ? t('setup.accountStepSubtitle') : t('setup.usageSubtitle')}

-

+

{t('setup.stepOf', { current: stepNumber, total: 3 })}

From 86324e7da75069e61686b1b77495f02c33b12e1a Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:38:48 +0200 Subject: [PATCH 09/12] feat(backup): fold .picpeak restore into the Restore wizard's Upload source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the redundant standalone .picpeak card. The wizard's 'Upload Backup' source now splits into two kinds: '.picpeak backup' (the working portable restore — renders the upload + destructive-confirm flow inline) and 'Manifest + files' (legacy, still 'Manifest Upload functionality coming soon'). en + de strings added. --- .../src/components/admin/RestoreWizard.jsx | 46 +++++++++++++++++-- frontend/src/i18n/locales/de.json | 11 ++++- frontend/src/i18n/locales/en.json | 11 ++++- frontend/src/pages/admin/BackupManagement.tsx | 7 +-- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/admin/RestoreWizard.jsx b/frontend/src/components/admin/RestoreWizard.jsx index 74f6b047..87c72296 100644 --- a/frontend/src/components/admin/RestoreWizard.jsx +++ b/frontend/src/components/admin/RestoreWizard.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { PicpeakRestoreCard } from './PicpeakBackupCard'; import { RefreshCw, AlertTriangle, @@ -283,12 +284,47 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => { )} {restoreData.source === 'upload' && ( - -
- -

{t('backup.restore.source.upload.comingSoon')}

+
+ {/* Two upload kinds: the working portable .picpeak restore, and the + legacy manifest+files upload (still a stub). */} +
+ +
- + + {restoreData.uploadType === 'picpeak' && } + + {restoreData.uploadType === 'manifest' && ( + +
+ +

{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}

+
+
+ )} +
)}
); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 326f8ac1..befaace4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -634,7 +634,16 @@ "upload": { "name": "Backup hochladen", "description": "Eine Backup-Datei hochladen", - "comingSoon": "Upload-Funktion kommt bald" + "comingSoon": "Upload-Funktion kommt bald", + "manifestComingSoon": "Manifest-Upload-Funktion kommt bald", + "picpeak": { + "name": ".picpeak-Backup", + "description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)." + }, + "manifest": { + "name": "Manifest + Dateien", + "description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)." + } }, "configuration": { "s3": "S3-Konfiguration", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 84ce2a13..13d57bd0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3022,7 +3022,16 @@ "upload": { "name": "Upload Backup", "description": "Upload a backup file", - "comingSoon": "Upload functionality coming soon" + "comingSoon": "Upload functionality coming soon", + "manifestComingSoon": "Manifest Upload functionality coming soon", + "picpeak": { + "name": ".picpeak backup", + "description": "Portable full backup — restores everything (full override, keeps your current account)." + }, + "manifest": { + "name": "Manifest + files", + "description": "Upload a manifest and its backup files (legacy format)." + } }, "configuration": { "s3": "S3 Configuration", diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index b72d3acd..43b4a79c 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,7 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; -import { PicpeakExportCard, PicpeakRestoreCard } from '../../components/admin/PicpeakBackupCard'; +import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -229,10 +229,7 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( -
- - setActiveTab('integrity')} /> -
+ setActiveTab('integrity')} /> )} {activeTab === 'integrity' && ( From a95ee473ae7fb2bf3c94c610724476701145d113 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:57:50 +0200 Subject: [PATCH 10/12] feat(setup): add restore-from-backup branch to the first-run wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage step now offers 'Migrating from another PicPeak?' → a restore step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another instance onto this fresh one, preserving the account just created. en + de strings added. --- frontend/src/i18n/locales/de.json | 4 ++++ frontend/src/i18n/locales/en.json | 4 ++++ frontend/src/pages/SetupPage.tsx | 40 ++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index befaace4..f704d13e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3505,6 +3505,10 @@ "usageSkip": "Nur mit Galerien fortfahren", "finish": "Einrichtung abschließen", "featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.", + "restoreStepSubtitle": "Aus einem Backup wiederherstellen", + "restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?", + "restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.", + "restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.", "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 13d57bd0..d2e437e5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3401,6 +3401,10 @@ "usageSkip": "Continue with galleries only", "finish": "Finish setup", "featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.", + "restoreStepSubtitle": "Restore from a backup", + "restoreEntry": "Migrating from another PicPeak?", + "restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.", + "restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.", "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index c2747f0a..48fa2f0a 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -9,6 +9,7 @@ import { Button, Input, Card, Loading } from '../components/common'; import { useAdminAuth } from '../contexts'; import { setupService } from '../services/setup.service'; import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; +import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -51,7 +52,7 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account' | 'usage'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -253,11 +254,15 @@ export const SetupPage: React.FC = () => { ? t('setup.tokenStepSubtitle') : step === 'account' ? t('setup.accountStepSubtitle') - : t('setup.usageSubtitle')} -

-

- {t('setup.stepOf', { current: stepNumber, total: 3 })} + : step === 'restore' + ? t('setup.restoreStepSubtitle') + : t('setup.usageSubtitle')}

+ {step !== 'restore' && ( +

+ {t('setup.stepOf', { current: stepNumber, total: 3 })} +

+ )}
@@ -397,12 +402,21 @@ export const SetupPage: React.FC = () => {
- ) : ( + ) : step === 'usage' ? (

{t('setup.usageAlwaysOn')}

+ + {USAGE_GROUPS.map((group) => (

{t(group.titleKey)}

@@ -447,6 +461,20 @@ export const SetupPage: React.FC = () => { {selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
+ ) : ( +
+

{t('setup.restoreIntro')}

+ + +
)}
From 07b450a954a53781d23a71749552e4101c637777 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:10:54 +0200 Subject: [PATCH 11/12] feat(setup): per-feature config step after feature selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the chosen features need config the wizard can collect, 'Finish' on the usage step now advances to a lean config step instead of jumping to the dashboard: - Invoicing (if Invoices): company/legal name, address, VAT-ID or tax number, IBAN, currency → saved to business-profile + a default bank account. Carries the bank/VAT legal disclaimer. - Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/ user/pass/from → saved to email_configs. Each section persists only if started, and 'Skip for now' is always available — soft settings keep their seeded defaults. en + de strings. --- .../src/components/admin/SetupConfigStep.tsx | 153 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 26 +++ frontend/src/i18n/locales/en.json | 26 +++ frontend/src/pages/SetupPage.tsx | 26 ++- 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/admin/SetupConfigStep.tsx diff --git a/frontend/src/components/admin/SetupConfigStep.tsx b/frontend/src/components/admin/SetupConfigStep.tsx new file mode 100644 index 00000000..b6a3a200 --- /dev/null +++ b/frontend/src/components/admin/SetupConfigStep.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ShieldAlert } from 'lucide-react'; + +import { Button, Input } from '../common'; +import type { FeatureKey } from '../../services/featureFlags.service'; +import { businessProfileService } from '../../services/businessProfile.service'; +import { emailService, type EmailConfig } from '../../services/email.service'; + +// Features that need working SMTP to deliver anything. +const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills']; + +interface Props { + selectedFeatures: Set; + onDone: () => void; +} + +// Lean per-feature config, shown after the "How will you use PicPeak?" step. +// Only the sections a selected feature actually needs are rendered; everything +// else keeps its seeded defaults and is tunable later in Settings. Saving is +// best-effort per section — a failure never traps the user on setup. +export const SetupConfigStep: React.FC = ({ selectedFeatures, onDone }) => { + const { t } = useTranslation(); + const showInvoicing = selectedFeatures.has('bills'); + const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f)); + const [saving, setSaving] = useState(false); + + const [inv, setInv] = useState({ + companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '', + vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '', + }); + const [mail, setMail] = useState({ + smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '', + }); + + const invField = (k: keyof typeof inv) => (e: React.ChangeEvent) => + setInv((p) => ({ ...p, [k]: e.target.value })); + const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent) => + setMail((p) => ({ ...p, [k]: e.target.value })); + + const finish = async () => { + setSaving(true); + try { + // Invoicing: only persist if they actually started filling it in. + if (showInvoicing && inv.companyName.trim()) { + await businessProfileService.update({ + companyName: inv.companyName.trim(), + addressLine1: inv.addressLine1.trim(), + postalCode: inv.postalCode.trim(), + city: inv.city.trim(), + countryCode: inv.countryCode.trim(), + vatId: inv.vatId.trim(), + taxId: inv.taxId.trim(), + defaultCurrency: inv.defaultCurrency.trim() || 'CHF', + }); + if (inv.iban.trim()) { + await businessProfileService.createBankAccount({ + iban: inv.iban.replace(/\s+/g, ''), + accountHolder: inv.companyName.trim(), + currency: inv.defaultCurrency.trim() || 'CHF', + isDefault: true, + }); + } + } + // Email: only persist if a host was entered. + if (showEmail && mail.smtp_host.trim()) { + const port = parseInt(mail.smtp_port, 10) || 587; + const config: EmailConfig = { + smtp_host: mail.smtp_host.trim(), + smtp_port: port, + smtp_secure: port === 465, + smtp_user: mail.smtp_user.trim(), + smtp_pass: mail.smtp_pass, + from_email: mail.from_email.trim(), + from_name: mail.from_name.trim(), + tls_reject_unauthorized: true, + }; + await emailService.updateConfig(config); + } + } catch (_) { + toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.')); + } finally { + setSaving(false); + onDone(); + } + }; + + return ( +
+

+ {t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')} +

+ + {showInvoicing && ( +
+

{t('setup.config.invoicing', 'Invoicing details')}

+
+ +

+ {t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')} +

+
+ + +
+ +
+
+
+ + +
+
+ + +
+ +
+ )} + + {showEmail && ( +
+

{t('setup.config.email', 'Email delivery (SMTP)')}

+

{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}

+
+
+ +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+
+ ); +}; + +SetupConfigStep.displayName = 'SetupConfigStep'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f704d13e..3b761a57 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3509,6 +3509,32 @@ "restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?", "restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.", "restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.", + "config": { + "subtitle": "Richten Sie Ihre Funktionen ein", + "intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.", + "invoicing": "Rechnungsdaten", + "invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.", + "companyName": "Firma / rechtlicher Name", + "addressLine1": "Strasse und Nummer", + "postalCode": "PLZ", + "city": "Ort", + "countryCode": "Ländercode (z. B. CH)", + "currency": "Währung (z. B. CHF)", + "vatId": "MwSt-Nummer (oder leer lassen)", + "taxId": "Steuernummer (oder MwSt-Nummer)", + "iban": "IBAN (für Rechnungszahlungen)", + "email": "E-Mail-Versand (SMTP)", + "emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.", + "smtpHost": "SMTP-Host", + "smtpPort": "Port", + "smtpUser": "Benutzername", + "smtpPass": "Passwort", + "fromEmail": "Absenderadresse", + "fromName": "Absendername", + "skip": "Vorerst überspringen", + "finish": "Einrichtung abschließen", + "saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen." + }, "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d2e437e5..3ba9cb3c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3405,6 +3405,32 @@ "restoreEntry": "Migrating from another PicPeak?", "restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.", "restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.", + "config": { + "subtitle": "Set up your features", + "intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.", + "invoicing": "Invoicing details", + "invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.", + "companyName": "Company / legal name", + "addressLine1": "Street and number", + "postalCode": "Postal code", + "city": "City", + "countryCode": "Country code (e.g. CH)", + "currency": "Currency (e.g. CHF)", + "vatId": "VAT ID (or leave blank)", + "taxId": "Tax number (or VAT ID)", + "iban": "IBAN (for invoice payments)", + "email": "Email delivery (SMTP)", + "emailHint": "Required to send reminders, invoices and notifications.", + "smtpHost": "SMTP host", + "smtpPort": "Port", + "smtpUser": "Username", + "smtpPass": "Password", + "fromEmail": "From address", + "fromName": "From name", + "skip": "Skip for now", + "finish": "Finish setup", + "saveFailed": "Some settings could not be saved — you can finish them in Settings." + }, "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 48fa2f0a..790c01e7 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -10,6 +10,7 @@ import { useAdminAuth } from '../contexts'; import { setupService } from '../services/setup.service'; import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; +import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -52,7 +53,7 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -226,7 +227,15 @@ export const SetupPage: React.FC = () => { toast.warn(t('setup.featuresSaveFailed')); } finally { setIsSavingFeatures(false); - navigate('/admin/dashboard', { replace: true }); + // If the chosen features need config the wizard can collect (invoicing, + // email), go to the config step; otherwise enter the app. + const needsConfig = + selectedFeatures.has('bills') || + selectedFeatures.has('reminderEmails') || + selectedFeatures.has('incomingMail') || + selectedFeatures.has('whatsapp'); + if (needsConfig) setStep('config'); + else navigate('/admin/dashboard', { replace: true }); } }; @@ -256,9 +265,11 @@ export const SetupPage: React.FC = () => { ? t('setup.accountStepSubtitle') : step === 'restore' ? t('setup.restoreStepSubtitle') - : t('setup.usageSubtitle')} + : step === 'config' + ? t('setup.config.subtitle') + : t('setup.usageSubtitle')}

- {step !== 'restore' && ( + {(step === 'token' || step === 'account' || step === 'usage') && (

{t('setup.stepOf', { current: stepNumber, total: 3 })}

@@ -461,7 +472,7 @@ export const SetupPage: React.FC = () => { {selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
- ) : ( + ) : step === 'restore' ? (

{t('setup.restoreIntro')}

@@ -475,6 +486,11 @@ export const SetupPage: React.FC = () => { {t('setup.back')}
+ ) : ( + navigate('/admin/dashboard', { replace: true })} + /> )}
From fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:39:13 +0200 Subject: [PATCH 12/12] =?UTF-8?q?fix(backup):=20address=20.picpeak=20revie?= =?UTF-8?q?w=20=E2=80=94=20table=20filter,=20superuser=20guard,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the-luap's review: - Import no longer trusts manifest.tables blindly. It now intersects the manifest's table list with the real data tables of THIS database (listDataTables(), which already excludes knex_migrations/_lock) and drops anything else. A crafted/corrupted .picpeak listing knex_migrations or a non-existent table can no longer wipe it; skipped tables are logged. - The Postgres session_replication_role='replica' SET (needs superuser) is now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are deleted (transaction rolls back) and surfaces a clear, actionable 400 instead of a cryptic permission error. - Export: on an archiver error, the temp out dir (a partial plaintext-secret archive) is now removed instead of orphaned. Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused, non-picpeak rejection, and files/ restored + filesRestored asserted. --- .../integration/picpeakRoundtrip.test.js | 63 ++++++++++++++++++- backend/src/services/picpeakExportService.js | 43 ++++++++----- backend/src/services/picpeakImportService.js | 34 ++++++++-- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js index e31ee1d7..8e26de07 100644 --- a/backend/__tests__/integration/picpeakRoundtrip.test.js +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -18,13 +18,14 @@ let cleanup; let tmpDir; let createPicpeak; let importFromPicpeak; +let validateManifest; let superAdminRoleId; beforeAll(async () => { ({ db, cleanup, tmpDir } = await bootCrmDb()); process.env.STORAGE_PATH = tmpDir; ({ createPicpeak } = require('../../src/services/picpeakExportService')); - ({ importFromPicpeak } = require('../../src/services/picpeakImportService')); + ({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService')); const role = await db('roles').where({ name: 'super_admin' }).first(); superAdminRoleId = role.id; }, 60000); @@ -116,4 +117,64 @@ describe('.picpeak roundtrip (export → import)', () => { fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); } }); + + it('restores files/ and reports filesRestored', async () => { + // A business-doc that lives in storage → travels in the backup. + const docDir = path.join(tmpDir, 'business-docs'); + const marker = path.join(docDir, 'roundtrip-doc.txt'); + fs.mkdirSync(docDir, { recursive: true }); + fs.writeFileSync(marker, 'hello'); + await db('admin_users').del(); + const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id'); + const currentAdminId = typeof id === 'object' ? id.id : id; + + const { filePath } = await createPicpeak({ includePhotos: false }); + try { + fs.rmSync(marker); // delete on disk so the restore must bring it back + const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId }); + expect(result.filesRestored).toBeGreaterThanOrEqual(1); + expect(fs.existsSync(marker)).toBe(true); + expect(fs.readFileSync(marker, 'utf8')).toBe('hello'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + fs.rmSync(docDir, { recursive: true, force: true }); + } + }); +}); + +describe('.picpeak manifest validation', () => { + it('rejects a database-engine mismatch', async () => { + // Harness runs on SQLite, so a pg manifest must be refused. + const blockers = await validateManifest({ + kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {}, + }); + expect(blockers.some((b) => /engine/i.test(b))).toBe(true); + }); + + it('rejects a backup from a newer schema (forward-only)', async () => { + // validateManifest reads knex_migrations for the target's latest migration; + // the harness has none, so create it with an older migration than the backup. + await db.schema.createTable('knex_migrations', (t) => { + t.increments('id'); + t.string('name'); + t.integer('batch'); + t.timestamp('migration_time'); + }); + try { + await db('knex_migrations').insert({ name: '100_baseline', batch: 1 }); + const blockers = await validateManifest({ + kind: 'picpeak-backup', format: 1, + database: { engine: 'sqlite', latest_migration: '999_from_the_future' }, + tables: {}, + }); + expect(blockers.some((b) => /newer/i.test(b))).toBe(true); + } finally { + await db.schema.dropTableIfExists('knex_migrations'); + } + }); + + it('rejects a file that is not a PicPeak backup', async () => { + const blockers = await validateManifest({ some: 'random-json' }); + expect(blockers.length).toBeGreaterThan(0); + }); }); diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js index 0fb8f469..561f6f54 100644 --- a/backend/src/services/picpeakExportService.js +++ b/backend/src/services/picpeakExportService.js @@ -184,23 +184,31 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { const stamp = manifest.created_at.replace(/[:.]/g, '-'); const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`); - 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(); - }); + 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})` @@ -215,6 +223,7 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { module.exports = { PICPEAK_FORMAT_VERSION, + EXCLUDED_TABLES, createPicpeak, // exported for reuse/testing listDataTables, diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index ae37455d..b1a6b0f3 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -23,7 +23,7 @@ const knexConfig = require('../../knexfile'); const { getStoragePath } = require('../config/storage'); const { hasColumnCached } = require('../utils/schemaCache'); const logger = require('../utils/logger'); -const { PICPEAK_FORMAT_VERSION } = require('./picpeakExportService'); +const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService'); const isPostgres = () => knexConfig.client === 'pg'; @@ -129,8 +129,23 @@ function serialiseJsonColumns(rows, jsonCols) { // 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()) await trx.raw("SET session_replication_role = 'replica'"); - else await trx.raw('PRAGMA defer_foreign_keys = ON'); + 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 instance’s 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(); @@ -223,7 +238,18 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) { } const dataDir = path.join(staging, 'data'); - const tables = Object.keys(manifest.tables || {}); + // 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);