From 28d346d0f5f9f54cacf5efd53e34d0ea1884b3f7 Mon Sep 17 00:00:00 2001 From: Pepe Ziberi Date: Thu, 23 Jul 2026 23:33:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(backup):=20GUI-konfigurierbares,=20verschl?= =?UTF-8?q?=C3=BCsseltes=20Off-Site-Backup=20(SFTP/Nextcloud)=20(v1.9.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin → Backup: Ziel (SFTP oder Nextcloud/WebDAV) konfigurieren, Verbindung testen, Jetzt sichern, Zeitplan (aus/täglich/wöchentlich) + Aufbewahrung. Best Practice: verschlüsselt + off-site. - Verschlüsselung: Backup als tar.gz (database.dump + MinIO-Dateien) → AES-256-GCM mit Passphrase. Zugangsdaten + Passphrase verschlüsselt in DB (src/lib/crypto-secret.ts, Schlüssel aus Server-Secret) - Engine (src/lib/backup): pg_dump + archiver-Stream aus MinIO + Stream-Verschlüsselung + Upload + Prune - Ziel-Adapter: SFTP (ssh2-sftp-client) + WebDAV (webdav), je test/upload/list/delete - API (SERVER_ADMIN): /api/admin/backup/{config,test,run}; öffentlich per CRON_SECRET: /api/cron/backup - Scheduler in server-custom.js (stündliche Fälligkeitsprüfung) - Dockerfile: postgresql16-client (pg_dump) + runtime-Libs; next.config serverExternalPackages - scripts/decrypt-backup.js + docs/BACKUP.md (Restore-Anleitung), .env.example (CRON_SECRET, BACKUP_ENC_KEY) Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 8 + Dockerfile | 5 +- docs/BACKUP.md | 33 + next.config.js | 3 + package-lock.json | 1048 +++++++++++++++++++++- package.json | 7 +- scripts/decrypt-backup.js | 51 ++ server-custom.js | 17 + src/app/admin/page.tsx | 9 +- src/app/api/admin/backup/config/route.ts | 55 ++ src/app/api/admin/backup/run/route.ts | 17 + src/app/api/admin/backup/test/route.ts | 23 + src/app/api/cron/backup/route.ts | 24 + src/components/admin/backup-tab.tsx | 160 ++++ src/lib/backup/config.ts | 87 ++ src/lib/backup/destinations.ts | 101 +++ src/lib/backup/engine.ts | 163 ++++ src/lib/backup/secrets.ts | 19 + src/lib/crypto-secret.ts | 49 + src/middleware.ts | 1 + 20 files changed, 1872 insertions(+), 8 deletions(-) create mode 100644 scripts/decrypt-backup.js create mode 100644 src/app/api/admin/backup/config/route.ts create mode 100644 src/app/api/admin/backup/run/route.ts create mode 100644 src/app/api/admin/backup/test/route.ts create mode 100644 src/app/api/cron/backup/route.ts create mode 100644 src/components/admin/backup-tab.tsx create mode 100644 src/lib/backup/config.ts create mode 100644 src/lib/backup/destinations.ts create mode 100644 src/lib/backup/engine.ts create mode 100644 src/lib/backup/secrets.ts create mode 100644 src/lib/crypto-secret.ts diff --git a/.env.example b/.env.example index e1d5da8..ece06ee 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,11 @@ GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort BACKUP_RETENTION_DAYS=30 # Intervall in Stunden (24 = taeglich) BACKUP_INTERVAL_HOURS=24 + +# --- GUI-Backup (SFTP/Nextcloud, verschluesselt) --- +# Noetig fuer den automatischen Zeitplan (interner Scheduler ruft /api/cron/backup): +CRON_SECRET=langer-zufaelliger-wert-min-32-zeichen +# Optional: eigener Schluessel zum Verschluesseln der gespeicherten Zugangsdaten +# (Default: NEXTAUTH_SECRET). Wenn gesetzt, NICHT mehr aendern (sonst sind gespeicherte +# Zugangsdaten/Passphrase nicht mehr entschluesselbar): +# BACKUP_ENC_KEY=eigener-langer-schluessel diff --git a/Dockerfile b/Dockerfile index d1e8eaf..3d6c212 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,8 @@ RUN --mount=type=cache,target=/app/.next/cache npm run build # Stage 3: Runner FROM node:20-alpine AS runner -RUN apk add --no-cache openssl +# openssl (Prisma) + postgresql16-client (pg_dump/pg_restore für GUI-Backup, passend zu postgres:16) +RUN apk add --no-cache openssl postgresql16-client ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -44,7 +45,7 @@ USER nextjs # Install only the unbundled runtime deps needed by the custom server. # Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards. -RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save +RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 ssh2-sftp-client@12.1.1 webdav@5.10.0 archiver@8.0.0 --no-save COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./ COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 5974d15..8bf7b4e 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -80,3 +80,36 @@ Empfehlung: **halbjährlich** auf einer Testumgebung durchspielen. - E-Mails liegen beim SMTP-Anbieter. - Secrets (`NEXTAUTH_SECRET`, MinIO-Keys) — separat sicher aufbewahren (Passwort-Manager), sie stehen nicht in den Backups. + +--- + +# GUI-Backup (verschlüsselt, extern: SFTP / Nextcloud) + +Zusätzlich zum lokalen Container-Backup gibt es ein **über die Oberfläche konfigurierbares**, +verschlüsseltes Off-Site-Backup. Als **SERVER_ADMIN**: **Administration → Backup**. + +- **Ziele:** SFTP oder Nextcloud (WebDAV). +- **Verschlüsselung:** AES-256-GCM mit einer **Passphrase** (im Passwort-Manager aufbewahren — + ohne sie ist kein Restore möglich). Zugangsdaten werden verschlüsselt in der DB gespeichert. +- **Zeitplan:** aus / täglich / wöchentlich + Aufbewahrung in Tagen (prunt alte Backups am Ziel). +- **Ablauf:** Speichern → **Verbindung testen** → **Jetzt sichern**. Der automatische Lauf braucht + `CRON_SECRET` (siehe `.env.example`); der interne Scheduler prüft stündlich die Fälligkeit. + +Hochgeladen wird eine Datei `lageplan_.tar.gz.enc` (enthält `database.dump` + `files/`). + +## Restore eines GUI-Backups +``` +# 1) Datei vom Ziel herunterladen, dann entschlüsseln (Passphrase bereithalten): +node scripts/decrypt-backup.js lageplan_2026-07-23-03-00-00.tar.gz.enc backup.tar.gz "DEINE-PASSPHRASE" + +# 2) Entpacken +tar xzf backup.tar.gz # -> database.dump + files/ + +# 3) Datenbank wiederherstellen (destruktiv, App vorher stoppen) +docker cp database.dump lageplan-db:/tmp/database.dump +docker exec lageplan-db pg_restore --clean --if-exists --no-owner -U -d /tmp/database.dump + +# 4) Dateien zurück in MinIO (Ordner files/ in den Bucket) +docker run --rm -v minio_data:/data -v "$PWD/files":/src alpine sh -c "cp -r /src/* /data// 2>/dev/null || true" +``` +Danach App wieder starten. **Restore einmal testen** (Tabelle oben ausfüllen). diff --git a/next.config.js b/next.config.js index 29da266..cf6881b 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,9 @@ const nextConfig = { APP_VERSION: packageJson.version, }, output: 'standalone', + // Node-Libs für das Backup nicht bundeln, sondern zur Laufzeit aus node_modules laden + // (ssh2 hat optionale native Module; webdav/archiver sind reine Server-Abhängigkeiten). + serverExternalPackages: ['ssh2', 'ssh2-sftp-client', 'archiver', 'webdav'], async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index 20f4f3d..45cfc93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lageplan", - "version": "1.8.0", + "version": "1.8.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lageplan", - "version": "1.8.0", + "version": "1.8.11", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.1.0", @@ -29,6 +29,7 @@ "@react-pdf/renderer": "^4.3.2", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "archiver": "^8.0.0", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -52,19 +53,23 @@ "react-moveable": "^0.56.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", + "ssh2-sftp-client": "^12.1.1", "stripe": "^20.3.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", + "webdav": "^5.10.0", "zod": "^3.22.4", "zustand": "^5.0.11" }, "devDependencies": { + "@types/archiver": "^8.0.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^20.11.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ssh2-sftp-client": "^9.0.6", "@types/uuid": "^9.0.7", "autoprefixer": "^10.4.17", "eslint": "^8.56.0", @@ -100,6 +105,15 @@ "node": ">=6.9.0" } }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "license": "MIT", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, "node_modules/@cfcs/core": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.6.tgz", @@ -1591,6 +1605,18 @@ "node": ">= 10" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3673,6 +3699,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/readdir-glob": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -3805,6 +3842,53 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -4486,6 +4570,18 @@ "license": "(Unlicense OR Apache-2.0)", "optional": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -4590,6 +4686,63 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4786,6 +4939,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -4917,11 +5079,110 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -4975,6 +5236,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -5106,6 +5376,30 @@ "node": ">= 0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -5115,6 +5409,27 @@ "node": "*" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5258,6 +5573,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5381,6 +5705,38 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5388,6 +5744,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5416,6 +5787,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -5433,6 +5810,61 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5448,6 +5880,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", @@ -5509,6 +5950,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5801,6 +6251,18 @@ "node": ">=10.0.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -6518,6 +6980,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6527,6 +6998,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -6543,6 +7023,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -6587,6 +7073,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, "node_modules/fast-xml-parser": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", @@ -6633,6 +7135,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -6745,6 +7270,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -7180,6 +7717,12 @@ "react-is": "^16.7.0" } }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "license": "MIT" + }, "node_modules/hsl-to-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", @@ -7428,6 +7971,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -7678,6 +8227,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -7728,6 +8289,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -8002,6 +8575,60 @@ "node": ">=0.10" } }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8426,6 +9053,17 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-engine": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", @@ -8548,6 +9186,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -8598,6 +9243,12 @@ "node": ">= 0.6" } }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "license": "MIT" + }, "node_modules/next": { "version": "15.5.12", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", @@ -8687,6 +9338,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8716,6 +9387,24 @@ "semver": "bin/semver.js" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -8736,7 +9425,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9038,6 +9726,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9065,6 +9768,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9347,6 +10056,21 @@ "fsevents": "2.3.3" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9427,6 +10151,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -9737,6 +10467,57 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9840,6 +10621,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10072,6 +10859,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", @@ -10406,6 +11199,40 @@ "node": ">=6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10451,6 +11278,17 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -10850,6 +11688,36 @@ "node": ">= 6" } }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -11065,6 +11933,12 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11169,6 +12043,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11310,6 +12190,25 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -11643,6 +12542,102 @@ "@zxing/text-encoding": "0.9.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.10.0.tgz", + "integrity": "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A==", + "license": "MIT", + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.7.2", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.9", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/webdav/node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/webdav/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/webdav/node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11828,6 +12823,21 @@ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "license": "MIT" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -11970,6 +12980,36 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 3be4652..07a5aaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.8.11", + "version": "1.9.0", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { @@ -45,6 +45,7 @@ "@react-pdf/renderer": "^4.3.2", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "archiver": "^8.0.0", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -68,10 +69,12 @@ "react-moveable": "^0.56.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", + "ssh2-sftp-client": "^12.1.1", "stripe": "^20.3.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", + "webdav": "^5.10.0", "zod": "^3.22.4", "zustand": "^5.0.11" }, @@ -79,11 +82,13 @@ "seed": "node prisma/seed.js" }, "devDependencies": { + "@types/archiver": "^8.0.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^20.11.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/ssh2-sftp-client": "^9.0.6", "@types/uuid": "^9.0.7", "autoprefixer": "^10.4.17", "eslint": "^8.56.0", diff --git a/scripts/decrypt-backup.js b/scripts/decrypt-backup.js new file mode 100644 index 0000000..d820870 --- /dev/null +++ b/scripts/decrypt-backup.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Entschlüsselt ein Lageplan-Backup (.tar.gz.enc) → .tar.gz + * + * Verwendung: + * node scripts/decrypt-backup.js [passphrase] + * (ohne Passphrase-Argument wird sie interaktiv abgefragt bzw. aus BACKUP_PASSPHRASE gelesen) + * + * Danach: + * tar xzf output.tar.gz → enthält database.dump + files/ + * pg_restore --clean --if-exists -d database.dump + * files/ zurück in MinIO spielen (siehe docs/BACKUP.md) + * + * Format der .enc-Datei: [salt(16)][iv(12)][ciphertext][tag(16)], AES-256-GCM, Key = scrypt(passphrase, salt). + */ +const fs = require('fs') +const crypto = require('crypto') + +const [inPath, outPath] = process.argv.slice(2) +let passphrase = process.argv[4] || process.env.BACKUP_PASSPHRASE + +if (!inPath || !outPath) { + console.error('Verwendung: node decrypt-backup.js [passphrase]') + process.exit(1) +} +if (!passphrase) { + console.error('Passphrase fehlt. Als 3. Argument oder via BACKUP_PASSPHRASE übergeben.') + process.exit(1) +} + +const size = fs.statSync(inPath).size +if (size < 44) { console.error('Datei zu klein / kein gültiges Backup.'); process.exit(1) } + +const fd = fs.openSync(inPath, 'r') +const head = Buffer.alloc(28) +fs.readSync(fd, head, 0, 28, 0) +const tag = Buffer.alloc(16) +fs.readSync(fd, tag, 0, 16, size - 16) +fs.closeSync(fd) + +const salt = head.subarray(0, 16) +const iv = head.subarray(16, 28) +const key = crypto.scryptSync(passphrase, salt, 32) +const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) +decipher.setAuthTag(tag) + +const input = fs.createReadStream(inPath, { start: 28, end: size - 17 }) +const output = fs.createWriteStream(outPath) +input.pipe(decipher).pipe(output) +output.on('finish', () => console.log('OK →', outPath)) +decipher.on('error', (e) => { console.error('Entschlüsselung fehlgeschlagen (falsche Passphrase?):', e.message); process.exit(1) }) diff --git a/server-custom.js b/server-custom.js index 7d66370..4f0e9de 100644 --- a/server-custom.js +++ b/server-custom.js @@ -71,4 +71,21 @@ app.prepare().then(() => { httpServer.listen(port, hostname, () => { console.log(`> Ready on http://${hostname}:${port}`) }) + + // Backup-Scheduler: stündlich prüfen, ob laut Zeitplan ein Backup fällig ist. + // Die Route /api/cron/backup entscheidet anhand der Konfiguration; hier wird nur getriggert. + if (process.env.CRON_SECRET) { + const checkBackup = () => { + fetch(`http://127.0.0.1:${port}/api/cron/backup`, { + method: 'POST', + headers: { 'x-cron-secret': process.env.CRON_SECRET }, + }) + .then((r) => r.json()) + .then((d) => { if (d && d.skipped !== true) console.log('[backup-scheduler]', JSON.stringify(d)) }) + .catch((e) => console.warn('[backup-scheduler]', e.message)) + } + setInterval(checkBackup, 60 * 60 * 1000) // stündlich + setTimeout(checkBackup, 90 * 1000) // ~1.5 Min nach Start einmal + console.log('> Backup-Scheduler aktiv (stündliche Fälligkeitsprüfung)') + } }) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5432e30..7ea7384 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -9,6 +9,7 @@ import { useAuth } from '@/components/providers/auth-provider' import { ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen, Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2, + HardDriveDownload, } from 'lucide-react' import { TenantsTab } from '@/components/admin/tenants-tab' import { UsersTab } from '@/components/admin/users-tab' @@ -21,6 +22,7 @@ import { SuggestionsTab } from '@/components/admin/suggestions-tab' import { DictionaryTab } from '@/components/admin/dictionary-tab' import { SymbolManager } from '@/components/admin/symbol-manager' import { OrgTab } from '@/components/admin/org-tab' +import { BackupTab } from '@/components/admin/backup-tab' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' export default function AdminPage() { @@ -81,7 +83,7 @@ export default function AdminPage() {
{isServerAdmin ? ( - + Mandanten Einsätze Symbole @@ -89,6 +91,7 @@ export default function AdminPage() { Wörterbuch Benutzer System + Backup ) : ( @@ -121,6 +124,10 @@ export default function AdminPage() { )} + {isServerAdmin && ( + + )} + {/* ===== Geteilt: Benutzer ===== */} diff --git a/src/app/api/admin/backup/config/route.ts b/src/app/api/admin/backup/config/route.ts new file mode 100644 index 0000000..19a4075 --- /dev/null +++ b/src/app/api/admin/backup/config/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/crypto-secret' +import { loadBackupConfig, saveBackupConfig, maskBackupConfig, DEFAULT_BACKUP_CONFIG, type BackupConfig } from '@/lib/backup/config' + +// Backup ist plattformweit → nur SERVER_ADMIN. +async function guard() { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') return null + return user +} + +export async function GET() { + if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + const cfg = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG + return NextResponse.json(maskBackupConfig(cfg)) +} + +export async function PUT(req: NextRequest) { + if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + const body = await req.json().catch(() => ({})) + const existing = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG + + const next: BackupConfig = { ...existing } + next.enabled = !!body.enabled + next.schedule = ['off', 'daily', 'weekly'].includes(body.schedule) ? body.schedule : 'off' + next.retentionDays = Math.max(1, Math.min(3650, Number(body.retentionDays) || 30)) + next.destinationType = body.destinationType === 'webdav' ? 'webdav' : 'sftp' + + // Passphrase nur ersetzen, wenn neu eingegeben + if (typeof body.passphrase === 'string' && body.passphrase.length > 0) { + next.encryptPassphrase = encryptSecret(body.passphrase) + } + + if (body.sftp) { + next.sftp = { + host: String(body.sftp.host || ''), + port: Number(body.sftp.port) || 22, + username: String(body.sftp.username || ''), + remotePath: String(body.sftp.remotePath || '/lageplan-backups'), + password: body.sftp.password ? encryptSecret(String(body.sftp.password)) : (existing.sftp?.password || ''), + } + } + if (body.webdav) { + next.webdav = { + url: String(body.webdav.url || ''), + username: String(body.webdav.username || ''), + remotePath: String(body.webdav.remotePath || '/Lageplan-Backups'), + password: body.webdav.password ? encryptSecret(String(body.webdav.password)) : (existing.webdav?.password || ''), + } + } + + await saveBackupConfig(next) + return NextResponse.json(maskBackupConfig(next)) +} diff --git a/src/app/api/admin/backup/run/route.ts b/src/app/api/admin/backup/run/route.ts new file mode 100644 index 0000000..3f5b55f --- /dev/null +++ b/src/app/api/admin/backup/run/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { runBackup } from '@/lib/backup/engine' + +// POST: löst sofort ein Backup aus (manuell „Jetzt sichern"). Nur SERVER_ADMIN. +export async function POST() { + try { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + const res = await runBackup() + return NextResponse.json(res) + } catch (e: any) { + return NextResponse.json({ ok: false, message: e?.message || 'Backup fehlgeschlagen' }, { status: 500 }) + } +} diff --git a/src/app/api/admin/backup/test/route.ts b/src/app/api/admin/backup/test/route.ts new file mode 100644 index 0000000..bd18bfc --- /dev/null +++ b/src/app/api/admin/backup/test/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { loadBackupConfig } from '@/lib/backup/config' +import { plainDestFromConfig } from '@/lib/backup/secrets' +import { testDestination } from '@/lib/backup/destinations' + +// POST: testet die gespeicherte Zielverbindung (Verbindung + Schreibzugriff). Nur SERVER_ADMIN. +export async function POST() { + try { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + const cfg = await loadBackupConfig() + if (!cfg) return NextResponse.json({ ok: false, message: 'Bitte zuerst konfigurieren und speichern.' }, { status: 400 }) + + const dest = plainDestFromConfig(cfg) + const res = await testDestination(cfg.destinationType, dest) + return NextResponse.json(res, { status: res.ok ? 200 : 400 }) + } catch (e: any) { + return NextResponse.json({ ok: false, message: e?.message || 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/cron/backup/route.ts b/src/app/api/cron/backup/route.ts new file mode 100644 index 0000000..247f840 --- /dev/null +++ b/src/app/api/cron/backup/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { loadBackupConfig } from '@/lib/backup/config' +import { isBackupDue, runBackup } from '@/lib/backup/engine' + +// POST (öffentlich, aber per CRON_SECRET geschützt): vom internen Scheduler aufgerufen. +// Führt ein Backup NUR aus, wenn es laut Zeitplan fällig ist. +export async function POST(req: NextRequest) { + try { + const secret = process.env.CRON_SECRET + if (!secret) return NextResponse.json({ error: 'CRON_SECRET nicht gesetzt' }, { status: 503 }) + if (req.headers.get('x-cron-secret') !== secret) { + return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + } + + const cfg = await loadBackupConfig() + if (!cfg) return NextResponse.json({ skipped: true, reason: 'nicht konfiguriert' }) + if (!isBackupDue(cfg)) return NextResponse.json({ skipped: true, reason: 'nicht fällig' }) + + const res = await runBackup() + return NextResponse.json(res) + } catch (e: any) { + return NextResponse.json({ ok: false, message: e?.message || 'Fehler' }, { status: 500 }) + } +} diff --git a/src/components/admin/backup-tab.tsx b/src/components/admin/backup-tab.tsx new file mode 100644 index 0000000..9c2589d --- /dev/null +++ b/src/components/admin/backup-tab.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { HardDriveDownload, Loader2, ShieldCheck, PlugZap, Save } from 'lucide-react' + +interface MaskedCfg { + enabled: boolean + schedule: 'off' | 'daily' | 'weekly' + retentionDays: number + hasPassphrase: boolean + destinationType: 'sftp' | 'webdav' + sftp?: { host: string; port: number; username: string; remotePath: string; hasPassword: boolean } + webdav?: { url: string; username: string; remotePath: string; hasPassword: boolean } + lastRun?: string | null + lastStatus?: 'ok' | 'error' | null + lastMessage?: string | null +} + +export function BackupTab() { + const { toast } = useToast() + const [cfg, setCfg] = useState(null) + const [busy, setBusy] = useState<'' | 'save' | 'test' | 'run'>('') + // Neue Geheimnisse (nur senden, wenn ausgefüllt) + const [passphrase, setPassphrase] = useState('') + const [sftpPw, setSftpPw] = useState('') + const [webdavPw, setWebdavPw] = useState('') + + const load = useCallback(async () => { + const r = await fetch('/api/admin/backup/config') + if (r.ok) setCfg(await r.json()) + }, []) + useEffect(() => { load() }, [load]) + + if (!cfg) return
+ + const patch = (p: Partial) => setCfg(c => c ? { ...c, ...p } : c) + const patchSftp = (p: any) => setCfg(c => c ? { ...c, sftp: { ...(c.sftp || { host: '', port: 22, username: '', remotePath: '/lageplan-backups', hasPassword: false }), ...p } } : c) + const patchWebdav = (p: any) => setCfg(c => c ? { ...c, webdav: { ...(c.webdav || { url: '', username: '', remotePath: '/Lageplan-Backups', hasPassword: false }), ...p } } : c) + + const save = async () => { + setBusy('save') + try { + const body: any = { + enabled: cfg.enabled, schedule: cfg.schedule, retentionDays: cfg.retentionDays, + destinationType: cfg.destinationType, + } + if (passphrase) body.passphrase = passphrase + if (cfg.destinationType === 'sftp' && cfg.sftp) body.sftp = { ...cfg.sftp, password: sftpPw || undefined } + if (cfg.destinationType === 'webdav' && cfg.webdav) body.webdav = { ...cfg.webdav, password: webdavPw || undefined } + const r = await fetch('/api/admin/backup/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + if (r.ok) { setCfg(await r.json()); setPassphrase(''); setSftpPw(''); setWebdavPw(''); toast({ title: 'Gespeichert' }) } + else { const d = await r.json(); toast({ title: 'Fehler', description: d.error, variant: 'destructive' }) } + } finally { setBusy('') } + } + + const test = async () => { + setBusy('test') + try { + const r = await fetch('/api/admin/backup/test', { method: 'POST' }) + const d = await r.json() + toast({ title: d.ok ? 'Verbindung ok' : 'Verbindung fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' }) + } finally { setBusy('') } + } + + const runNow = async () => { + if (!confirm('Jetzt ein Backup erstellen und zum Ziel hochladen?')) return + setBusy('run') + try { + const r = await fetch('/api/admin/backup/run', { method: 'POST' }) + const d = await r.json() + toast({ title: d.ok ? 'Backup erstellt' : 'Backup fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' }) + load() + } finally { setBusy('') } + } + + return ( +
+
+

Backup (verschlüsselt, extern)

+

+ Sichert Datenbank + hochgeladene Dateien verschlüsselt auf ein externes Ziel (SFTP oder Nextcloud/WebDAV). + Best Practice: zusätzlich zum lokalen Backup, an einen anderen Ort. +

+
+ + {/* Status */} + {cfg.lastRun && ( +
+ Letztes Backup: {new Date(cfg.lastRun).toLocaleString('de-CH')} — {cfg.lastStatus === 'ok' ? '✅' : '❌'} {cfg.lastMessage} +
+ )} + + {/* Zeitplan */} +
+

Zeitplan

+
+
+ + +
+
+ + patch({ retentionDays: Number(e.target.value) })} className="h-9" /> +
+
+
+ + {/* Verschlüsselung */} +
+

Verschlüsselung

+ + setPassphrase(e.target.value)} placeholder={cfg.hasPassphrase ? 'Zum Beibehalten leer lassen' : 'Passphrase wählen (sicher aufbewahren!)'} className="h-9" /> +

⚠️ Ohne diese Passphrase ist kein Restore möglich — im Passwort-Manager speichern.

+
+ + {/* Ziel */} +
+
+

Ziel

+ +
+ + {cfg.destinationType === 'sftp' ? ( +
+
patchSftp({ host: e.target.value })} placeholder="nas.example.com" className="h-9" />
+
patchSftp({ port: Number(e.target.value) })} className="h-9" />
+
patchSftp({ username: e.target.value })} className="h-9" />
+
setSftpPw(e.target.value)} placeholder={cfg.sftp?.hasPassword ? 'leer lassen' : ''} className="h-9" />
+
patchSftp({ remotePath: e.target.value })} placeholder="/lageplan-backups" className="h-9" />
+
+ ) : ( +
+
patchWebdav({ url: e.target.value })} placeholder="https://cloud.example.com/remote.php/dav/files/USER/" className="h-9" />
+
patchWebdav({ username: e.target.value })} className="h-9" />
+
setWebdavPw(e.target.value)} placeholder={cfg.webdav?.hasPassword ? 'leer lassen' : 'Nextcloud App-Passwort'} className="h-9" />
+
patchWebdav({ remotePath: e.target.value })} placeholder="/Lageplan-Backups" className="h-9" />
+
+ )} +
+ +
+ + + +
+

Speichern → Verbindung testen → Jetzt sichern. Restore: siehe docs/BACKUP.md (Entschlüsseln mit der Passphrase).

+
+ ) +} diff --git a/src/lib/backup/config.ts b/src/lib/backup/config.ts new file mode 100644 index 0000000..9d030e6 --- /dev/null +++ b/src/lib/backup/config.ts @@ -0,0 +1,87 @@ +import { prisma } from '@/lib/db' + +export type BackupDestinationType = 'sftp' | 'webdav' +export type BackupSchedule = 'off' | 'daily' | 'weekly' + +export interface SftpConfig { + host: string + port: number + username: string + /** verschlüsselt (enc:v1:) */ + password: string + remotePath: string +} + +export interface WebdavConfig { + /** z.B. https://cloud.example.com/remote.php/dav/files/USER/ */ + url: string + username: string + /** verschlüsselt (enc:v1:) — bei Nextcloud besser ein App-Passwort */ + password: string + remotePath: string +} + +export interface BackupConfig { + enabled: boolean + schedule: BackupSchedule + retentionDays: number + /** Passphrase zur Backup-Verschlüsselung, verschlüsselt (enc:v1:) */ + encryptPassphrase: string + destinationType: BackupDestinationType + sftp?: SftpConfig + webdav?: WebdavConfig + // Statusfelder (vom letzten Lauf) + lastRun?: string | null + lastStatus?: 'ok' | 'error' | null + lastMessage?: string | null + lastSizeBytes?: number | null +} + +const SETTING_KEY = 'backup_config' + +export const DEFAULT_BACKUP_CONFIG: BackupConfig = { + enabled: false, + schedule: 'off', + retentionDays: 30, + encryptPassphrase: '', + destinationType: 'sftp', + sftp: { host: '', port: 22, username: '', password: '', remotePath: '/lageplan-backups' }, + webdav: { url: '', username: '', password: '', remotePath: '/Lageplan-Backups' }, + lastRun: null, lastStatus: null, lastMessage: null, lastSizeBytes: null, +} + +export async function loadBackupConfig(): Promise { + try { + const row = await (prisma as any).systemSetting.findUnique({ where: { key: SETTING_KEY } }) + if (!row?.value) return null + return { ...DEFAULT_BACKUP_CONFIG, ...JSON.parse(row.value) } + } catch (e) { + console.error('[backup] loadBackupConfig:', e) + return null + } +} + +export async function saveBackupConfig(cfg: BackupConfig): Promise { + await (prisma as any).systemSetting.upsert({ + where: { key: SETTING_KEY }, + update: { value: JSON.stringify(cfg) }, + create: { key: SETTING_KEY, value: JSON.stringify(cfg), isSecret: true, category: 'backup' }, + }) +} + +/** Konfig für die API maskieren: Geheimnisse durch Boolean-Flags ersetzen. */ +export function maskBackupConfig(cfg: BackupConfig) { + return { + enabled: cfg.enabled, + schedule: cfg.schedule, + retentionDays: cfg.retentionDays, + hasPassphrase: !!cfg.encryptPassphrase, + destinationType: cfg.destinationType, + sftp: cfg.sftp ? { host: cfg.sftp.host, port: cfg.sftp.port, username: cfg.sftp.username, remotePath: cfg.sftp.remotePath, hasPassword: !!cfg.sftp.password } : undefined, + webdav: cfg.webdav ? { url: cfg.webdav.url, username: cfg.webdav.username, remotePath: cfg.webdav.remotePath, hasPassword: !!cfg.webdav.password } : undefined, + lastRun: cfg.lastRun ?? null, + lastStatus: cfg.lastStatus ?? null, + lastMessage: cfg.lastMessage ?? null, + lastSizeBytes: cfg.lastSizeBytes ?? null, + } +} diff --git a/src/lib/backup/destinations.ts b/src/lib/backup/destinations.ts new file mode 100644 index 0000000..8a5a528 --- /dev/null +++ b/src/lib/backup/destinations.ts @@ -0,0 +1,101 @@ +import { createReadStream, readFileSync } from 'fs' +import type { BackupDestinationType } from './config' + +// Entschlüsselte (Klartext-)Zugangsdaten — werden vom Aufrufer aus der Konfig entschlüsselt. +export interface SftpPlain { host: string; port: number; username: string; password: string; remotePath: string } +export interface WebdavPlain { url: string; username: string; password: string; remotePath: string } +export type PlainDest = SftpPlain | WebdavPlain + +export interface RemoteFile { name: string; size?: number; modified?: number } + +function joinRemote(dir: string, name: string): string { + return `${dir.replace(/\/+$/, '')}/${name}` +} + +// ─── SFTP ─────────────────────────────────────────────── +async function withSftp(c: SftpPlain, fn: (sftp: any) => Promise): Promise { + const SftpClient = (await import('ssh2-sftp-client')).default + const sftp = new SftpClient() + await sftp.connect({ host: c.host, port: c.port || 22, username: c.username, password: c.password, readyTimeout: 15000 }) + try { + return await fn(sftp) + } finally { + try { await sftp.end() } catch { /* ignore */ } + } +} + +// ─── WebDAV (Nextcloud) ───────────────────────────────── +async function getWebdav(c: WebdavPlain) { + const { createClient } = await import('webdav') + return createClient(c.url, { username: c.username, password: c.password }) +} + +// ─── Öffentliche, typ-übergreifende API ───────────────── + +export async function testDestination(type: BackupDestinationType, dest: PlainDest): Promise<{ ok: boolean; message: string }> { + try { + const marker = Buffer.from(`lageplan-backup-test ${new Date().toISOString()}`) + if (type === 'sftp') { + const c = dest as SftpPlain + await withSftp(c, async (sftp) => { + if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true) + const p = joinRemote(c.remotePath, '.lageplan-test') + await sftp.put(marker, p) + await sftp.delete(p) + }) + return { ok: true, message: 'SFTP-Verbindung und Schreibzugriff ok.' } + } + const c = dest as WebdavPlain + const dav = await getWebdav(c) + if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any) + const p = joinRemote(c.remotePath, '.lageplan-test') + await dav.putFileContents(p, marker) + await dav.deleteFile(p) + return { ok: true, message: 'WebDAV-Verbindung und Schreibzugriff ok.' } + } catch (e: any) { + return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' } + } +} + +export async function uploadBackup(type: BackupDestinationType, dest: PlainDest, localPath: string, remoteName: string): Promise { + if (type === 'sftp') { + const c = dest as SftpPlain + await withSftp(c, async (sftp) => { + if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true) + await sftp.put(createReadStream(localPath), joinRemote(c.remotePath, remoteName)) + }) + return + } + const c = dest as WebdavPlain + const dav = await getWebdav(c) + if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any) + // Für WebDAV als Buffer hochladen (Backups einer kleinen Feuerwehr sind moderat gross). + await dav.putFileContents(joinRemote(c.remotePath, remoteName), readFileSync(localPath), { overwrite: true } as any) +} + +export async function listBackups(type: BackupDestinationType, dest: PlainDest): Promise { + if (type === 'sftp') { + const c = dest as SftpPlain + return withSftp(c, async (sftp) => { + if (!(await sftp.exists(c.remotePath))) return [] + const list = await sftp.list(c.remotePath) + return list.filter((f: any) => f.type === '-').map((f: any) => ({ name: f.name, size: f.size, modified: f.modifyTime })) + }) + } + const c = dest as WebdavPlain + const dav = await getWebdav(c) + if (!(await dav.exists(c.remotePath))) return [] + const items = (await dav.getDirectoryContents(c.remotePath)) as any[] + return items.filter(i => i.type === 'file').map(i => ({ name: i.basename, size: i.size, modified: i.lastmod ? Date.parse(i.lastmod) : undefined })) +} + +export async function deleteBackup(type: BackupDestinationType, dest: PlainDest, name: string): Promise { + if (type === 'sftp') { + const c = dest as SftpPlain + await withSftp(c, async (sftp) => { await sftp.delete(joinRemote(c.remotePath, name)) }) + return + } + const c = dest as WebdavPlain + const dav = await getWebdav(c) + await dav.deleteFile(joinRemote(c.remotePath, name)) +} diff --git a/src/lib/backup/engine.ts b/src/lib/backup/engine.ts new file mode 100644 index 0000000..47e429e --- /dev/null +++ b/src/lib/backup/engine.ts @@ -0,0 +1,163 @@ +import { spawn } from 'child_process' +import { createWriteStream, createReadStream, promises as fsp } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { randomBytes, scryptSync, createCipheriv } from 'crypto' +// archiver nutzt `export =` (CommonJS); require umgeht die Default-Import-Inkompatibilität. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const archiver = require('archiver') as (format: string, opts?: any) => any +import { minioClient, BUCKET } from '@/lib/minio' +import { loadBackupConfig, saveBackupConfig, type BackupConfig } from './config' +import { plainDestFromConfig, getPassphrase } from './secrets' +import { uploadBackup, listBackups, deleteBackup } from './destinations' + +const NAME_PREFIX = 'lageplan_' +const NAME_RE = /^lageplan_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})\.tar\.gz\.enc$/ + +function tsName(d = new Date()): string { + // 2026-07-23-22-30-00 (UTC, sortierbar) + return d.toISOString().replace('T', '-').replace(/:/g, '-').slice(0, 19) +} +function fmtSize(n: number): string { + if (n > 1e9) return (n / 1e9).toFixed(1) + ' GB' + if (n > 1e6) return (n / 1e6).toFixed(1) + ' MB' + return (n / 1e3).toFixed(0) + ' KB' +} + +/** PostgreSQL-Dump (custom/komprimiert) via pg_dump. */ +function pgDump(dumpPath: string): Promise { + const url = process.env.DATABASE_URL + if (!url) return Promise.reject(new Error('DATABASE_URL fehlt')) + return new Promise((resolve, reject) => { + const p = spawn('pg_dump', [url, '-Fc', '-f', dumpPath], { stdio: ['ignore', 'ignore', 'pipe'] }) + let err = '' + p.stderr.on('data', d => { err += d.toString() }) + p.on('error', (e) => reject(new Error(`pg_dump nicht ausführbar: ${e.message} (postgresql-client im Image?)`))) + p.on('close', code => code === 0 ? resolve() : reject(new Error('pg_dump: ' + (err || `Exit ${code}`)))) + }) +} + +function listObjectNames(): Promise { + return new Promise((resolve, reject) => { + const out: string[] = [] + const s = minioClient.listObjects(BUCKET, '', true) + s.on('data', (o: any) => { if (o.name) out.push(o.name) }) + s.on('error', reject) + s.on('end', () => resolve(out)) + }) +} + +/** tar.gz mit database.dump + files/. */ +async function buildArchive(dumpPath: string, tarPath: string): Promise { + const names = await listObjectNames() + const output = createWriteStream(tarPath) + const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } }) + const done = new Promise((resolve, reject) => { + output.on('close', () => resolve()) + output.on('error', reject) + archive.on('error', reject) + }) + archive.pipe(output) + archive.file(dumpPath, { name: 'database.dump' }) + for (const name of names) { + const st = await minioClient.getObject(BUCKET, name) + archive.append(st as any, { name: 'files/' + name }) + } + await archive.finalize() + await done +} + +/** AES-256-GCM Stream-Verschlüsselung mit Passphrase. Format: [salt16][iv12][ciphertext][tag16]. */ +function encryptFile(inPath: string, outPath: string, passphrase: string): Promise { + return new Promise((resolve, reject) => { + const salt = randomBytes(16) + const iv = randomBytes(12) + const key = scryptSync(passphrase, salt, 32) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const out = createWriteStream(outPath) + out.on('error', reject) + out.on('close', () => resolve()) + out.write(salt); out.write(iv) + const input = createReadStream(inPath) + input.on('error', reject) + cipher.on('error', reject) + cipher.on('data', (c) => out.write(c)) + cipher.on('end', () => { out.write(cipher.getAuthTag()); out.end() }) + input.pipe(cipher) + }) +} + +/** Entfernt Remote-Backups, die älter als retentionDays sind (Zeitstempel aus dem Dateinamen). */ +async function prune(cfg: BackupConfig): Promise { + try { + const dest = plainDestFromConfig(cfg) + const files = await listBackups(cfg.destinationType, dest) + const cutoff = Date.now() - cfg.retentionDays * 86400_000 + for (const f of files) { + const m = f.name.match(NAME_RE) + if (!m) continue + // 2026-07-23-22-30-00 → ISO + const iso = m[1].replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1T$2:$3:$4Z') + const t = Date.parse(iso) + if (!isNaN(t) && t < cutoff) { + await deleteBackup(cfg.destinationType, dest, f.name) + } + } + } catch (e) { + console.warn('[backup] prune:', e) + } +} + +/** + * Führt ein vollständiges Backup aus: DB-Dump + Dateien → tar.gz → verschlüsselt → Upload → prune. + * Läuft unabhängig vom Zeitplan (auch für „Jetzt sichern"), solange Ziel + Passphrase gesetzt sind. + */ +export async function runBackup(): Promise<{ ok: boolean; message: string }> { + const cfg = await loadBackupConfig() + if (!cfg) throw new Error('Backup nicht konfiguriert.') + const pass = getPassphrase(cfg) + if (!pass) throw new Error('Backup-Passphrase fehlt.') + const dest = plainDestFromConfig(cfg) // wirft, wenn Ziel nicht konfiguriert + + const ts = tsName() + const tmp = await fsp.mkdtemp(join(tmpdir(), 'lpbak-')) + const dumpPath = join(tmp, 'db.dump') + const tarPath = join(tmp, `${NAME_PREFIX}${ts}.tar.gz`) + const encPath = `${tarPath}.enc` + const remoteName = `${NAME_PREFIX}${ts}.tar.gz.enc` + + try { + await pgDump(dumpPath) + await buildArchive(dumpPath, tarPath) + await encryptFile(tarPath, encPath, pass) + await uploadBackup(cfg.destinationType, dest, encPath, remoteName) + const size = (await fsp.stat(encPath)).size + await prune(cfg) + + cfg.lastRun = new Date().toISOString() + cfg.lastStatus = 'ok' + cfg.lastMessage = `Gesichert: ${remoteName} (${fmtSize(size)})` + cfg.lastSizeBytes = size + await saveBackupConfig(cfg) + return { ok: true, message: cfg.lastMessage } + } catch (e: any) { + cfg.lastRun = new Date().toISOString() + cfg.lastStatus = 'error' + cfg.lastMessage = e?.message || 'Unbekannter Fehler' + await saveBackupConfig(cfg) + throw e + } finally { + await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {}) + } +} + +/** Prüft, ob laut Zeitplan ein Backup fällig ist (für den Scheduler). */ +export function isBackupDue(cfg: BackupConfig, now = Date.now()): boolean { + if (!cfg.enabled || cfg.schedule === 'off') return false + const intervalMs = cfg.schedule === 'weekly' ? 7 * 86400_000 : 86400_000 + if (!cfg.lastRun) return true + const last = Date.parse(cfg.lastRun) + if (isNaN(last)) return true + // Bei Fehlversuchen nicht dauernd neu starten: erst nach dem Intervall erneut. + return now - last >= intervalMs +} diff --git a/src/lib/backup/secrets.ts b/src/lib/backup/secrets.ts new file mode 100644 index 0000000..7f9b918 --- /dev/null +++ b/src/lib/backup/secrets.ts @@ -0,0 +1,19 @@ +import { decryptSecret } from '@/lib/crypto-secret' +import type { BackupConfig } from './config' +import type { PlainDest, SftpPlain, WebdavPlain } from './destinations' + +/** Entschlüsselt die Zugangsdaten des aktiven Ziels aus der Konfig. */ +export function plainDestFromConfig(cfg: BackupConfig): PlainDest { + if (cfg.destinationType === 'sftp') { + const s = cfg.sftp + if (!s) throw new Error('SFTP nicht konfiguriert') + return { host: s.host, port: s.port, username: s.username, password: decryptSecret(s.password), remotePath: s.remotePath } as SftpPlain + } + const w = cfg.webdav + if (!w) throw new Error('WebDAV nicht konfiguriert') + return { url: w.url, username: w.username, password: decryptSecret(w.password), remotePath: w.remotePath } as WebdavPlain +} + +export function getPassphrase(cfg: BackupConfig): string { + return decryptSecret(cfg.encryptPassphrase || '') +} diff --git a/src/lib/crypto-secret.ts b/src/lib/crypto-secret.ts new file mode 100644 index 0000000..4b6e333 --- /dev/null +++ b/src/lib/crypto-secret.ts @@ -0,0 +1,49 @@ +import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'crypto' + +/** + * Verschlüsselt kurze Geheimnisse (z.B. SFTP-/WebDAV-Passwörter, Backup-Passphrase) für die + * Ablage in der Datenbank. AES-256-GCM. Der Schlüssel wird aus einem Server-Secret abgeleitet + * (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) — er liegt NICHT in der Datenbank. + * + * Format (base64): [salt(16)][iv(12)][tag(16)][ciphertext] + * Ein Präfix "enc:v1:" kennzeichnet verschlüsselte Werte. + */ + +const PREFIX = 'enc:v1:' + +function masterKey(): string { + const k = process.env.BACKUP_ENC_KEY || process.env.NEXTAUTH_SECRET + if (!k || k.length < 16) { + throw new Error('Kein Verschlüsselungs-Secret (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) gesetzt.') + } + return k +} + +export function isEncrypted(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(PREFIX) +} + +export function encryptSecret(plain: string): string { + if (!plain) return '' + const salt = randomBytes(16) + const iv = randomBytes(12) + const key = scryptSync(masterKey(), salt, 32) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return PREFIX + Buffer.concat([salt, iv, tag, ct]).toString('base64') +} + +export function decryptSecret(value: string): string { + if (!value) return '' + if (!isEncrypted(value)) return value // Abwärtskompatibel: unverschlüsselte Altwerte + const raw = Buffer.from(value.slice(PREFIX.length), 'base64') + const salt = raw.subarray(0, 16) + const iv = raw.subarray(16, 28) + const tag = raw.subarray(28, 44) + const ct = raw.subarray(44) + const key = scryptSync(masterKey(), salt, 32) + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') +} diff --git a/src/middleware.ts b/src/middleware.ts index 1166d0a..0fb6eed 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -18,6 +18,7 @@ const PUBLIC_API_PREFIXES = [ '/api/auth/resend-verification', '/api/auth/logout', '/api/auth/mfa-login/', + '/api/cron/', '/api/contact', '/api/demo', '/api/donate',