diff --git a/.gitignore b/.gitignore index d03cce16..88fd5b98 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ tmp/ # Internal docs + Claude instructions: kept local-only, not in the shared repo docs/ /CLAUDE.md + +# Client-facing feature screenshots (real PII — do not commit) +docs/feature-screenshots/ diff --git a/package.json b/package.json index 5a2e4cc0..aa401d5a 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", + "ssh2-sftp-client": "^12.1.1", "svgo": "^4.0.1", "tailwind-merge": "^3.6.0", "tesseract.js": "^7.0.0", @@ -154,6 +155,7 @@ "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/ssh2-sftp-client": "^9.0.6", "@types/topojson-client": "^3.1.5", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2a9e759..8498162c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + ssh2-sftp-client: + specifier: ^12.1.1 + version: 12.1.1 svgo: specifier: ^4.0.1 version: 4.0.1 @@ -374,6 +377,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/ssh2-sftp-client': + specifier: ^9.0.6 + version: 9.0.6 '@types/topojson-client': specifier: ^3.1.5 version: 3.1.5 @@ -3095,6 +3101,9 @@ packages: '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} @@ -3127,6 +3136,12 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/ssh2-sftp-client@9.0.6': + resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} @@ -3582,6 +3597,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3694,6 +3712,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-auth@1.6.11: resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} peerDependencies: @@ -3856,6 +3877,10 @@ packages: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bullmq@5.76.8: resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==} engines: {node: '>=12.22.0'} @@ -3995,6 +4020,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + conf@15.1.0: resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} engines: {node: '>=20'} @@ -4023,6 +4052,10 @@ packages: country-flag-icons@1.6.17: resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -5723,6 +5756,9 @@ packages: msgpackr@2.0.1: resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} + nan@2.27.0: + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6629,6 +6665,14 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + ssh2-sftp-client@12.1.1: + resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==} + engines: {node: '>=18.20.4'} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -6966,6 +7010,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6994,6 +7041,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.59.3: resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7021,6 +7071,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -9990,6 +10043,10 @@ snapshots: '@types/node@14.18.63': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@20.19.41': dependencies: undici-types: 6.21.0 @@ -10030,6 +10087,14 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/ssh2-sftp-client@9.0.6': + dependencies: + '@types/ssh2': 1.15.5 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/tedious@4.0.14': dependencies: '@types/node': 20.19.41 @@ -10582,6 +10647,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -10663,6 +10732,10 @@ snapshots: baseline-browser-mapping@2.10.29: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -10803,6 +10876,9 @@ snapshots: buffers@0.1.1: {} + buildcheck@0.0.7: + optional: true + bullmq@5.76.8: dependencies: cron-parser: 4.9.0 @@ -10936,6 +11012,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + conf@15.1.0: dependencies: ajv: 8.20.0 @@ -10971,6 +11054,12 @@ snapshots: country-flag-icons@1.6.17: {} + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.27.0 + optional: true + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -12736,6 +12825,9 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 + nan@2.27.0: + optional: true + nanoid@3.3.12: {} nanostores@1.3.0: {} @@ -13765,6 +13857,19 @@ snapshots: split2@4.2.0: {} + ssh2-sftp-client@12.1.1: + dependencies: + concat-stream: 2.0.0 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.27.0 + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -14103,6 +14208,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -14146,6 +14253,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) @@ -14172,6 +14281,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici@7.25.0: {} diff --git a/scripts/create-full-backup.ts b/scripts/create-full-backup.ts new file mode 100644 index 00000000..b5ffdf78 --- /dev/null +++ b/scripts/create-full-backup.ts @@ -0,0 +1,48 @@ +/** + * Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json) + * to a local file. Same code path as the admin "Download full backup" button + * (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for + * rehearsing the restore runbook (docs/backup-restore-runbook.md). + * + * pnpm tsx scripts/create-full-backup.ts [outfile.tar] + * + * Defaults the output name to ./pn-crm-backup-.tar in the CWD. + */ +import 'dotenv/config'; + +import { copyFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { createFullBackupTar } from '@/lib/services/backup-export.service'; +import { logger } from '@/lib/logger'; + +async function main(): Promise { + const { tarPath, filename, manifest, cleanup } = await createFullBackupTar(); + try { + const dest = path.resolve(process.argv[2] ?? filename); + await copyFile(tarPath, dest); + logger.info( + { + dest, + storageBackend: manifest.storageBackend, + dbDumpBytes: manifest.database.sizeBytes, + blobs: manifest.counts.blobs, + blobBytes: manifest.counts.blobBytes, + skipped: manifest.counts.skipped, + }, + 'Full backup written', + ); + if (manifest.skipped.length) { + logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage'); + } + } finally { + await cleanup(); + } +} + +main() + .then(() => process.stdout.write('', () => process.exit(0))) + .catch((err) => { + logger.error({ err }, 'Full backup failed'); + process.stderr.write('', () => process.exit(1)); + }); diff --git a/scripts/decrypt-backup.ts b/scripts/decrypt-backup.ts new file mode 100644 index 00000000..98aea24d --- /dev/null +++ b/scripts/decrypt-backup.ts @@ -0,0 +1,31 @@ +/** + * Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination + * has bundle encryption enabled. Restore step — see + * docs/backup-restore-runbook.md. + * + * BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts + * + * The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of + * shell history / the process list). + */ +import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption'; + +async function main(): Promise { + const [input, output] = process.argv.slice(2); + const passphrase = process.env.BACKUP_PASSPHRASE; + if (!input || !output) { + throw new Error( + 'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts ', + ); + } + if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment'); + await decryptFileToFile(input, output, passphrase); + process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0)); +} + +main().catch((err) => { + process.stderr.write( + `Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`, + () => process.exit(1), + ); +}); diff --git a/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx b/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx index 465cb8b5..32270c49 100644 --- a/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/backup/page.tsx @@ -1,4 +1,5 @@ import { BackupAdminPanel } from '@/components/admin/backup-admin-panel'; +import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card'; import { PageHeader } from '@/components/shared/page-header'; export default function BackupManagementPage() { @@ -7,9 +8,10 @@ export default function BackupManagementPage() { + ); } diff --git a/src/app/api/v1/admin/backup/destinations/[id]/route.ts b/src/app/api/v1/admin/backup/destinations/[id]/route.ts new file mode 100644 index 00000000..faf6f410 --- /dev/null +++ b/src/app/api/v1/admin/backup/destinations/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { + deleteDestination, + updateDestination, + type DestinationInput, +} from '@/lib/services/backup-destinations.service'; +import { backupDestinationSchema } from '@/lib/validators/backup-destinations'; + +export const runtime = 'nodejs'; + +/** Update a backup destination. Super-admin only. */ +export const PUT = withAuth(async (req, ctx, params) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.update'); + const id = params.id; + if (!id) throw new NotFoundError('Backup destination'); + const body = await parseBody(req, backupDestinationSchema); + const updated = await updateDestination(id, body as DestinationInput); + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'backup_destination', + entityId: id, + severity: 'warning', + metadata: { name: updated.name, type: updated.type, enabled: updated.enabled }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } +}); + +/** Delete a backup destination. Super-admin only. */ +export const DELETE = withAuth(async (_req, ctx, params) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.delete'); + const id = params.id; + if (!id) throw new NotFoundError('Backup destination'); + await deleteDestination(id); + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'delete', + entityType: 'backup_destination', + entityId: id, + severity: 'warning', + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/admin/backup/destinations/[id]/run/route.ts b/src/app/api/v1/admin/backup/destinations/[id]/run/route.ts new file mode 100644 index 00000000..8d818698 --- /dev/null +++ b/src/app/api/v1/admin/backup/destinations/[id]/run/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { pushBackupToDestination } from '@/lib/services/backup-destinations.service'; + +export const runtime = 'nodejs'; +// A full backup (pg_dump + every blob) is assembled before the push, so allow +// a long run on large datasets. +export const maxDuration = 3600; + +/** Assemble a fresh full backup and push it to this destination now. Super-admin. */ +export const POST = withAuth(async (_req, ctx, params) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.run'); + const id = params.id; + if (!id) throw new NotFoundError('Backup destination'); + const result = await pushBackupToDestination(id, { + trigger: 'manual', + triggeredBy: ctx.userId, + }); + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'backup_export', + entityType: 'backup_destination', + entityId: id, + severity: 'warning', + metadata: { bytes: result.bytes, remoteRef: result.remoteRef }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/admin/backup/destinations/[id]/test/route.ts b/src/app/api/v1/admin/backup/destinations/[id]/test/route.ts new file mode 100644 index 00000000..f323e1c2 --- /dev/null +++ b/src/app/api/v1/admin/backup/destinations/[id]/test/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { testDestination } from '@/lib/services/backup-destinations.service'; + +export const runtime = 'nodejs'; + +/** + * Test connectivity to a destination (connect + verify the target dir/bucket). + * Returns `{ data: { ok: true } }` or a structured error the UI can surface. + * Super-admin only. + */ +export const POST = withAuth(async (_req, ctx, params) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.test'); + const id = params.id; + if (!id) throw new NotFoundError('Backup destination'); + await testDestination(id); + return NextResponse.json({ data: { ok: true } }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/admin/backup/destinations/route.ts b/src/app/api/v1/admin/backup/destinations/route.ts new file mode 100644 index 00000000..176009f9 --- /dev/null +++ b/src/app/api/v1/admin/backup/destinations/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse } from '@/lib/errors'; +import { + createDestination, + listDestinations, + type DestinationInput, +} from '@/lib/services/backup-destinations.service'; +import { backupDestinationSchema } from '@/lib/validators/backup-destinations'; + +export const runtime = 'nodejs'; + +/** List configured backup destinations (secrets masked). Super-admin only. */ +export const GET = withAuth(async (_req, ctx) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.list'); + return NextResponse.json({ data: await listDestinations() }); + } catch (error) { + return errorResponse(error); + } +}); + +/** Create a backup destination. Super-admin only. */ +export const POST = withAuth(async (req, ctx) => { + try { + requireSuperAdmin(ctx, 'admin.backup.destinations.create'); + const body = await parseBody(req, backupDestinationSchema); + const created = await createDestination(body as DestinationInput); + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'create', + entityType: 'backup_destination', + entityId: created.id, + severity: 'warning', + metadata: { name: created.name, type: created.type, enabled: created.enabled }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: created }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/admin/backup/export/route.ts b/src/app/api/v1/admin/backup/export/route.ts new file mode 100644 index 00000000..7ca259f8 --- /dev/null +++ b/src/app/api/v1/admin/backup/export/route.ts @@ -0,0 +1,71 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { Readable } from 'node:stream'; + +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse } from '@/lib/errors'; +import { createFullBackupTar } from '@/lib/services/backup-export.service'; + +export const runtime = 'nodejs'; +// A full backup pg_dumps the DB and streams every blob; on a large dataset the +// assembly phase can run for a while before the download starts. Lift the +// platform timeout accordingly (no-op on hosts without a hard cap). +export const maxDuration = 3600; + +/** + * Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json) + * as a tar download. Super-admin only — this egresses every tenant's data. + * + * The bundle is assembled to a temp file first, so any failure (pg_dump, + * storage read) surfaces as a clean JSON error *before* the download begins + * rather than as a truncated tar. The temp file is removed once the response + * stream closes (including on client disconnect). + */ +export const GET = withAuth(async (_req, ctx) => { + try { + requireSuperAdmin(ctx, 'admin.backup.export'); + + const { tarPath, filename, manifest, cleanup } = await createFullBackupTar(); + + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'backup_export', + entityType: 'system_backup', + entityId: filename, + severity: 'warning', + source: 'user', + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + metadata: { + storageBackend: manifest.storageBackend, + blobs: manifest.counts.blobs, + blobBytes: manifest.counts.blobBytes, + skipped: manifest.counts.skipped, + dbDumpBytes: manifest.database.sizeBytes, + }, + }); + + const { size } = await stat(tarPath); + const nodeStream = createReadStream(tarPath); + // Remove the temp tar once it's been fully sent or the client bails. + nodeStream.on('close', () => void cleanup()); + nodeStream.on('error', () => void cleanup()); + + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + return new NextResponse(webStream, { + status: 200, + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Length': String(size), + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/app/api/v1/admin/backup/schedule/route.ts b/src/app/api/v1/admin/backup/schedule/route.ts new file mode 100644 index 00000000..ba12e97d --- /dev/null +++ b/src/app/api/v1/admin/backup/schedule/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; + +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { createAuditLog } from '@/lib/audit'; +import { errorResponse } from '@/lib/errors'; +import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service'; +import { backupScheduleSchema } from '@/lib/validators/backup-destinations'; + +export const runtime = 'nodejs'; + +/** Read the global automated-backup schedule. Super-admin only. */ +export const GET = withAuth(async (_req, ctx) => { + try { + requireSuperAdmin(ctx, 'admin.backup.schedule.get'); + return NextResponse.json({ data: { schedule: await getSchedule() } }); + } catch (error) { + return errorResponse(error); + } +}); + +/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */ +export const PUT = withAuth(async (req, ctx) => { + try { + requireSuperAdmin(ctx, 'admin.backup.schedule.set'); + const { schedule } = await parseBody(req, backupScheduleSchema); + await setSchedule(schedule, ctx.userId); + await createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'backup_schedule', + entityId: 'global', + severity: 'warning', + metadata: { schedule }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: { schedule } }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/admin/backup-admin-panel.tsx b/src/components/admin/backup-admin-panel.tsx index ab0a16fb..c427259d 100644 --- a/src/components/admin/backup-admin-panel.tsx +++ b/src/components/admin/backup-admin-panel.tsx @@ -1,7 +1,14 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react'; +import { + Loader2, + Download, + Database, + RefreshCw, + AlertTriangle, + HardDriveDownload, +} from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -94,6 +101,18 @@ export function BackupAdminPanel() { } } + function downloadFullBundle() { + // Streams a tar (db.dump + every blob + manifest.json) straight to disk via + // the GET endpoint (cookie auth). The server assembles the bundle before + // the first byte arrives, so the browser download sits "pending" for a + // moment on large datasets — flag that so it doesn't look stuck. + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + toast.info( + 'Preparing full backup — your download starts once the server finishes assembling it.', + ); + triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`); + } + return (
@@ -126,6 +145,32 @@ export function BackupAdminPanel() { + + +
+ + + Full disaster-recovery export + + + Bundles the database dump and every stored file (documents, berth PDFs, + brochures, GDPR exports) into one .tar and downloads it to this computer. + Use this as an offsite, storage-backend-independent backup. + +
+ +
+ + The archive contains db.dump, blobs/<key> for every file, + and a manifest.json with a SHA-256 per object for restore-side verification. + Unlike the DB-only backup above, this does not depend on the active storage backend + surviving. Restore steps live in docs/backup-restore-runbook.md. + +
+ History diff --git a/src/components/admin/backup-destinations-card.tsx b/src/components/admin/backup-destinations-card.tsx new file mode 100644 index 00000000..78689660 --- /dev/null +++ b/src/components/admin/backup-destinations-card.tsx @@ -0,0 +1,604 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Loader2, + Plus, + Server, + Cloud, + FolderTree, + Trash2, + Pencil, + PlugZap, + UploadCloud, + ShieldCheck, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +type DestType = 'sftp' | 's3' | 'filesystem'; +type Schedule = 'off' | 'daily' | 'weekly'; + +interface Destination { + id: string; + name: string; + type: DestType; + enabled: boolean; + config: Record; + retentionCount: number | null; + encryptBundle: boolean; + encryptionKeyIsSet: boolean; + lastRunAt: string | null; + lastStatus: string | null; + lastError: string | null; + lastBackupBytes: number | null; +} + +const TYPE_META: Record = { + sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' }, + s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' }, + filesystem: { + label: 'Mounted path / NAS', + icon: FolderTree, + hint: 'A directory this server can write to.', + }, +}; + +const STATUS_TONE: Record = { + ok: 'text-emerald-700', + failed: 'text-rose-700', +}; + +function formatBytes(n: number | null): string { + if (n === null) return '—'; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +export function BackupDestinationsCard() { + const [destinations, setDestinations] = useState([]); + const [schedule, setSchedule] = useState('off'); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [editing, setEditing] = useState(null); + + useEffect(() => { + void load(); + }, []); + + async function load() { + setLoading(true); + try { + const [d, s] = await Promise.all([ + apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'), + apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'), + ]); + setDestinations(d.data); + setSchedule(s.data.schedule); + } catch (err) { + toastError(err); + } finally { + setLoading(false); + } + } + + async function changeSchedule(value: Schedule) { + setSchedule(value); + try { + await apiFetch('/api/v1/admin/backup/schedule', { + method: 'PUT', + body: { schedule: value }, + }); + toast.success( + value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`, + ); + } catch (err) { + toastError(err); + void load(); + } + } + + async function test(id: string) { + setBusyId(id); + try { + await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' }); + toast.success('Connection OK'); + } catch (err) { + toastError(err); + } finally { + setBusyId(null); + } + } + + async function runNow(id: string) { + setBusyId(id); + try { + toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.'); + await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' }); + toast.success('Backup pushed'); + await load(); + } catch (err) { + toastError(err); + await load(); + } finally { + setBusyId(null); + } + } + + async function remove(id: string) { + if (!confirm('Delete this backup destination? This does not delete already-pushed backups.')) + return; + setBusyId(id); + try { + await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' }); + toast.success('Destination deleted'); + await load(); + } catch (err) { + toastError(err); + } finally { + setBusyId(null); + } + } + + return ( + + +
+ + + Automated backup destinations + + + Where scheduled backups are pushed. Each destination receives the same full bundle + (database + every file) you can download above. + +
+ +
+ +
+ + + + Pushes to every enabled destination below. + +
+ + {loading ? ( +
+ Loading… +
+ ) : destinations.length === 0 ? ( +

+ No destinations yet. Add one to enable automated offsite backups. +

+ ) : ( +
    + {destinations.map((d) => { + const Icon = TYPE_META[d.type].icon; + return ( +
  • +
    +
    + + {d.name} + {d.encryptBundle && ( + + )} + {!d.enabled && ( + + disabled + + )} +
    +
    + {TYPE_META[d.type].label} + {d.lastStatus && ( + <> + {' · '} + + {d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'} + + {d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`} + {d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`} + + )} + {d.lastStatus === 'failed' && d.lastError && ( + {d.lastError} + )} +
    +
    +
    + + + + +
    +
  • + ); + })} +
+ )} +
+ + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + void load(); + }} + /> + )} +
+ ); +} + +// ─── add/edit dialog ───────────────────────────────────────────────────────── + +interface DialogProps { + destination: Destination | null; + onClose: () => void; + onSaved: () => void; +} + +function DestinationDialog({ destination, onClose, onSaved }: DialogProps) { + const isEdit = Boolean(destination); + const cfg = (destination?.config ?? {}) as Record; + const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : ''); + const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : ''); + + const [name, setName] = useState(destination?.name ?? ''); + const [type, setType] = useState(destination?.type ?? 'sftp'); + const [enabled, setEnabled] = useState(destination?.enabled ?? true); + const [retention, setRetention] = useState( + destination?.retentionCount != null ? String(destination.retentionCount) : '', + ); + const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false); + const [encryptionKey, setEncryptionKey] = useState(''); + const [saving, setSaving] = useState(false); + + // Config fields (controlled). Secrets start blank on edit (kept server-side). + const [c, setC] = useState>({ + directory: str('directory'), + host: str('host'), + port: num('port'), + username: str('username'), + password: '', + privateKey: '', + passphrase: '', + remoteDir: str('remoteDir'), + hostFingerprint: str('hostFingerprint'), + endpoint: str('endpoint'), + region: str('region'), + bucket: str('bucket'), + accessKey: str('accessKey'), + secretKey: '', + prefix: str('prefix'), + }); + const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v })); + + function buildConfig(): Record { + if (type === 'filesystem') return { directory: c.directory }; + if (type === 'sftp') { + return { + host: c.host, + ...(c.port ? { port: Number(c.port) } : {}), + username: c.username, + ...(c.password ? { password: c.password } : {}), + ...(c.privateKey ? { privateKey: c.privateKey } : {}), + ...(c.passphrase ? { passphrase: c.passphrase } : {}), + remoteDir: c.remoteDir, + ...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}), + }; + } + return { + endpoint: c.endpoint, + ...(c.region ? { region: c.region } : {}), + bucket: c.bucket, + accessKey: c.accessKey, + ...(c.secretKey ? { secretKey: c.secretKey } : {}), + ...(c.prefix ? { prefix: c.prefix } : {}), + }; + } + + async function save() { + setSaving(true); + try { + const body = { + name, + type, + enabled, + config: buildConfig(), + retentionCount: retention ? Number(retention) : null, + encryptBundle, + ...(encryptionKey ? { encryptionKey } : {}), + }; + if (isEdit && destination) { + await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, { + method: 'PUT', + body, + }); + } else { + await apiFetch('/api/v1/admin/backup/destinations', { + method: 'POST', + body, + }); + } + toast.success(isEdit ? 'Destination updated' : 'Destination added'); + onSaved(); + } catch (err) { + toastError(err); + } finally { + setSaving(false); + } + } + + const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : ''; + + return ( + !o && onClose()}> + + + {isEdit ? 'Edit destination' : 'Add backup destination'} + {TYPE_META[type].hint} + + +
+ + setName(e.target.value)} + placeholder="Hetzner box" + /> + + + + + + {type === 'filesystem' && ( + + set('directory', e.target.value)} + placeholder="/mnt/backups" + /> + + )} + + {type === 'sftp' && ( + <> +
+
+ + set('host', e.target.value)} + placeholder="backups.example.com" + /> + +
+ + set('port', e.target.value)} + placeholder="22" + /> + +
+ + set('username', e.target.value)} /> + + + set('password', e.target.value)} + placeholder={secretPlaceholder} + /> + + +