diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0839ccb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +backend/node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/backend/.env b/backend/.env index 95187d0..2063f03 100644 --- a/backend/.env +++ b/backend/.env @@ -6,4 +6,5 @@ SMTP_USER=seduesseldorf@gmail.com SMTP_PASS=odkxssbmfvewpitv MAIL_FROM="'SE Düsseldorf' " UNSUBSCRIBE_SECRET=tegvideo7010! -FRONTEND_ORIGIN=https://sekt.tegdssd.de \ No newline at end of file +FRONTEND_ORIGIN=https://sekt.tegdssd.de +#FRONTEND_ORIGIN=https://sekt.local \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index d7c9c5d..9fdaf95 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@prisma/client": "^6.11.0", + "@prisma/client": "^6.19.0", "bcrypt": "^6.0.0", "chokidar": "^4.0.3", "cookie-parser": "^1.4.7", @@ -16,11 +16,37 @@ "fast-xml-parser": "^5.2.3", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.3", + "pdf-lib": "^1.17.1", + "puppeteer": "^24.29.1", "sharp": "^0.34.2", + "undici": "^7.16.0", "ws": "^8.18.2" }, "devDependencies": { - "prisma": "^6.11.0" + "prisma": "^6.19.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@emnapi/runtime": { @@ -429,10 +455,28 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@prisma/client": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.0.tgz", - "integrity": "sha512-K9TkKepOYvCOg3qCuKz7ZHf6rf58BFKi08plKjU4qVv9y7/UxO6tLz7PlWcgODUZKURLPmRHjHERffIx/8az4w==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", + "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -452,63 +496,120 @@ } }, "node_modules/@prisma/config": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.0.tgz", - "integrity": "sha512-icBfutMpdrwSf2ggo012zhQ4oianijXL/UPbv4PNVK3WUWbB3/F5Ltq8ZfElGrtwKC6XuFFPxU5qDC9x7vh8zQ==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", + "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "jiti": "2.4.2" + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.0.tgz", - "integrity": "sha512-zo4oEZMWMt0BFWl+4NK9FUpaEOmjGR3y2/r0lkW/DK4BUBRgMj90s8QqK2K+vXG3xn0nAGg2kOSu+Swn60CFLg==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", + "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.0.tgz", - "integrity": "sha512-uqnYxvPKZPvYZA7F0q4gTR+fVWUJSY5bif7JAKBIOD5SoRRy0qEIaPy4Nna5WDLQaFGshaY/Bh8dLOQMfxhJJw==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", + "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.0", - "@prisma/engines-version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173", - "@prisma/fetch-engine": "6.11.0", - "@prisma/get-platform": "6.11.0" + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/fetch-engine": "6.19.0", + "@prisma/get-platform": "6.19.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173.tgz", - "integrity": "sha512-M3vbyDICFIA1oJl0cFkM0omD4HsJZjFi0hu0f0UxyPABH8KEcZyUd5BToCrNl4B8lUeQn+L5+gfaQleOKp6Lrg==", + "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", + "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.0.tgz", - "integrity": "sha512-ZHHSP7vJFo5hePH+MNovxhqXabIg38ZpCwQfUBON29kwPX3f1pjYnzGpgJLCJy4k7mKGOzTgrXPqH8+nJvq2fw==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", + "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.0", - "@prisma/engines-version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173", - "@prisma/get-platform": "6.11.0" + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/get-platform": "6.19.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.0.tgz", - "integrity": "sha512-yspBGvOfJQwuoApk5B4aBlHDy6YDXAOe4Ml8U2eZ+M2b7fDd10YDomS3Q4qrYHUUVYF3TJyN86NcnRMOvCMUrA==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", + "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.0" + "@prisma/debug": "6.19.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" } }, "node_modules/accepts": { @@ -524,6 +625,171 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.0.tgz", + "integrity": "sha512-GljgCjeupKZJNetTqxKaQArLK10vpmK28or0+RwWjEl5Rk+/xG3wkpmkv+WrcBm3q1BwHKlnhXzR8O37kcvkXQ==", + "license": "Apache-2.0", + "optional": true, + "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-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -558,6 +824,15 @@ "node": ">=18" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -573,6 +848,35 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -602,6 +906,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -617,6 +930,43 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chromium-bidi": { + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-10.5.1.tgz", + "integrity": "sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -658,6 +1008,23 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -729,6 +1096,32 @@ "node": ">= 0.10" } }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/crypto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", @@ -736,10 +1129,19 @@ "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", "license": "ISC" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -753,6 +1155,37 @@ } } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -762,6 +1195,13 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -771,10 +1211,17 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -812,6 +1259,33 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -821,6 +1295,39 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -851,12 +1358,73 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -866,6 +1434,15 @@ "node": ">= 0.6" } }, + "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/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -917,6 +1494,62 @@ "node": ">= 0.6" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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-xml-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.3.tgz", @@ -935,6 +1568,15 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -979,6 +1621,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1016,6 +1667,53 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1068,6 +1766,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1080,12 +1804,37 @@ "node": ">=0.10.0" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1101,6 +1850,15 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1108,15 +1866,39 @@ "license": "MIT" }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -1160,6 +1942,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1202,6 +1990,15 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1253,6 +2050,12 @@ "node": ">= 0.6" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1268,6 +2071,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-addon-api": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", @@ -1277,6 +2089,13 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1297,6 +2116,26 @@ "node": ">=6.0.0" } }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1318,6 +2157,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1339,6 +2185,74 @@ "wrappy": "1" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1357,16 +2271,73 @@ "node": ">=16" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/prisma": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.0.tgz", - "integrity": "sha512-gI69E7fusgk32XALpXzdgR10xUx2aFnHiu/JaUo4O07G4JvFT0xNtD0Iy81p37iBLTYFEhWa9VrHKXaiyZ5fLQ==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", + "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { - "@prisma/config": "6.11.0", - "@prisma/engines": "6.11.0" + "@prisma/config": "6.19.0", + "@prisma/engines": "6.19.0" }, "bin": { "prisma": "build/index.js" @@ -1383,6 +2354,15 @@ } } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1396,6 +2376,97 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "24.29.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.29.1.tgz", + "integrity": "sha512-pX05JV1mMP+1N0vP3I4DOVwjMdpihv2LxQTtSfw6CUm5F0ZFLUFE/LSZ4yUWHYaM3C11Hdu+sgn7uY7teq5MYw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "10.5.1", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1521046", + "puppeteer-core": "24.29.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.29.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.29.1.tgz", + "integrity": "sha512-ErJ9qKCK+bdLvBa7QVSQTBSPm8KZbl1yC/WvhrZ0ut27hDf2QBzjDsn1IukzE1i1KtZ7NYGETOV4W1beoo9izA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "10.5.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1435,6 +2506,17 @@ "node": ">= 0.8" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1448,6 +2530,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1491,9 +2591,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1667,6 +2767,54 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1676,6 +2824,43 @@ "node": ">= 0.8" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strnum": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", @@ -1688,6 +2873,50 @@ ], "license": "MIT" }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1701,8 +2930,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-is": { "version": "2.0.1", @@ -1718,6 +2946,28 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT", + "optional": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1736,6 +2986,29 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1743,9 +3016,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1762,6 +3035,61 @@ "optional": true } } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 12f9b51..d1793ae 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@prisma/client": "^6.11.0", + "@prisma/client": "^6.19.0", "bcrypt": "^6.0.0", "chokidar": "^4.0.3", "cookie-parser": "^1.4.7", @@ -11,10 +11,13 @@ "fast-xml-parser": "^5.2.3", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.3", + "pdf-lib": "^1.17.1", + "puppeteer": "^24.29.1", "sharp": "^0.34.2", + "undici": "^7.16.0", "ws": "^8.18.2" }, "devDependencies": { - "prisma": "^6.11.0" + "prisma": "^6.19.0" } } diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index a66e9f3..38e5b32 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ diff --git a/backend/prisma/migrations/20251104132913_add_user_features_downloads/migration.sql b/backend/prisma/migrations/20251104132913_add_user_features_downloads/migration.sql new file mode 100644 index 0000000..a7e5c0f --- /dev/null +++ b/backend/prisma/migrations/20251104132913_add_user_features_downloads/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "UserFeature" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "feature" TEXT NOT NULL, + CONSTRAINT "UserFeature_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserFeature_userId_feature_key" ON "UserFeature"("userId", "feature"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 58e6a79..324f150 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,6 +11,19 @@ datasource db { * ───────────── Bestehende Tabellen ───────────── */ +enum Feature { + DOWNLOADS +} + +model UserFeature { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + feature Feature + + @@unique([userId, feature], name: "userId_feature_unique") +} + model Recognition { id Int @id @default(autoincrement()) license String @@ -42,6 +55,7 @@ model User { lastLogin DateTime? cameraAccess CameraAccess[] notificationRules NotificationRule[] @relation("UserRules") + features UserFeature[] } model CameraAccess { diff --git a/backend/prisma/update-direction.js b/backend/prisma/update-direction.js deleted file mode 100644 index 06e1402..0000000 --- a/backend/prisma/update-direction.js +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env node -/* - Backfill direction & directionDegrees from *_Info.xml files into Recognition rows. - - Usage: - node scripts/backfill-direction.js [PATH_TO_ROOT] -*/ - -const fs = require('fs'); -const path = require('path'); -const { PrismaClient } = require('@prisma/client'); -const { XMLParser } = require('fast-xml-parser'); - -const prisma = new PrismaClient(); -const parser = new XMLParser({ ignoreAttributes: false }); - -const ROOT = process.argv[2] || './data'; -const CONCURRENCY = 8; // parallel verarbeitete Dateien - -// Hilfsfunktion: alle Dateien rekursiv einsammeln -function walkFiles(dir, list = []) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walkFiles(full, list); - } else if (e.isFile() && e.name.endsWith('_Info.xml')) { - list.push(full); - } - } - return list; -} - -// XML -> Date aus {DateUTC, TimeUTC} / {DateLocal, TimeLocal} -function toDate(dateObj, timeObj) { - if (!dateObj || !timeObj) return null; - const y = Number(dateObj.Year); - const m = Number(dateObj.Month) - 1; // 0-based - const d = Number(dateObj.Day); - const hh = Number(timeObj.Hour); - const mm = Number(timeObj.Min); - const ss = Number(timeObj.Sec); - const ms = Number(timeObj.Msec || 0); - if ([y,m,d,hh,mm,ss].some(n => Number.isNaN(n))) return null; - return new Date(y, m, d, hh, mm, ss, ms); -} - -async function processXmlFile(file) { - try { - const xml = fs.readFileSync(file, 'utf8'); - const json = parser.parse(xml); - const data = json?.ReturnRecognitionData?.StandardMessage?.RecognitionData; - if (!data) return { skipped: true, reason: 'no RecognitionData' }; - - const license = String(data.License || '').trim(); - if (!license) return { skipped: true, reason: 'no license' }; - - const dirStr = data.Direction != null ? String(data.Direction).trim() : null; - const degInt = data.DirectionDegrees != null ? parseInt(data.DirectionDegrees, 10) : null; - - // nichts zu backfillen? - if ((!dirStr || dirStr.length === 0) && (!degInt || Number.isNaN(degInt))) { - return { skipped: true, reason: 'no direction fields in XML' }; - } - - // Zeiten - const tsUTC = toDate(data.DateUTC, data.TimeUTC); - const tsLocal = toDate(data.DateLocal, data.TimeLocal); - - if (!tsUTC && !tsLocal) { - return { skipped: true, reason: 'no timestamps' }; - } - - // Update-Bedingungen: - // - entweder (license, timestampUTC) matcht - // - oder (license, timestampLocal) matcht - // - und direction/directionDegrees sind leer / null / 0 - const where = { - AND: [ - { - OR: [ - ...(tsUTC ? [{ license, timestampUTC: tsUTC }] : []), - ...(tsLocal ? [{ license, timestampLocal: tsLocal }] : []), - ] - }, - { - OR: [ - { direction: null }, - { direction: '' }, - { directionDegrees: null }, - { directionDegrees: 0 }, - ] - } - ] - }; - - // Nur setzen, wenn sie in XML sinnvolle Werte haben - const dataUpdate = {}; - if (dirStr && dirStr.length > 0) dataUpdate.direction = dirStr; - if (Number.isInteger(degInt) && degInt >= 0) dataUpdate.directionDegrees = degInt; - - if (Object.keys(dataUpdate).length === 0) { - return { skipped: true, reason: 'nothing to update' }; - } - - const result = await prisma.recognition.updateMany({ - where, - data: dataUpdate, - }); - - return { updated: result.count }; - } catch (err) { - return { error: err.message || String(err) }; - } -} - -// Einfache Parallelitätssteuerung -async function run() { - console.log(`🔎 Suche XMLs unter: ${path.resolve(ROOT)}`); - const files = walkFiles(ROOT); - console.log(`Gefundene *_Info.xml: ${files.length}`); - - let idx = 0; - let active = 0; - let done = 0; - let updated = 0; - let skipped = 0; - let errors = 0; - - await new Promise((resolve) => { - const pump = () => { - while (active < CONCURRENCY && idx < files.length) { - const file = files[idx++]; - active++; - - processXmlFile(file) - .then((res) => { - done++; - if (res?.updated) { - updated += res.updated; - console.log(`✅ ${file} → updated ${res.updated}`); - } else if (res?.skipped) { - skipped++; - // optional leiser: - // console.log(`⏭️ ${file} → skipped: ${res.reason}`); - } else if (res?.error) { - errors++; - console.warn(`❌ ${file} → ${res.error}`); - } else { - // nichts - } - }) - .catch((e) => { - errors++; - console.warn(`❌ ${file} → ${e.message || e}`); - }) - .finally(() => { - active--; - if (done === files.length) resolve(); - else pump(); - }); - } - }; - pump(); - }); - - console.log('—'.repeat(50)); - console.log(`Fertig. Dateien: ${files.length}`); - console.log(`✅ Updates: ${updated}`); - console.log(`⏭️ Skipped: ${skipped}`); - console.log(`❌ Errors : ${errors}`); - - await prisma.$disconnect(); -} - -run().catch(async (e) => { - console.error('Fatal:', e); - await prisma.$disconnect(); - process.exit(1); -}); diff --git a/backend/server.js b/backend/server.js index 6eba766..378c603 100644 --- a/backend/server.js +++ b/backend/server.js @@ -15,14 +15,27 @@ const https = require('https'); require('dotenv').config(); const WATCH_PATH = process.argv[2] || process.env.WATCH_PATH || './data'; +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'https://sekt.local'; +const API_BIND = process.env.API_BIND || '0.0.0.0'; // nicht FRONTEND-Host const API_PORT = process.env.API_PORT || 3001; -const frontendBase = process.env.FRONTEND_ORIGIN || 'https://localhost:3000'; + +const originUrl = new URL(FRONTEND_ORIGIN); + +const ALLOWED_FEATURES = ['DOWNLOADS']; const prisma = new PrismaClient(); const app = express(); const nodemailer = require('nodemailer'); +const puppeteer = require('puppeteer'); + +const { Buffer } = require('buffer'); + +const { Agent, fetch } = require('undici'); + +const { PDFDocument } = require('pdf-lib'); + const mailer = nodemailer.createTransport({ host : process.env.SMTP_HOST, port : parseInt(process.env.SMTP_PORT || '587', 10), @@ -33,7 +46,7 @@ const mailer = nodemailer.createTransport({ }, }); -const allowedHosts = [process.env.FRONTEND_ORIGIN, 'localhost', '10.0.1.25', '10.0.3.6', 'kennzeichen.tegdssd.de', 'sekt.tegdssd.de', 'kennzeichen.local', 'sekt.local']; +const allowedHosts = [originUrl.hostname, 'localhost', '10.0.1.25', '10.0.3.6', 'kennzeichen.tegdssd.de', 'sekt.tegdssd.de', 'kennzeichen.local', 'sekt.local']; app.use( cors({ @@ -149,8 +162,8 @@ async function sendNotificationMail(rule, rec) { data: { token: globalSig }, }); - const unsubscribeLink = `https://sekt.tegdssd.de/api/unsubscribe?id=${tokenRecord.id}&sig=${unsubscribeSig}`; - const globalUnsubscribeLink = `https://sekt.tegdssd.de/api/unsubscribe?id=${globalTokenRecord.id}&sig=${globalSig}`; + const unsubscribeLink = new URL(`/api/unsubscribe?id=${tokenRecord.id}&sig=${unsubscribeSig}`, FRONTEND_ORIGIN).toString(); + const globalUnsubscribeLink = new URL(`/api/unsubscribe?id=${globalTokenRecord.id}&sig=${globalSig}`, FRONTEND_ORIGIN).toString(); const html = `

Hallo ${rule.user.username},

@@ -235,78 +248,60 @@ function matchesRule(rule, rec) { async function verifyToken(req, res, next) { let token; - // 1. Erst in req.cookies schauen - if (req.cookies && req.cookies.token) { - token = req.cookies.token; - } - - // 2. Falls nicht gefunden, in Authorization Header schauen - if (!token && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + if (req.cookies?.token) token = req.cookies.token; + if (!token && req.headers.authorization?.startsWith('Bearer ')) { token = req.headers.authorization.substring(7); } - - // 3. Falls nicht gefunden, manuell aus Cookie-String extrahieren if (!token && req.headers.cookie) { const cookies = req.headers.cookie.split(';'); - for (let cookie of cookies) { + for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); - if (name === 'token') { - token = value; - break; - } + if (name === 'token') { token = value; break; } } } - - if (!token) { - return res.status(401).json({ error: 'Kein Token' }); - } + if (!token) return res.status(401).json({ error: 'Kein Token' }); try { const decoded = jwt.verify(token, process.env.JWT_SECRET); const user = await prisma.user.findUnique({ - where: { id: decoded.id }, - include: { cameraAccess: true }, + where: { id: decoded.id }, + include:{ cameraAccess: true, features: { select: { feature: true } } }, }); - if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' }); - // Ablaufprüfung für Nicht-Admins + // Non-Admins: Ablauf prüfen if (!user.isAdmin && user.expiresAt && new Date(user.expiresAt) < new Date()) { pushLogout(user.id, 'expired'); return res - .clearCookie('token', { - httpOnly: true, - secure: true, - sameSite: 'none', - }) + .clearCookie('token', { httpOnly: true, secure: true, sameSite: 'none' }) .status(403).json({ error: 'Der Zugang ist abgelaufen.', logout: true }); } + const canDownload = user.isAdmin || user.features.some(f => f.feature === 'DOWNLOADS'); + req.user = { id: user.id, username: user.username, isAdmin: user.isAdmin, - cameraAccess: user.cameraAccess ?? [] + cameraAccess: user.cameraAccess ?? [], + canDownload, }; - // Token-Aktualisierung, falls < 2 Minuten verbleiben - const remainingMs = decoded.exp * 1000 - Date.now(); - if (remainingMs < 2 * 60 * 1000) { - - const maxAgeMs = 60 * 60 * 1000; // 1 Stunde in Millisekunden - + // Leises Refresh, wenn < 2 Min Restlaufzeit + const expMs = typeof decoded.exp === 'number' ? decoded.exp * 1000 : 0; + if (expMs - Date.now() < 2 * 60 * 1000) { + const maxAgeMs = 60 * 60 * 1000; const newToken = jwt.sign( - { id: user.id, username: user.username, isAdmin: user.isAdmin }, + { id: user.id, username: user.username, isAdmin: user.isAdmin, canDownload }, process.env.JWT_SECRET, - { expiresIn: maxAgeMs / 1000 } // 5 Minuten + { expiresIn: maxAgeMs / 1000 } ); - res.cookie('token', newToken, { httpOnly: true, - secure: true, + secure: true, sameSite: 'none', - maxAge: maxAgeMs, + maxAge: maxAgeMs, }); } @@ -457,7 +452,7 @@ async function processFile(filePath) { for (const rule of rules) { if (matchesRule(rule, saved)) { const url = saved.imageFile - ? `https://${frontendBase}/images/${saved.imageFile}` + ? new URL(`/images/${saved.imageFile}`, FRONTEND_ORIGIN).toString() : null; sendNotificationMail(rule, saved, url).catch(console.error); } @@ -529,58 +524,52 @@ function broadcastSSE(data) { // === POST === -app.post('/api/login', express.json(), async (req, res) => { +// ✅ /api/login +app.post('/api/login', async (req, res) => { const { username, password } = req.body; - /* ── 1. User laden ───────────────────────────── */ const user = await prisma.user.findUnique({ where: { username } }); - if (!user) { - return res.status(401).json({ error: 'Ungültiger Benutzername oder Passwort' }); - } + if (!user) return res.status(401).json({ error: 'Ungültiger Benutzername oder Passwort' }); - /* ── 2. Passwort prüfen ───────────────────────── */ const isValidPw = await bcrypt.compare(password, user.passwordHash); - if (!isValidPw) { - return res.status(401).json({ error: 'Ungültiger Benutzername oder Passwort' }); - } + if (!isValidPw) return res.status(401).json({ error: 'Ungültiger Benutzername oder Passwort' }); - /* ── 3. Ablaufdatum prüfen (außer bei Admins) ─── */ - if ( - !user.isAdmin && - user.expiresAt && - new Date(user.expiresAt) <= new Date() // ≤ jetzt ⇒ abgelaufen - ) { + if (!user.isAdmin && user.expiresAt && new Date(user.expiresAt) <= new Date()) { return res.status(403).json({ error: 'Der Zugang ist abgelaufen.' }); } - // === lastLogin aktualisieren =========================================== + // lastLogin best-effort const now = new Date(); - let updatedUserLastLogin = user.lastLogin; - try { - const updated = await prisma.user.update({ - where: { id: user.id }, - data: { lastLogin: now }, - select: { lastLogin: true }, // nur was wir brauchen + try { await prisma.user.update({ where: { id: user.id }, data: { lastLogin: now } }); } catch {} + + // Features laden + const feats = await prisma.userFeature.findMany({ where: { userId: user.id }, select: { feature: true } }); + const features = feats.map(f => f.feature); + + // Admins: DOWNLOADS garantieren (DB + Antwort) + if (user.isAdmin && !features.includes('DOWNLOADS')) { + await prisma.userFeature.upsert({ + where: { userId_feature_unique: { userId: user.id, feature: 'DOWNLOADS' } }, + update: {}, + create: { userId: user.id, feature: 'DOWNLOADS' }, }); - updatedUserLastLogin = updated.lastLogin; - } catch (err) { - console.warn('⚠️ lastLogin konnte nicht aktualisiert werden:', err); - // updatedUserLastLogin bleibt auf altem Wert (oder undefined) + features.push('DOWNLOADS'); } - /* ── 4. Token ausstellen ─────────────────────── */ - const maxAgeMs = 60 * 60 * 1000; // 1 Stunde in Millisekunden + const canDownload = user.isAdmin || features.includes('DOWNLOADS'); + + const maxAgeMs = 60 * 60 * 1000; // 1h const token = jwt.sign( - { id: user.id, username: user.username, isAdmin: user.isAdmin }, + { id: user.id, username: user.username, isAdmin: user.isAdmin, canDownload }, process.env.JWT_SECRET, { expiresIn: maxAgeMs / 1000 } ); res.cookie('token', token, { - httpOnly : true, + httpOnly: true, secure: true, sameSite: 'none', - maxAge : maxAgeMs, + maxAge: maxAgeMs, }); res.json({ @@ -591,37 +580,38 @@ app.post('/api/login', express.json(), async (req, res) => { username: user.username, isAdmin: user.isAdmin, tokenExpiresAt: Date.now() + maxAgeMs, - expiresAt: user.expiresAt ? user.expiresAt.toISOString?.() ?? user.expiresAt : null, - lastLogin: updatedUserLastLogin - ? updatedUserLastLogin.toISOString?.() ?? updatedUserLastLogin - : now.toISOString(), + expiresAt: user.expiresAt ? (user.expiresAt.toISOString?.() ?? user.expiresAt) : null, + lastLogin: now.toISOString(), + features, }, }); }); -app.post('/api/refresh-token', verifyToken, (req, res) => { +// ✅ /api/refresh-token +app.post('/api/refresh-token', verifyToken, async (req, res) => { const { id, username, isAdmin } = req.user; - - const maxAgeMs = 60 * 60 * 1000; // 1 Stunde - const maxAgeSec = maxAgeMs / 1000; - const newToken = jwt.sign( - { id, username, isAdmin }, - process.env.JWT_SECRET, - { expiresIn: maxAgeSec } // 1 Stunde - ); + // canDownload frisch gegen DB + let canDownload = !!isAdmin; + if (!canDownload) { + canDownload = await prisma.userFeature.count({ + where: { userId: id, feature: 'DOWNLOADS' } + }) > 0; + } + + const maxAgeMs = 60 * 60 * 1000; + const newToken = jwt.sign({ id, username, isAdmin, canDownload }, process.env.JWT_SECRET, { + expiresIn: maxAgeMs / 1000, + }); res.cookie('token', newToken, { httpOnly: true, - secure: true, + secure: true, sameSite: 'none', - maxAge: maxAgeMs, + maxAge: maxAgeMs, }); - res.json({ - success: true, - tokenExpiresAt: Date.now() + maxAgeMs, - }); + res.json({ success: true, tokenExpiresAt: Date.now() + maxAgeMs }); }); app.post('/api/logout', (req, res) => { @@ -678,40 +668,110 @@ app.post('/api/notifications', verifyToken, async (req, res) => { } }); +// /api/admin/create-user app.post('/api/admin/create-user', verifyToken, async (req, res) => { if (!req.user?.isAdmin) { return res.status(403).json({ error: 'Nicht autorisiert' }); } - const { username, expiresAt, cameraAccess } = req.body; + const { username, expiresAt, cameraAccess, features } = req.body; - const newPassword = generateSecurePassword(12); + const newPassword = generateSecurePassword(12); const passwordHash = await bcrypt.hash(newPassword, 10); + // nur erlaubte Features übernehmen + const validFeatures = Array.isArray(features) + ? features.filter(f => ALLOWED_FEATURES.includes(f)) + : []; + try { const user = await prisma.user.create({ data: { username, passwordHash, expiresAt: expiresAt ? new Date(expiresAt) : null, + + // Kamerazugriffe cameraAccess: { - create: cameraAccess?.map(access => ({ - camera: access.camera, - from: access.from ? new Date(access.from) : null, - to: access.to ? new Date(access.to) : null, - })) || [], + create: (cameraAccess?.map(a => ({ + camera: a.camera, + from : a.from ? new Date(a.from) : null, + to : a.to ? new Date(a.to) : null, + })) ?? []), }, + + // 🔑 HIER: Features gleich mit anlegen + ...(validFeatures.length + ? { + features: { + create: validFeatures.map(f => ({ feature: f })), + }, + } + : {}), + }, + include: { + cameraAccess: true, + features: { select: { feature: true } }, }, - include: { cameraAccess: true }, }); - res.json({ success: true, user, newPassword }); + // Antwort vereinheitlichen (Features als String-Array) + res.json({ + success: true, + user: { + ...user, + features: user.features.map(f => f.feature), + }, + newPassword, + }); } catch (err) { console.error('Fehler beim Erstellen des Benutzers:', err); res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' }); } }); + +// Zuweisen +app.post('/api/admin/features/grant', verifyToken, async (req, res) => { + if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' }); + + const { userId, feature } = req.body; + if (!userId || !ALLOWED_FEATURES.includes(feature)) { + return res.status(400).json({ error: 'Ungültige Parameter' }); + } + + await prisma.userFeature.upsert({ + where: { userId_feature_unique: { userId, feature } }, + update: {}, + create: { userId, feature }, + }); + + res.json({ success: true }); +}); + +// ✅ /api/admin/features/revoke +app.post('/api/admin/features/revoke', verifyToken, async (req, res) => { + if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' }); + + const { userId, feature } = req.body; + if (!userId || !ALLOWED_FEATURES.includes(feature)) { + return res.status(400).json({ error: 'Ungültige Parameter' }); + } + + const target = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }); + if (target?.isAdmin && feature === 'DOWNLOADS') { + return res.status(400).json({ error: 'Admins müssen das Feature "DOWNLOADS" behalten.' }); + } + + await prisma.userFeature.deleteMany({ where: { userId, feature } }); + + // ❗ Sofortige Wirkung erzwingen + pushLogout(userId, 'features-changed'); + + res.json({ success: true }); +}); + + app.post('/api/admin/reset-password/:id', verifyToken, async (req, res) => { if (!req.user?.isAdmin) { return res.status(403).json({ error: 'Nicht autorisiert' }); @@ -765,6 +825,559 @@ app.post('/api/admin/block-user/:id', verifyToken, async (req, res) => { } }); +app.post('/api/recognitions/export', verifyToken, async (req, res) => { + try { + const { format = 'csv', filters = {}, selection, fields } = req.body; + const { search = '', direction = '', timestampFrom, timestampTo, camera = '' } = filters; + + // ---- Felder-Whitelist und Defaults ----------------------------------- + const ALLOWED = [ + 'id','license','licenseFormatted','country','brand','model', + 'confidence','timestampLocal','cameraName','direction','directionDegrees' + ]; + const DEFAULT_FIELDS = [ + 'licenseFormatted','country','brand','model', + 'confidence','timestampLocal','cameraName','direction','directionDegrees' + ]; + + // In der Route, nach dem Whitelisten: + const fieldsFiltered = Array.isArray(fields) && fields.length + ? fields.filter((f) => ALLOWED.includes(f)) + : DEFAULT_FIELDS; + + // NEU: direction erzwingt directionDegrees + const fieldsToUse = pairDirectionFields(fieldsFiltered); + + // ---- Hilfen ----------------------------------------------------------- + const normalize = (s) => (s || '').toString().trim().toLowerCase().replace(/[-\s]+/g, ''); + const searchNorm = normalize(search); + + const timeFilter = {}; + if (timestampFrom) timeFilter.gte = new Date(timestampFrom); + if (timestampTo) { const t = new Date(timestampTo); t.setHours(23,59,59,999); timeFilter.lte = t; } + + const user = req.user; + + const accessFilters = user.cameraAccess.map(access => { + const tf = {}; + if (access.from) tf.gte = access.from; + if (access.to) tf.lte = access.to; + return { cameraName: access.camera, ...(Object.keys(tf).length ? { timestampLocal: tf } : {}) }; + }); + + const searchFilters = []; + if (searchNorm) { + searchFilters.push( + { license: { contains: searchNorm } }, + { brand : { contains: searchNorm } }, + { model : { contains: searchNorm } }, + ); + } + + const baseWhere = { + AND: [ + ...(!user.isAdmin ? [{ OR: accessFilters }] : []), + ...(searchFilters.length ? [{ OR: searchFilters }] : []), + ...(Object.keys(timeFilter).length ? [{ timestampLocal: timeFilter }] : []), + ...(direction === 'towards' || direction === 'away' ? [{ direction: { equals: capitalize(direction) } }] : []), + ...(camera ? [{ cameraName: camera }] : []), + ], + }; + + let where = baseWhere; + if (selection?.mode === 'selected') { + where = { AND: [baseWhere, { id: { in: selection.ids || [] } }] }; + } else if (selection?.mode === 'selected-all-except') { + where = { AND: [baseWhere, { id: { notIn: selection.exceptIds || [] } }] }; + } + + // ---- Daten holen (Superset) ------------------------------------------ + const rows = await prisma.recognition.findMany({ + where, + orderBy: { timestampLocal: 'desc' }, + select: { + id: true, license: true, licenseFormatted: true, country: true, + brand: true, model: true, confidence: true, timestampLocal: true, + cameraName: true, direction: true, directionDegrees: true, + imageFile: true, plateFile: true, + }, + take: 2000, + }); + + const clientJobId = req.body?.clientJobId || null; + + // ---- Dispatch je Format ---------------------------------------------- + if (format === 'pdf') { + return await exportAsPdf(rows, fieldsToUse, req, res, { clientJobId }); + } + + if (format === 'json') { + return exportAsJson(rows, fieldsToUse, res); + } + // default CSV + return exportAsCsv(rows, fieldsToUse, res); + + } catch (err) { + console.error('❌ Export error:', err); + res.status(500).json({ error: 'Export fehlgeschlagen' }); + } +}); + +/* ============================== HELPERS ============================== */ + +// Einheitlicher Export-Dateiname: export_YYYY-MM-DD_HH-mm. +function buildExportFilename(ext = 'csv', d = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + const yyyy = d.getFullYear(); + const mm = pad(d.getMonth() + 1); + const dd = pad(d.getDate()); + const hh = pad(d.getHours()); + const mi = pad(d.getMinutes()); + return `export_${yyyy}-${mm}-${dd}_${hh}-${mi}.${ext}`; +} + +// ---- Helper: Datei -> Data-URL ---- +async function fileToDataUrl(absPath, mime = 'image/jpeg') { + const buf = await fs.promises.readFile(absPath); + return `data:${mime};base64,${buf.toString('base64')}`; +} + +// ---- Helper: HTTP -> Data-URL mit eigener CA (für Logo-Fallback) ---- +async function fetchAsDataUrl(url, cookie = '', bearer = '') { + let dispatcher = undefined; + try { + const ca = fs.readFileSync(path.resolve(__dirname, 'certs', 'myRoot.crt')); + dispatcher = new Agent({ connect: { ca } }); + } catch { /* wenn CA fehlt, versucht er es ohne */ } + + const headers = {}; + if (cookie) headers['Cookie'] = cookie; + if (bearer) headers['Authorization'] = `Bearer ${bearer}`; + + const resp = await fetch(url, { dispatcher, headers }); + if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); + const buf = Buffer.from(await resp.arrayBuffer()); + const mime = resp.headers.get('content-type') || 'image/png'; + return `data:${mime};base64,${buf.toString('base64')}`; +} + +// ---- Helper: Bild der Erkennung -> Data-URL (bevorzugt Filesystem) ---- +async function fullPhotoDataUrlForRow(r) { + if (r.imageFile) { + try { + const abs = await findImageFile(r.imageFile); // deine vorhandene Funktion + if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg'); + } catch {} + } + // Fallback: per HTTP holen (inkl. Token/Cookies) und inline konvertieren + try { + const tokenFromCookie = req.cookies?.token || null; + const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') + ? req.headers.authorization.slice(7) + : null; + const token = tokenFromCookie || tokenFromAuth || ''; + const url = r.imageFile + ? `${BASE}/images/${r.imageFile}` + : `${BASE}/assets/img/placeholder.jpg`; + return await fetchAsDataUrl(url, req.headers.cookie || '', token); + } catch { + // letzter Fallback: „leeres“ 1x1 PNG + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII='; + } +} + +async function platePhotoDataUrlForRow(r) { + if (r.plateFile) { + try { + const abs = await findImageFile(r.plateFile); // deine vorhandene Funktion + if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg'); + } catch {} + } + // Fallback: per HTTP holen (inkl. Token/Cookies) und inline konvertieren + try { + const tokenFromCookie = req.cookies?.token || null; + const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') + ? req.headers.authorization.slice(7) + : null; + const token = tokenFromCookie || tokenFromAuth || ''; + const url = r.plateFile + ? `${BASE}/images/${r.plateFile}` + : `${BASE}/assets/img/placeholder.jpg`; + return await fetchAsDataUrl(url, req.headers.cookie || '', token); + } catch { + // letzter Fallback: „leeres“ 1x1 PNG + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII='; + } +} + +function emitToUser(userId, event, payload) { + const set = sseClients.get(String(userId)); + if (!set) return; + const msg = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const res of set) res.write(msg); +} + + +function pairDirectionFields(fields) { + if (!Array.isArray(fields)) return fields; + const hasDir = fields.includes('direction'); + const hasDeg = fields.includes('directionDegrees'); + if (hasDir && !hasDeg) { + const out = []; + for (const f of fields) { + out.push(f); + if (f === 'direction') out.push('directionDegrees'); // direkt danach einsortieren + } + return out; + } + return fields; +} + +// CSV ----------------------------------------------------------------- +function exportAsCsv(rows, fieldsToUse, res) { + const header = fieldsToUse; + const csv = [ + header.join(';'), + ...rows.map(r => + header.map(key => { + const v = r[key]; + const s = v instanceof Date ? v.toISOString() : (v ?? ''); + return `"${String(s).replace(/"/g, '""')}"`; + }).join(';') + ), + ].join('\n'); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + const filename = buildExportFilename('csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + return res.send(csv); +} + +// JSON ---------------------------------------------------------------- +function exportAsJson(rows, fieldsToUse, res) { + const slim = rows.map(r => { + const obj = {}; + for (const k of fieldsToUse) obj[k] = r[k]; + return obj; + }); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + const filename = buildExportFilename('json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + return res.send(JSON.stringify(slim)); +} + +// PDF ----------------------------------------------------------------- +// Erwartet emitToUser(userId, event, payload) als globale Helper-Funktion +// sowie findImageFile, fileToDataUrl, fetchAsDataUrl und puppeteer. + +async function exportAsPdf(rows, fieldsToUse, req, res, { clientJobId } = {}) { + const userId = String(req.user?.id || ''); + const total = rows.length; + const CHUNK_SIZE = 40; + const now = new Date(); + + // ===== Progress-Helfer ================================================== + let done = 0; // bereits "verarbeitete" Datensätze (für Progress) + + const safeEmit = (payload) => { + try { + if (typeof emitToUser === 'function') { + emitToUser(userId, 'export-progress', payload); + } + } catch {} + }; + + const ping = (stage, overrideProgress) => { + if (!clientJobId) return; + const computed = total > 0 ? Math.round((done / total) * 98) : 1; + const progress = Math.max(1, Math.min(99, overrideProgress ?? computed)); + safeEmit({ jobId: clientJobId, stage, done, total, progress }); + }; + + // ===== Utilities ======================================================== + const escapeHtml = (s) => + String(s ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + .replace(/'/g, '''); + + const fmtDate = (d) => { + try { + return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(d)); + } catch { return new Date(d).toISOString(); } + }; + + const BASE = process.env.FRONTEND_ORIGIN || 'http://localhost:3000'; + + async function fileToDataUrl(absPath, mime = 'image/jpeg') { + const buf = await fs.promises.readFile(absPath); + return `data:${mime};base64,${buf.toString('base64')}`; + } + + async function fetchAsDataUrl(url, cookie = '', bearer = '') { + let dispatcher; + try { + const ca = fs.readFileSync(path.resolve(__dirname, 'certs', 'myRoot.crt')); + dispatcher = new Agent({ connect: { ca } }); + } catch {} + const headers = {}; + if (cookie) headers['Cookie'] = cookie; + if (bearer) headers['Authorization'] = `Bearer ${bearer}`; + const resp = await fetch(url, { dispatcher, headers }); + if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); + const buf = Buffer.from(await resp.arrayBuffer()); + const mime = resp.headers.get('content-type') || 'image/png'; + return `data:${mime};base64,${buf.toString('base64')}`; + } + + async function findImageFile(filename) { + if (!filename) return null; + const jpgFilename = filename.replace(/\.webp$/, '.jpg'); + function searchInDir(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { const f = searchInDir(full); if (f) return f; } + else if (e.isFile() && e.name === jpgFilename) return full; + } + } catch {} + return null; + } + return searchInDir(WATCH_PATH); + } + + async function fullPhotoDataUrlForRow(r) { + if (r.imageFile) { + const abs = await findImageFile(r.imageFile); + if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg'); + } + try { + const tokenFromCookie = req.cookies?.token || null; + const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null; + const token = tokenFromCookie || tokenFromAuth || ''; + const url = r.imageFile ? `${BASE}/images/${r.imageFile}` : `${BASE}/assets/img/placeholder.jpg`; + return await fetchAsDataUrl(url, req.headers.cookie || '', token); + } catch { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII='; + } + } + + async function platePhotoDataUrlForRow(r) { + if (r.plateFile) { + const abs = await findImageFile(r.plateFile); + if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg'); + } + try { + const tokenFromCookie = req.cookies?.token || null; + const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null; + const token = tokenFromCookie || tokenFromAuth || ''; + const url = r.plateFile ? `${BASE}/images/${r.plateFile}` : `${BASE}/assets/img/placeholder.jpg`; + return await fetchAsDataUrl(url, req.headers.cookie || '', token); + } catch { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII='; + } + } + + const labelOf = { + id:'ID', license:'Kennzeichen (roh)', licenseFormatted:'Kennzeichen', + country:'Land', brand:'Marke', model:'Modell', confidence:'Treffsicherheit', + timestampLocal:'Zeit', cameraName:'Kamera', direction:'Richtung', directionDegrees:'Richtung (°)', + }; + const fieldsForPdf = fieldsToUse.filter(f => f !== 'directionDegrees'); + const valueFor = (r, k) => { + if (k === 'timestampLocal') return fmtDate(r.timestampLocal); + if (k === 'confidence' && r.confidence != null) return `${r.confidence}%`; + if (k === 'licenseFormatted') return r.licenseFormatted || r.license || '-'; + if (k === 'direction') return r.directionDegrees != null ? `${r.direction ?? '-'} (${r.directionDegrees}°)` : (r.direction ?? '-'); + return r[k] ?? '-'; + }; + + // ---- Logo -------------------------------------------------------------- + let logoSrc; + try { + const svg = path.resolve(__dirname, 'assets', 'img', 'logo.svg'); + const png = path.resolve(__dirname, 'assets', 'img', 'logo.png'); + if (fs.existsSync(svg)) { + logoSrc = await fileToDataUrl(svg, 'image/svg+xml'); + } else if (fs.existsSync(png)) { + logoSrc = await fileToDataUrl(png, 'image/png'); + } else { + const cookie = req.headers.cookie || ''; + const bearer = req.cookies?.token || ''; + try { + logoSrc = await fetchAsDataUrl(`${BASE}/assets/img/logo.svg`, cookie, bearer); + } catch { + logoSrc = await fetchAsDataUrl(`${BASE}/assets/img/logo.png`, cookie, bearer); + } + } + } catch { + logoSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAACENnwnAAAAIUlEQVR42mP8z8Dwn4GBgYHhP2NgYGBg+P///w8GAAxwBvUQ8ZqjAAAAAElFTkSuQmCC'; + } + + const headerHtml = ` +
+
+

PP Düsseldorf

+

Dir. GE / Spezialeinheiten

+

Technische Einsatzgruppe

+
+ +
+ `; + + const minTs = rows.length ? new Date(Math.min(...rows.map(r => new Date(r.timestampLocal).getTime()))) : null; + const maxTs = rows.length ? new Date(Math.max(...rows.map(r => new Date(r.timestampLocal).getTime()))) : null; + const rangeHtml = (minTs && maxTs) + ? `
Von: ${escapeHtml(fmtDate(minTs))}
+
Bis: ${escapeHtml(fmtDate(maxTs))}
` : '–'; + + const coverHtml = ` +
+ ${headerHtml} +
+

Kennzeichenerfassung – Export

+
+
Erstellt von
${escapeHtml(req.user?.username || 'Unbekannt')}
+
Erstellt am
${escapeHtml(fmtDate(now))}
+
Anzahl Einträge
${rows.length.toLocaleString('de-DE')}
+
Zeitraum
${rangeHtml}
+
+
+
+ `; + + const style = ` + @page { size: A4; margin: 14mm 12mm 16mm; } + body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif; color:#111; } + .head{ display:flex; justify-content:space-between; align-items:center; gap:12mm; width:100%; max-width:160mm; margin:0 auto 12mm; } + .head-left p{ margin:0; } + .head-logo img{ height:14mm; object-fit:contain; display:block; border:0; } + .cover .head{ max-width:150mm; } + .page{ page-break-after: always; display:flex; flex-direction:column; align-items:center; } + .cover{ justify-content:center; } + .cover-inner{ max-width:160mm; text-align:center; } + .cover h1{ font-size:22pt; margin:6mm 0 10mm; } + .center{ width:100%; display:flex; justify-content:center; } + .meta{ display:grid; grid-template-columns:45mm 1fr; gap:6px 12px; font-size:12pt; margin:0 auto; } + .meta dt{ color:#555; font-weight:600; text-align:left; } .meta dd{ margin:0; text-align:left; } + .meta.meta--stack{ display:grid; grid-template-columns:1fr; gap:10px; justify-content:center; } + .meta.meta--stack .row{ display:grid; grid-template-columns:45mm 1fr; column-gap:14px; align-items:start; } + .meta .muted{ color:#666; } + .photo-wrap{ width:100%; display:flex; flex-direction:column; align-items:center; gap:4mm; } + .photo{ max-width:160mm; max-height:100mm; object-fit:contain; border-radius:6px; border:1px solid #ddd; } + .details{ width:100%; margin-top:10mm; } + h2{ margin:0 0 6mm; font-size:16pt; } + table{ width:100%; border-collapse:collapse; font-size:11pt; } + th,td{ padding:6px 0; vertical-align:top; text-align:left; } + th{ width:42mm; color:#444; font-weight:600; padding-right:8mm; } + tr+tr td, tr+tr th{ border-top:1px solid #eee; } + `; + + // 🔹 pro Zeile pingen + kurz yielden + async function buildPagesHtml(subRows) { + const chunks = []; + for (const r of subRows) { + const [fullPhotoSrc, platePhotoSrc] = await Promise.all([ + fullPhotoDataUrlForRow(r), + platePhotoDataUrlForRow(r), + ]); + + const detailRows = fieldsForPdf.map(k => ` + ${escapeHtml(labelOf[k])}${escapeHtml(String(valueFor(r,k)))} + `).join(''); + + chunks.push(` + ${headerHtml} +
+

Erkennung #${r.id}

+
+ Plate ${escapeHtml(r.licenseFormatted || r.license || String(r.id))} + Full ${escapeHtml(r.licenseFormatted || r.license || String(r.id))} +
+
${detailRows}
+
+ `); + + // 🔹 hier: done+1 und sofort Progress senden + done += 1; + ping(`Seite ${done}/${total}`); + + // 🔹 Event-Loop freigeben, damit SSE rausfliegt + await new Promise(resolve => setImmediate(resolve)); + } + return chunks.join('\n'); + } + + function wrapHtml(body) { + return ` + + Kennzeichenerfassung Export + ${body}`; + } + + // ===== Progress-Start =================================================== + ping('starte…', 1); + ping('PDF wird aufgebaut…'); + + // ---- CHUNKED RENDERING ------------------------------------------------- + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--no-zygote','--no-first-run','--no-default-browser-check'], + }); + const page = await browser.newPage(); + page.setDefaultTimeout(60_000); + page.setDefaultNavigationTimeout(60_000); + + const partBuffers = []; + + for (let i = 0; i < rows.length; i += CHUNK_SIZE) { + const sub = rows.slice(i, i + CHUNK_SIZE); + const pagesHtml = await buildPagesHtml(sub); // 🔹 pings passieren hier pro Zeile + const withCover = i === 0 ? coverHtml : ''; + const html = wrapHtml(`${withCover}${pagesHtml}`); + + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + await page.emulateMediaType('screen'); + + const buf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '14mm', right: '12mm', bottom: '16mm', left: '12mm' }, + }); + partBuffers.push(buf); + + // 🔹 zusätzlicher Stage-Hinweis (ohne Progress-Override) + ping(`Teil ${Math.floor(i / CHUNK_SIZE) + 1} gerendert`); + await new Promise(resolve => setImmediate(resolve)); + } + + await page.close(); + await browser.close(); + + // ---- PDFs zusammenführen ---------------------------------------------- + ping('PDF wird zusammengeführt…', Math.min(99, (total > 0 ? Math.round((done / total) * 98) + 1 : 98))); + + const merged = await PDFDocument.create(); + for (const b of partBuffers) { + const src = await PDFDocument.load(b); + const pages = await merged.copyPages(src, src.getPageIndices()); + pages.forEach(p => merged.addPage(p)); + } + const mergedBytes = await merged.save(); + + // Final + ping('bereit zum Download', 99); + + const filename = buildExportFilename('pdf', now); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + return res.send(Buffer.from(mergedBytes)); +} + + + // === PUT === app.put('/api/notifications/:id', verifyToken, async (req, res) => { @@ -856,28 +1469,22 @@ app.put('/api/notifications/:id', verifyToken, async (req, res) => { } }); - +// ✅ /api/admin/update-user/:id app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => { - if (!req.user?.isAdmin) { - return res.status(403).json({ error: 'Nicht autorisiert' }); - } + if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' }); - const userId = req.params.id; // String-ID! - const { username, expiresAt, cameraAccess } = req.body; + const userId = req.params.id; + const { username, expiresAt, cameraAccess, features } = req.body; try { - /* 1. Benutzer aktualisieren ---------------------------------------- */ + // Update Basisdaten await prisma.user.update({ where: { id: userId }, - data : { - username, - expiresAt: expiresAt ? new Date(expiresAt) : null, - }, + data : { username, expiresAt: expiresAt ? new Date(expiresAt) : null }, }); - /* 2. Kamera-Zugriffe neu setzen ------------------------------------ */ + // Kamera-Zugriffe ersetzen await prisma.cameraAccess.deleteMany({ where: { userId } }); - if (Array.isArray(cameraAccess) && cameraAccess.length) { await prisma.cameraAccess.createMany({ data: cameraAccess.map(a => ({ @@ -885,13 +1492,50 @@ app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => { from : a.from ? new Date(a.from) : null, to : a.to ? new Date(a.to) : null, userId, - })) + })), }); } - /* 3. Sofortige Abmeldung, falls Zugang jetzt abgelaufen ----------- */ + // Features neu setzen + Entzug erkennen + if (Array.isArray(features)) { + const before = await prisma.userFeature.findMany({ + where: { userId }, + select: { feature: true }, + }); + const hadDownloads = before.some(f => f.feature === 'DOWNLOADS'); + + await prisma.userFeature.deleteMany({ where: { userId } }); + const toCreate = features + .filter(f => ALLOWED_FEATURES.includes(f)) + .map(f => ({ userId, feature: f })); + if (toCreate.length) await prisma.userFeature.createMany({ data: toCreate }); + + // Admins müssen DOWNLOADS behalten + const isTargetAdmin = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }); + if (isTargetAdmin?.isAdmin) { + await prisma.userFeature.upsert({ + where: { userId_feature_unique: { userId, feature: 'DOWNLOADS' } }, + update: {}, + create: { userId, feature: 'DOWNLOADS' }, + }); + } + + // Nachher prüfen + const after = await prisma.userFeature.findMany({ + where: { userId }, + select: { feature: true }, + }); + const hasDownloadsNow = after.some(f => f.feature === 'DOWNLOADS'); + + // ❗ Bei Entzug sofort ausloggen (JWT wird ungültig gemacht, weil Client /api/logout aufruft) + if (hadDownloads && !hasDownloadsNow) { + pushLogout(userId, 'features-changed'); + } + } + + // Sofortige Abmeldung, falls Zugang jetzt abgelaufen if (expiresAt && new Date(expiresAt) <= new Date()) { - pushLogout(userId, 'expired'); // <<==== HIER + pushLogout(userId, 'expired'); } res.json({ success: true }); @@ -968,6 +1612,7 @@ app.get('/api/notifications', verifyToken, async (req, res) => { app.get('/api/unsubscribe', async (req, res) => { const id = req.query.id?.toString(); // UUID (Token-ID) const sig = req.query.sig?.toString(); // HMAC-Signatur + const redirectTo = new URL('/unsubscribe', FRONTEND_ORIGIN).toString(); const setStatus = (status, ruleId = '') => { res.cookie('unsubscribeStatus', status, { @@ -987,49 +1632,87 @@ app.get('/api/unsubscribe', async (req, res) => { if (!id || !sig) { setStatus('error'); - return res.redirect(`${frontendBase}/unsubscribe`); + return res.redirect(302, redirectTo); } try { const record = await prisma.unsubscribeToken.findUnique({ where: { id } }); - if (!record) { setStatus('invalid'); - return res.redirect(`${frontendBase}/unsubscribe`); + return res.redirect(302, redirectTo); } const expectedSig = createUnsubscribeToken(record.ruleId, record.email); const isValid = sig === expectedSig; - if (!isValid) { setStatus('invalid'); - return res.redirect(`${frontendBase}/unsubscribe`); + return res.redirect(302, redirectTo); } const ruleId = record.ruleId; - // Regel-spezifische Austragung + /* ───────────────────────────── Rule-spezifische Austragung ───────────────────────────── */ if (ruleId !== null) { - const deleted = await prisma.notificationRecipient.deleteMany({ - where: { ruleId, email: record.email }, + const result = await prisma.$transaction(async (tx) => { + // Empfänger (nur diese E-Mail) bei dieser Regel entfernen + const del = await tx.notificationRecipient.deleteMany({ + where: { ruleId, email: record.email }, + }); + + // Prüfen, ob die Regel danach noch Empfänger hat + const remaining = await tx.notificationRecipient.count({ where: { ruleId } }); + + if (remaining === 0) { + // Aufräumen: erst Tokens für diese Regel löschen, dann die Regel selbst + await tx.unsubscribeToken.deleteMany({ where: { ruleId } }); + await tx.notificationRule.delete({ where: { id: ruleId } }); + // Falls der aktuelle Token noch existiert (kann in deleteMany enthalten sein), ignorieren wir das bewusst + return { removed: del.count, prunedRule: true }; + } else { + // Nur den benutzten Token löschen + await tx.unsubscribeToken.delete({ where: { id: record.id } }); + return { removed: del.count, prunedRule: false }; + } }); - await prisma.unsubscribeToken.delete({ where: { id } }); - - setStatus(deleted.count > 0 ? 'success' : 'notfound', ruleId); - return res.redirect(`${frontendBase}/unsubscribe`); + setStatus(result.removed > 0 ? 'success' : 'notfound', ruleId); + return res.redirect(302, redirectTo); } - // Globale Austragung - const deleted = await prisma.notificationRecipient.deleteMany({ where: { email: record.email } }); - await prisma.unsubscribeToken.deleteMany({ where: { email: record.email } }); + /* ───────────────────────────── Globale Austragung ───────────────────────────── + Entfernt diese E-Mail aus allen Regeln und löscht alle Regeln, die danach + keine Empfänger mehr haben. Räumt anschließend alle Tokens dieser E-Mail auf. + ─────────────────────────────────────────────────────────────────────────────── */ + const outcome = await prisma.$transaction(async (tx) => { + // 1) Alle Empfänger-Einträge dieser E-Mail entfernen + const del = await tx.notificationRecipient.deleteMany({ where: { email: record.email } }); + + // 2) Alle Regeln ohne Empfänger finden … + const emptyRules = await tx.notificationRule.findMany({ + where: { recipients: { none: {} } }, + select: { id: true }, + }); + const emptyIds = emptyRules.map(r => r.id); + + // … und löschen (vorher zugehörige Tokens weg) + if (emptyIds.length) { + await tx.unsubscribeToken.deleteMany({ where: { ruleId: { in: emptyIds } } }); + await tx.notificationRule.deleteMany({ where: { id: { in: emptyIds } } }); + } + + // 3) Alle Tokens dieser E-Mail entfernen (inkl. des aktuell genutzten) + await tx.unsubscribeToken.deleteMany({ where: { email: record.email } }); + + return { removed: del.count, prunedCount: emptyIds.length }; + }); + + setStatus(outcome.removed > 0 ? 'success' : 'notfound', 'all'); + return res.redirect(302, redirectTo); - setStatus(deleted.count > 0 ? 'success' : 'notfound', 'all'); - return res.redirect(`${frontendBase}/unsubscribe`); } catch (err) { console.error('❌ Fehler bei /api/unsubscribe:', err); setStatus('error'); - return res.redirect(`${frontendBase}/unsubscribe`); + return res.redirect(302, redirectTo); } }); @@ -1067,26 +1750,40 @@ app.get('/api/me', async (req, res) => { if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' }); try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - // User aus DB holen, um lastLogin (und ggf. expiresAt) zu liefern + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET fehlt'); + + const raw = jwt.verify(token, secret); // kann string oder object sein + if (typeof raw !== 'object' || raw === null) { + throw new Error('Ungültiger Token-Payload'); + } + + // raw ist nun ein Objekt – Felder defensiv auslesen + const id = String(raw.id); + const username = String(raw.username); + const isAdmin = Boolean(raw.isAdmin); + const exp = typeof raw.exp === 'number' ? raw.exp : undefined; + const dbUser = await prisma.user.findUnique({ - where: { id: decoded.id }, + where: { id }, // User.id ist String (cuid) select: { lastLogin: true, expiresAt: true, + features: { select: { feature: true } }, // Enum-Werte holen }, }); - + res.json({ - id: decoded.id, - username: decoded.username, - isAdmin: decoded.isAdmin, - tokenExpiresAt: decoded.exp ? decoded.exp * 1000 : undefined, + id, + username, + isAdmin, + tokenExpiresAt: exp ? exp * 1000 : undefined, lastLogin: dbUser?.lastLogin ? dbUser.lastLogin.toISOString() : null, expiresAt: dbUser?.expiresAt ? dbUser.expiresAt.toISOString() : null, + features: dbUser?.features?.map(f => f.feature) ?? [], // -> ['DOWNLOADS'] }); } catch (err) { + console.error('GET /api/me error:', err); res.status(401).json({ error: 'Ungültiger Token' }); } }); @@ -1096,8 +1793,13 @@ app.get('/api/recognitions', verifyToken, async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; - const search = req.query.search?.toString().trim().toLowerCase().replace(/\s+/g, ''); + //const search = req.query.search?.toString().trim().toLowerCase().replace(/\s+/g, ''); + const searchRaw = req.query.search?.toString() ?? ''; + const searchRawLower = searchRaw.trim().toLowerCase(); + // Entfernt Bindestriche UND Whitespaces (robuster): + const searchNoSep = searchRawLower.replace(/[^a-z0-9]+/g, ''); const direction = req.query.direction?.toString(); + const camera = req.query.camera?.toString()?.trim(); let timestampFrom = req.query.timestampFrom ? new Date(req.query.timestampFrom) : null; let timestampTo = req.query.timestampTo ? new Date(req.query.timestampTo) : null; @@ -1127,11 +1829,18 @@ app.get('/api/recognitions', verifyToken, async (req, res) => { // Suchfilter const searchFilters = []; - if (search) { + if (searchNoSep) { + // 1) Auf "license" ohne Trennzeichen suchen (DB-Feld ohne "-") + searchFilters.push({ license: { contains: searchNoSep } }); + + // 2) Optional zusätzlich auf licenseFormatted MIT Trennzeichen suchen + // (falls der Nutzer "D-AB" tippt und dein Feld so gespeichert ist) + searchFilters.push({ licenseFormatted: { contains: searchRawLower } }); + + // 3) Brand/Model normal (Groß-/Kleinschreibung ignorieren) searchFilters.push( - { license: { contains: search } }, - { brand: { contains: search } }, - { model: { contains: search } }, + { brand: { contains: searchRawLower } }, + { model: { contains: searchRawLower } }, ); } @@ -1149,6 +1858,7 @@ app.get('/api/recognitions', verifyToken, async (req, res) => { ...(direction === 'towards' || direction === 'away' ? [{ direction: { equals: capitalize(direction) } }] : []), + ...(camera ? [{ cameraName: camera }] : []), ], }; @@ -1183,7 +1893,7 @@ app.get('/api/recognitions/stream', verifyToken, (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - res.setHeader('Access-Control-Allow-Origin', 'https://kennzeichen.local'); + res.setHeader('Access-Control-Allow-Origin', FRONTEND_ORIGIN); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.flushHeaders(); @@ -1412,7 +2122,7 @@ app.get('/api/recognitions/top10plates', verifyToken, async (req, res) => { res.json({ data }); } catch (error) { - console.error('❌ Fehler bei /api/recognitions/top10plates:', err); + console.error('❌ Fehler bei /api/recognitions/top10plates:', error); res.status(500).json({ error: 'Interner Serverfehler' }); } }); @@ -1545,6 +2255,7 @@ app.get('/api/admin/users', verifyToken, async (req, res) => { to: true, }, }, + features: { select: { feature: true } }, }, }); @@ -1561,6 +2272,7 @@ app.get('/api/admin/users', verifyToken, async (req, res) => { from: c.from ? c.from.toISOString() : null, to: c.to ? c.to.toISOString() : null, })), + features: u.features.map(f => f.feature), })); res.json({ users }); @@ -1579,8 +2291,8 @@ const sslOptions = { cert: fs.readFileSync(certPath), }; -https.createServer(sslOptions, app).listen(API_PORT, () => { - console.log(`✅ HTTPS-Server läuft auf https://localhost:${API_PORT}`); +https.createServer(sslOptions, app).listen(API_PORT, API_BIND, () => { + console.log(`✅ HTTPS-Server läuft auf https://${API_BIND}:${API_PORT}`); }); // === XML-Parser und Dateiüberwachung === diff --git a/frontend/data/changelog.xml b/frontend/data/changelog.xml new file mode 100644 index 0000000..f0759ae --- /dev/null +++ b/frontend/data/changelog.xml @@ -0,0 +1,58 @@ + + + + Layoutanpassung in Administration + Problem behoben, bei dem die Abmelde-Links der Benachrichtigungen nicht richtig funktioniert haben + + + + Filter für Kameras hinzugefügt + Downloadseite für Firmware und Dokumentationen hinzugefügt + Benutzerrechte um Download-Feature erweitert + Ergebnisse sind jetzt auswählbar und exportierbar + Layoutanpassung in Administration + Problem behoben, bei dem die Abmelde-Links der Benachrichtigungen nicht richtig funktioniert haben + + + + Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis geliefert hat + Filter für Fahrtrichtung des Fahrzeugs hinzugefügt + Farbe der Treffsicherheit für bessere Lesbarkeit angepasst + Treffsicherheit für Marke & Modell in Details hinzugefügt + Layoutanpassung im Dashboard + + + + SE Kennzeichenerfassungstool)]]> + Problem mit der automatischen Abmeldung behoben + Hinweis nach dem Abmelden auf der Loginseite hinzugefügt + Spalte mit dem Zeitpunkt der letzten Anmeldung des Benutzers in der Administration hinzugefügt + + + + Überarbeitetes Benutzerinterface + Benutzerlogin hinzugefügt + Benutzerrollen hinzugefügt + + Neue Funktionen für Admins hinzugefügt + Neue Benutzer hinzufügen + Neues Passwort generieren + Zugang sperren + Zugang einschränken + Benutzer bearbeiten + Benutzer löschen + + Beschränkung der Ergebnisse auf einen festgelegten Zeitraum + Beschränkung der Ergebnisse für eine festgelegte Kamera + Benachrichtigungen per E-Mail mit benutzerdefinierten Regeln + Automatische Abmeldung nach 5 Minuten Inaktivität + + + + Problem behoben, bei der Suchanfragen mit einem Leerzeichen im Suchbegriff kein Ergebnis geliefert hat + + + + Erster Release + + diff --git a/frontend/data/downloads.json b/frontend/data/downloads.json new file mode 100644 index 0000000..77e5b43 --- /dev/null +++ b/frontend/data/downloads.json @@ -0,0 +1,13 @@ +{ + "firmwares": [ + { + "version": "6.1", + "date": "2024-10-03", + "url": "/assets/downloads/firmware/CatchCAM2/6.1/CatchCAM2-europe-6.1-20241003-1123.sys", + "releaseNotesUrl": "/assets/downloads/firmware/CatchCAM2/6.1/Release notes CatchCAM 2MP 6.1.pdf" + } + ], + "documents": [ + { "title": "CatchCAM 2MP User Manual", "url": "/assets/downloads/docs/CatchCAM2/6.0/CatchCAM 2MP User Manual 6.0.pdf", "lang": "en", "kind": "Manual" } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b33245..c145b0b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@preline/select": "^3.1.0", "@preline/theme-switch": "^3.1.0", "bcrypt": "^6.0.0", - "chart.js": "^4.5.0", + "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "clipboard": "^2.0.11", "clsx": "^2.1.1", @@ -22,6 +22,7 @@ "datatables.net-dt": "^2.3.1", "date-fns": "^4.1.0", "dropzone": "^6.0.0-beta.2", + "fast-xml-parser": "^5.3.1", "framer-motion": "^12.23.0", "https": "^1.0.0", "jquery": "^3.7.1", @@ -1690,6 +1691,7 @@ "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1757,6 +1759,7 @@ "integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.33.1", "@typescript-eslint/types": "8.33.1", @@ -2245,6 +2248,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2685,10 +2689,11 @@ } }, "node_modules/chart.js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", - "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3252,6 +3257,7 @@ "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3426,6 +3432,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -3723,6 +3730,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.1.tgz", + "integrity": "sha512-jbNkWiv2Ec1A7wuuxk0br0d0aTMUtQ4IkL+l/i1r9PRf6pLXjDgsBsWwO+UyczmQlnehi4Tbc8/KIvxGQe+I/A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5774,6 +5799,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5793,6 +5819,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6400,6 +6427,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6454,7 +6493,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz", "integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.2", @@ -6528,6 +6568,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6677,6 +6718,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 01c3ee2..d98fa13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "@preline/select": "^3.1.0", "@preline/theme-switch": "^3.1.0", "bcrypt": "^6.0.0", - "chart.js": "^4.5.0", + "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "clipboard": "^2.0.11", "clsx": "^2.1.1", @@ -24,6 +24,7 @@ "datatables.net-dt": "^2.3.1", "date-fns": "^4.1.0", "dropzone": "^6.0.0-beta.2", + "fast-xml-parser": "^5.3.1", "framer-motion": "^12.23.0", "https": "^1.0.0", "jquery": "^3.7.1", diff --git a/frontend/public/assets/downloads/docs/CatchCAM2/6.0/CatchCAM 2MP User Manual 6.0.pdf b/frontend/public/assets/downloads/docs/CatchCAM2/6.0/CatchCAM 2MP User Manual 6.0.pdf new file mode 100644 index 0000000..7d880c5 Binary files /dev/null and b/frontend/public/assets/downloads/docs/CatchCAM2/6.0/CatchCAM 2MP User Manual 6.0.pdf differ diff --git a/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/CatchCAM2-europe-6.1-20241003-1123.sys b/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/CatchCAM2-europe-6.1-20241003-1123.sys new file mode 100644 index 0000000..bf38b6b Binary files /dev/null and b/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/CatchCAM2-europe-6.1-20241003-1123.sys differ diff --git a/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/Release notes CatchCAM 2MP 6.1.pdf b/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/Release notes CatchCAM 2MP 6.1.pdf new file mode 100644 index 0000000..efbec7e Binary files /dev/null and b/frontend/public/assets/downloads/firmware/CatchCAM2/6.1/Release notes CatchCAM 2MP 6.1.pdf differ diff --git a/frontend/public/assets/img/csv.svg b/frontend/public/assets/img/csv.svg new file mode 100644 index 0000000..9d33925 --- /dev/null +++ b/frontend/public/assets/img/csv.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/img/json.svg b/frontend/public/assets/img/json.svg new file mode 100644 index 0000000..de1f669 --- /dev/null +++ b/frontend/public/assets/img/json.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/img/pdf.svg b/frontend/public/assets/img/pdf.svg new file mode 100644 index 0000000..0bbd4a6 --- /dev/null +++ b/frontend/public/assets/img/pdf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 45b2b22..f317b87 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -110,7 +110,7 @@ export default function LoginPage() { {/* Logout-Grund Alert */} - {logoutNotice && mapped ? ( + {logoutNotice && mapped && !error ? (
| undefined; +} + +const checksumCache: Map = + globalThis.__checksumCache ?? new Map(); + +globalThis.__checksumCache = checksumCache; + +function urlToAbsolutePublicPath(url: string) { + // nur lokale URLs wie "/assets/..." zulassen + if (!url.startsWith("/")) return null; + const clean = url.split("?")[0].replace(/^\/+/, ""); // Query entfernen, führende Slashes trimmen + const abs = path.join(publicDir, clean); + // Pfad-Traversal verhindern + if (!abs.startsWith(publicDir)) return null; + return abs; +} + +function hashFile(absPath: string, algo: "sha256" | "md5"): Promise { + return new Promise((resolve, reject) => { + const h = crypto.createHash(algo); + const s = createReadStream(absPath); + s.on("error", reject); + s.on("data", (chunk) => h.update(chunk)); + s.on("end", () => resolve(h.digest("hex"))); + }); +} + +async function computeChecksumsForUrl(url: string): Promise<{ sha256?: string; md5?: string }> { + const abs = urlToAbsolutePublicPath(url); + if (!abs) return {}; + + try { + const st = await fs.stat(abs); + const cached = checksumCache.get(abs); + if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) { + return { sha256: cached.sha256, md5: cached.md5 }; + } + + const [sha256, md5] = await Promise.all([hashFile(abs, "sha256"), hashFile(abs, "md5")]); + checksumCache.set(abs, { sha256, md5, mtimeMs: st.mtimeMs, size: st.size }); + return { sha256, md5 }; + } catch { + // Datei existiert nicht o.ä. → still zurückgeben + return {}; + } +} + +async function withAutoChecksums(fw: Firmware): Promise { + // vorhandene Werte aus JSON priorisieren; fehlende werden ergänzt + const needSha = !fw.checksumSha256; + const needMd5 = !fw.checksumMD5; + + if (!needSha && !needMd5) return fw; + + const computed = await computeChecksumsForUrl(fw.url); + return { + ...fw, + checksumSha256: fw.checksumSha256 ?? computed.sha256, + checksumMD5: fw.checksumMD5 ?? computed.md5, + }; +} + + +/* ---------- Typen ---------- */ +type Firmware = { + version: string; + date: string; // ISO-Datum + url: string; + releaseNotesUrl?: string; // <-- NEU: Link zur PDF + notes?: string; // (legacy, falls alte JSONs noch Text haben) + checksumSha256?: string; + checksumMD5?: string; +}; + +type Doc = { + title: string; + url: string; + lang?: string; + kind?: string; +}; + +type DownloadsFile = { + firmwares: Firmware[]; + documents: Doc[]; +}; + +/* ---------- Utils ---------- */ +function cmpSemverDesc(a: string, b: string) { + const pa = a.replace(/^v/i, "").split(".").map(Number); + const pb = b.replace(/^v/i, "").split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da !== db) return db - da; + } + return 0; +} +function fmtDateDE(iso: string) { + try { + return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date(iso)); + } catch { + return iso; + } +} + +/* ---------- Metadata ---------- */ +export const metadata = { + title: "Downloads – Kamera Firmware & Dokumente", + description: "Lade die aktuelle Firmware und Handbücher für deine Kamera herunter.", +}; + +/* ---------- Page (Server Component) ---------- */ +export default async function DownloadPage() { + const filePath = path.join(process.cwd(), "data", "downloads.json"); + let data: DownloadsFile = { firmwares: [], documents: [] }; + + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as DownloadsFile; + data = { + firmwares: Array.isArray(parsed.firmwares) ? parsed.firmwares : [], + documents: Array.isArray(parsed.documents) ? parsed.documents : [], + }; + } catch (err) { + console.warn("downloads.json konnte nicht gelesen werden:", err); + } + + const firmwaresWithHashes = await Promise.all( + data.firmwares.map((fw) => withAutoChecksums(fw)) + ); + + const sorted = [...firmwaresWithHashes].sort((a, b) => cmpSemverDesc(a.version, b.version)); + const latest = sorted[0]; + const older = sorted.slice(1); + + return ( +
+
+

Downloads

+

+ Hier findest du Firmware-Versionen für die Kamera sowie Handbücher und weitere Dokumente. +

+
+ + {/* 2-Spalten-Layout */} +
+ {/* Firmware (links) */} +
+
+

+ Firmware +

+ + {latest && ( + + + Neueste Version herunterladen + + )} +
+ + {latest ? ( +
+
+
+
Version {latest.version}
+
+ Veröffentlichungsdatum: {fmtDateDE(latest.date)} +
+
+
+ + + Download + +
+
+ + {/* Release Notes Link (statt Text) */} + {(latest.releaseNotesUrl || latest.notes) && ( +

+ + {/* kleines PDF-Icon */} + + Release Notes (PDF) + +

+ )} + + {/* ⬇️ SHA/MD5: klickbar & kopierbar */} + {(latest.checksumSha256 || latest.checksumMD5) && ( +
+ + +
+ )} +
+ ) : ( +

Keine Firmware verfügbar.

+ )} + + {/* Ältere Versionen */} + {older.length > 0 && ( +
+ + Ältere Versionen anzeigen + +
    + {older.map((fw) => ( +
  • +
    +
    Version {fw.version}
    +
    {fmtDateDE(fw.date)}
    + + {(fw.releaseNotesUrl || fw.notes) && ( +
    + + + Release Notes (PDF) + +
    + )} + {(fw.checksumSha256 || fw.checksumMD5) && ( +
    + + +
    + )} +
    +
    + + + Download + +
    +
  • + ))} +
+
+ )} +
+ + {/* Dokumente (rechts) */} +
+

Dokumente

+ + {data.documents.length === 0 ? ( +

Keine Dokumente verfügbar.

+ ) : ( +
+ {data.documents.map((doc) => ( +
+

{doc.title}

+

+ {doc.kind ?? "Dokument"}{doc.lang ? ` • ${doc.lang.toUpperCase()}` : ""} +

+
+ + + Download + +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/(protected)/layout.tsx b/frontend/src/app/(protected)/layout.tsx index 65b9c1b..41fdff8 100644 --- a/frontend/src/app/(protected)/layout.tsx +++ b/frontend/src/app/(protected)/layout.tsx @@ -1,4 +1,4 @@ -// app/(protected)/layout.tsx +// /src/app/(protected)/layout.tsx import { redirect } from 'next/navigation'; import { getServerUser } from '@/lib/auth'; @@ -25,7 +25,7 @@ export default async function ProtectedLayout({
- + {children} diff --git a/frontend/src/app/(protected)/notifications/page.tsx b/frontend/src/app/(protected)/notifications/page.tsx index 96f9c32..071c982 100644 --- a/frontend/src/app/(protected)/notifications/page.tsx +++ b/frontend/src/app/(protected)/notifications/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { Button } from '../../components/Button'; import Modal from '../../components/Modal'; import Table from '../../components/Table'; @@ -191,8 +191,7 @@ const BRAND_OPTIONS: string[] = [ "XPENG", "Zeekr", "Zhidou", - "Andere", -]; +].sort(); /* --------------------------------------------------------- */ /* Haupt-Komponente */ @@ -217,6 +216,10 @@ export default function NotiticationsPage() { recipients: [''], }); + const EMAIL_PATTERN = "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"; // simpel & robust genug + + const formEl = useRef(null); + /* ------------- Regeln laden --------------- */ useEffect(() => { (async () => { @@ -281,6 +284,8 @@ export default function NotiticationsPage() { /* ------------- Speichern (neu / edit) ---- */ const handleSave = async () => { + if (formEl.current && !formEl.current.reportValidity()) return; + const recipients = form.recipients .map(r => r.trim()) .filter(Boolean); @@ -360,11 +365,13 @@ export default function NotiticationsPage() { {form.recipients.map((mail, idx) => (
changeRecipient(idx, e.target.value)} type='email' + required={!!mail.trim()} + pattern={EMAIL_PATTERN} /> {idx === 0 ? ( + ); +} diff --git a/frontend/src/app/components/ConnectionIndicator.tsx b/frontend/src/app/components/ConnectionIndicator.tsx index 4358582..5896078 100644 --- a/frontend/src/app/components/ConnectionIndicator.tsx +++ b/frontend/src/app/components/ConnectionIndicator.tsx @@ -20,7 +20,7 @@ export default function ConnectionIndicator() { }; return ( -
+
● {labelMap[connectionStatus]}
); diff --git a/frontend/src/app/components/DatePicker.tsx b/frontend/src/app/components/DatePicker.tsx index 95a117d..3fb131a 100644 --- a/frontend/src/app/components/DatePicker.tsx +++ b/frontend/src/app/components/DatePicker.tsx @@ -8,6 +8,7 @@ type Props = { title: string; value?: string; className?: string; + containerClassName?: string; onDateChange: (range: { from: Date | null; to: Date | null }) => void; selectionDatesMode?: 'single' | 'multiple' | 'multiple-ranged'; disableUnavailableDates?: boolean; @@ -58,6 +59,7 @@ export default function DatePicker({ title, value, className = '', + containerClassName, selectionDatesMode = 'multiple-ranged', disableUnavailableDates = true, onDateChange, @@ -298,7 +300,7 @@ export default function DatePicker({ }, [calendarInstance, onReset, onDateChange]); return ( -
+
diff --git a/frontend/src/app/components/Field.tsx b/frontend/src/app/components/Field.tsx new file mode 100644 index 0000000..a7ae166 --- /dev/null +++ b/frontend/src/app/components/Field.tsx @@ -0,0 +1,24 @@ +// components/Field.tsx +'use client'; +import clsx from 'clsx'; + +export function Field({ + label, + htmlFor, + children, + className, +}: { + label: string; + htmlFor?: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/src/app/components/Footer.tsx b/frontend/src/app/components/Footer.tsx index 1529714..a01af21 100644 --- a/frontend/src/app/components/Footer.tsx +++ b/frontend/src/app/components/Footer.tsx @@ -1,12 +1,69 @@ +// /src/app/components/Footer.tsx 'use client'; import Changelog from "./Changelog"; import DarkModeToggle from "./DarkModeToggle"; import Modal from "./Modal"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export default function Footer() { const [isOpen, setIsOpen] = useState(false); + const [version, setVersion] = useState(null); + + useEffect(() => { + let alive = true; + + const tryFetch = async (url: string) => { + const res = await fetch(url, { cache: 'no-cache', headers: { Accept: 'application/xml,text/xml,*/*' } }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return res.text(); + }; + + const parseLatestVersion = (xml: string) => { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + const parserError = doc.querySelector('parsererror'); + if (parserError) throw new Error('XML parse error'); + + // Sammle alle + const entries = Array.from(doc.getElementsByTagName('entry')).map(e => ({ + version: e.getAttribute('version') ?? '', + dateIso: e.getAttribute('date') ?? '' + })); + + // Falls keine vorhanden: versuche oder -Tag + if (entries.length === 0) { + const rel = doc.querySelector('release'); + const vAttr = rel?.getAttribute('version'); + const vNode = vAttr || doc.querySelector('version')?.textContent?.trim(); + return vNode || null; + } + + // Sortiere wie in Changelog.tsx nach Datum (neueste zuerst) + entries.sort((a, b) => new Date(b.dateIso).getTime() - new Date(a.dateIso).getTime()); + const latest = entries[0]?.version?.trim(); + return latest || null; + }; + + (async () => { + try { + // 1) bevorzugt dein ursprünglicher Pfad + const xml = await tryFetch('/data/changelog.xml').catch(async () => { + // 2) Fallback wie in Changelog.tsx + return tryFetch('/changelog.xml'); + }); + + const v = parseLatestVersion(xml); + if (!alive) return; + setVersion(v ? (v.startsWith('v') || v.startsWith('V') ? v : `v${v}`) : 'v?'); + } catch (e) { + console.error('Version laden fehlgeschlagen:', e); + if (!alive) setVersion(null); + else setVersion('v?'); + } + })(); + + return () => { alive = false; }; + }, []); return (
@@ -15,21 +72,33 @@ export default function Footer() {
  • © TEG Düsseldorf - Christoph Rother
  • +
  • Kontakt
  • -
  • - setIsOpen(true)}> - v2.2 - -
  • + + {/* Version: dynamisch aus changelog.xml */} + {version && ( +
  • + +
  • + )} +
  • + setIsOpen(false)} title="Changelog"> diff --git a/frontend/src/app/components/FormSection.tsx b/frontend/src/app/components/FormSection.tsx new file mode 100644 index 0000000..2554204 --- /dev/null +++ b/frontend/src/app/components/FormSection.tsx @@ -0,0 +1,37 @@ +// components/FormSection.tsx +'use client'; +import clsx from 'clsx'; + +export default function FormSection({ + title, + description, + className, + bodyClassName, + children, +}: { + title: string; + description?: string; + className?: string; + bodyClassName?: string; // ⬅️ NEW + children: React.ReactNode; +}) { + const id = `sec-${title.replace(/\s+/g, '-').toLowerCase()}`; + return ( +
    +
    +

    {title}

    + {description && ( +

    {description}

    + )} +
    +
    {children}
    +
    + ); +} diff --git a/frontend/src/app/components/HomeClient.tsx b/frontend/src/app/components/HomeClient.tsx index 39b64c3..b584426 100644 --- a/frontend/src/app/components/HomeClient.tsx +++ b/frontend/src/app/components/HomeClient.tsx @@ -9,6 +9,7 @@ import { useCurrentUser } from './AuthContext'; import LoadingSpinner from './LoadingSpinner'; import { useSSE } from './SSEContext'; import { useEffect } from 'react'; +import DownloadPage from '../(protected)/downloads/page'; export default function HomeClient() { const { loading } = useCurrentUser(); @@ -40,6 +41,7 @@ export default function HomeClient() { {tabFromPath === 'dashboard' && } {tabFromPath === 'results' && } {tabFromPath === 'notifications' && } + {tabFromPath === 'downloads' && } {tabFromPath === 'admin' && } ); diff --git a/frontend/src/app/components/LoadingSpinner.tsx b/frontend/src/app/components/LoadingSpinner.tsx index 4d157ca..4059986 100644 --- a/frontend/src/app/components/LoadingSpinner.tsx +++ b/frontend/src/app/components/LoadingSpinner.tsx @@ -1,19 +1,55 @@ 'use client'; import React from 'react'; +import clsx from 'clsx'; type LoadingSpinnerProps = { showBackground?: boolean; showBorder?: boolean; + /** kleiner Spinner ohne äußeres Layout – z.B. im Button */ + inline?: boolean; + /** Größe des Spinners */ + size?: 'sm' | 'md' | 'lg'; + /** Zusätzliche Klassen (z.B. text-current) */ + className?: string; }; -export default function LoadingSpinner({ showBackground = false, showBorder = false }: LoadingSpinnerProps) { +const sizeClasses = { + sm: 'size-4 border-2', + md: 'size-6 border-3', + lg: 'size-8 border-4', +}; + +export default function LoadingSpinner({ + showBackground = false, + showBorder = false, + inline = false, + size = 'md', + className, +}: LoadingSpinnerProps) { + // INLINE: nur der Kreis – perfekt für Buttons/Labels + if (inline) { + return ( + + + Lädt... + + + ); + } + + // BLOCK: dein bisheriges Layout const outerClasses = [ 'flex flex-col flex-1 justify-center items-center', - showBackground && - 'bg-white shadow-2xs dark:bg-neutral-800 dark:shadow-neutral-700/70', - (showBorder) && - 'border border-gray-200 dark:border-neutral-700', + showBackground && 'bg-white shadow-2xs dark:bg-neutral-800 dark:shadow-neutral-700/70', + showBorder && 'border border-gray-200 dark:border-neutral-700', ] .filter(Boolean) .join(' '); @@ -22,7 +58,11 @@ export default function LoadingSpinner({ showBackground = false, showBorder = fa
    diff --git a/frontend/src/app/components/Modal.tsx b/frontend/src/app/components/Modal.tsx index e104d9d..711960d 100644 --- a/frontend/src/app/components/Modal.tsx +++ b/frontend/src/app/components/Modal.tsx @@ -1,8 +1,10 @@ +// Modal.tsx 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Button } from './Button'; +import LoadingSpinner from './LoadingSpinner'; type ModalProps = { open: boolean; @@ -10,8 +12,9 @@ type ModalProps = { title: string; children: React.ReactNode; saveButton?: boolean; - onSave?: () => void; + onSave?: () => void | Promise; maxWidth?: string; + busy?: boolean; }; export default function Modal({ @@ -22,35 +25,83 @@ export default function Modal({ saveButton = false, onSave, maxWidth, + busy = false, }: ModalProps) { + const [saving, setSaving] = useState(false); + const blocked = saving || busy; + useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape' && !blocked) onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); - }, [onClose]); + }, [onClose, blocked]); + + useEffect(() => { + if (!open) setSaving(false); + }, [open]); + + // Modal.tsx – Ergänzung + useEffect(() => { + if (!open) return; + + const body = document.body; + const docEl = document.documentElement; + + // Zustände merken + const prevOverflow = body.style.overflow; + const prevPaddingRight = body.style.paddingRight; + const prevBehavior = docEl.style.overscrollBehavior as string; + + // Breite der Scrollbar ermitteln (zur Layout-Stabilisierung) + const scrollbarWidth = window.innerWidth - docEl.clientWidth; + + body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + body.style.paddingRight = `${scrollbarWidth}px`; + } + + // verhindert iOS/Android "bounce" ins Dokument + docEl.style.overscrollBehavior = 'contain'; + + return () => { + body.style.overflow = prevOverflow; + body.style.paddingRight = prevPaddingRight; + docEl.style.overscrollBehavior = prevBehavior; + }; + }, [open]); + + const handleSaveClick = useCallback(async () => { + if (!onSave || blocked) return; + try { + setSaving(true); + await onSave(); + } finally { + setSaving(false); + } + }, [onSave, blocked]); if (!open) return null; return createPortal(
    -
    - {/* Header */} -
    -