diff --git a/.env.example b/.env.example index 0d9bf9d..55d9653 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -DB_TYPE = (mysql|sqlite|postgres) # default ist mysql +DB_TYPE = (mysql|postgres) # default ist mysql ## BSP für mysql DB_PORT = 3306 @@ -14,28 +14,10 @@ DB_NAME = database_name DB_USERNAME = database_username DB_PASSWORD = database_password -## BSP für sqlite -DB_HOST = filename.db - +## Dev only SERVER_PORT = portnumber -JWT_SECRET = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 # besitzt default -JWT_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 15m -REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 1d -PWA_REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 5d - -MAIL_USERNAME = mail_username -MAIL_PASSWORD = mail_password -MAIL_HOST = mail_hoststring -MAIL_PORT = mail_portnumber # default ist 587 -MAIL_SECURE = (true|false) # true für port 465, false für anders gewählten port - -CLUB_NAME = clubname #default FF Admin -CLUB_WEBSITE = https://my-club-website-url #optional, muss aber mit http:// oder https:// beginnen - -BACKUP_INTERVAL = number of days (min 1) # default 1 -BACKUP_COPIES = number of parallel copies # default 7 -BACKUP_AUTO_RESTORE = (true|false) # default ist true +APPLICATION_SECRET = mysecret USE_SECURITY_STRICT_LIMIT = (true|false) # default ist true SECURITY_STRICT_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 15m diff --git a/Dockerfile b/Dockerfile index 4846250..08f68ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ RUN mkdir -p /app/files ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser COPY --from=build /app/src/templates /app/src/templates +COPY --from=build /app/src/assets /app/src/assets COPY --from=build /app/dist /app/dist COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/package.json /app/package.json diff --git a/package-lock.json b/package-lock.json index 770360c..469fc68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,79 +10,92 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", - "dotenv": "^16.4.5", + "crypto": "^1.0.1", + "dotenv": "^16.5.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", "handlebars": "^4.7.8", - "helmet": "^8.0.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", + "lodash.clonedeep": "^4.5.0", "lodash.uniqby": "^4.7.0", "moment": "^2.30.1", "morgan": "^1.10.0", "ms": "^2.1.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "node-schedule": "^2.1.1", - "nodemailer": "^6.10.1", + "nodemailer": "^7.0.2", "pdf-lib": "^1.17.1", - "pg": "^8.13.1", - "puppeteer": "^24.6.1", + "pg": "^8.15.6", + "puppeteer": "^24.8.0", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", - "socket.io": "^4.7.5", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", - "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "typeorm": "^0.3.22", + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { - "@types/cors": "^2.8.14", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "^9.0.9", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", "@types/multer": "^1.4.12", - "@types/mysql": "^2.15.21", - "@types/node": "^22.14.1", - "@types/node-schedule": "^2.1.6", - "@types/nodemailer": "^6.4.14", - "@types/pg": "~8.11.12", + "@types/mysql": "^2.15.27", + "@types/node": "^22.15.12", + "@types/node-schedule": "^2.1.7", + "@types/nodemailer": "^6.4.17", + "@types/pg": "~8.12.0", "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", + "@types/validator": "^13.15.0", "ts-node": "10.9.2", "typescript": "^5.8.3" } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "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.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@canvas/image-data": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz", + "integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==", + "license": "MIT" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -96,6 +109,23 @@ "node": ">=12" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "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 + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -103,6 +133,383 @@ "license": "MIT", "optional": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -295,9 +702,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.0.tgz", - "integrity": "sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA==", + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.3.tgz", + "integrity": "sha512-iPpnFpX25gKIVsHsqVjHV+/GzW36xPgsscWkCnrrETndcdxNsXLdCrTwhkCJNR/FGWr122dJUBeyV4niz/j3TA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.0", @@ -461,6 +868,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lodash.uniqby": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/@types/lodash.uniqby/-/lodash.uniqby-4.7.9.tgz", @@ -516,9 +933,9 @@ } }, "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "version": "22.15.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz", + "integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -545,9 +962,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.12", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.12.tgz", - "integrity": "sha512-D8qPxnq0rgpvZPYwMxAZffxvlk2mtgimLC5kos8uM7+3wPKfTESxtpD49cfB5w1UnodZL7oYnjFHT5+cB3Gw9Q==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-a9Z11ecnpNPFu2iT4Qo9SSYgM2r1l4UqLIQ454zhCDRzxqOh/vsi57FFovbc64oBGPBotXw5cRhUQtJEHCb/OA==", "dev": true, "license": "MIT", "dependencies": { @@ -556,68 +973,6 @@ "pg-types": "^4.0.1" } }, - "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@types/pg/node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/@types/qrcode": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", @@ -682,6 +1037,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -910,9 +1272,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", - "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.4.tgz", + "integrity": "sha512-r8+26Voz8dGX3AYpJdFb1ZPaUSM8XOLCZvy+YGpRTmwPHIxA7Z3Jov/oMPtV7hfRQbOnH8qGlLTzQAbgtdNN0Q==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1312,9 +1674,9 @@ } }, "node_modules/chromium-bidi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz", - "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-4.1.1.tgz", + "integrity": "sha512-biR7t4vF3YluE6RlMSk9IWk+b9U+WWyzHp+N2pL9vRTk+UXHYRTVp7jTK58ZNzMLBgoLMHY4QyJMbeuw3eKxqg==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1348,6 +1710,19 @@ "node": ">=12" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1366,6 +1741,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1522,6 +1907,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "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", @@ -1563,6 +1955,33 @@ "node": ">=0.10.0" } }, + "node_modules/decode-bmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz", + "integrity": "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/decode-ico": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-ico/-/decode-ico-0.4.1.tgz", + "integrity": "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "decode-bmp": "^0.2.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1618,18 +2037,18 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/devtools-protocol": { - "version": "0.0.1425554", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1425554.tgz", - "integrity": "sha512-uRfxR6Nlzdzt0ihVIkV+sLztKgs7rgquY/Mhcv1YNCWDh5IZgl5mnn2aeEnW5stYTE0wwiF4RYVz8eMEpV1SEw==", + "version": "0.0.1439962", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1439962.tgz", + "integrity": "sha512-jJF48UdryzKiWhJ1bLKr7BFWUQCEIT5uCNbDLqkQJBtkFxYzILJH44WN0PDKMIlGDN7Utb8vyUY85C3w4R/t2g==", "license": "BSD-3-Clause" }, "node_modules/diff": { @@ -2058,6 +2477,15 @@ "node": ">= 8.0.0" } }, + "node_modules/express-validator/node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2472,6 +2900,12 @@ "ms": "^2.0.0" } }, + "node_modules/ico-endec": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz", + "integrity": "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==", + "license": "MPL-2.0" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2762,6 +3196,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3357,9 +3797,9 @@ } }, "node_modules/node-abi": { - "version": "3.74.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", - "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -3414,9 +3854,9 @@ } }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.2.tgz", + "integrity": "sha512-SYsisPeLFYli5Q+BCGSyHT5CVvezPmQjHgINV9KVvVLV1aktuoD4E0Np9Q3ND9I481qIHzUQzVT+Tl/Tw7Ivdg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -3735,14 +4175,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", + "pg-connection-string": "^2.8.5", + "pg-pool": "^3.9.6", + "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -3750,7 +4190,7 @@ "node": ">= 8.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.1.1" + "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -3762,16 +4202,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.8.5.tgz", + "integrity": "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==", "license": "MIT" }, "node_modules/pg-int8": { @@ -3794,21 +4234,40 @@ } }, "node_modules/pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.6.tgz", + "integrity": "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.9.5.tgz", + "integrity": "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg==", "license": "MIT" }, "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", @@ -3824,6 +4283,45 @@ "node": ">=4" } }, + "node_modules/pg/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pgpass": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", @@ -3849,42 +4347,46 @@ } }, "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-range": { @@ -4059,17 +4561,17 @@ } }, "node_modules/puppeteer": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.6.1.tgz", - "integrity": "sha512-/4ocGfu8LNvDbWUqJZV2VmwEWpbOdJa69y2Jivd213tV0ekAtUh/bgT1hhW63SDN/CtrEucOPwoomZ+9M+eBEg==", + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.8.0.tgz", + "integrity": "sha512-8GPlUKXvZK8ANxab75UerMar14ZnJTJpPok3XN9Nx6f7SKyabyFK39pQruMni6zfrwVBrPXp3Mo6ztwKEmXaDQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.0", - "chromium-bidi": "3.0.0", + "@puppeteer/browsers": "2.10.3", + "chromium-bidi": "4.1.1", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1425554", - "puppeteer-core": "24.6.1", + "devtools-protocol": "0.0.1439962", + "puppeteer-core": "24.8.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -4080,15 +4582,15 @@ } }, "node_modules/puppeteer-core": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.6.1.tgz", - "integrity": "sha512-sMCxsY+OPWO2fecBrhIeCeJbWWXJ6UaN997sTid6whY0YT9XM0RnxEwLeUibluIS5/fRmuxe1efjb5RMBsky7g==", + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.8.0.tgz", + "integrity": "sha512-tDf2YKIo5kM5r0vOzT52+PTgN0bBZOA4OFgQaqYyfarrcXLLJ92wi/lSMe44hd+F+gk0gw9QsAzyRW8v6ra93w==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.0", - "chromium-bidi": "3.0.0", + "@puppeteer/browsers": "2.10.3", + "chromium-bidi": "4.1.1", "debug": "^4.4.0", - "devtools-protocol": "0.0.1425554", + "devtools-protocol": "0.0.1439962", "typed-query-selector": "^2.12.0", "ws": "^8.18.1" }, @@ -4449,6 +4951,57 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.7.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" + } + }, + "node_modules/sharp-ico": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/sharp-ico/-/sharp-ico-0.1.5.tgz", + "integrity": "sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==", + "license": "MIT", + "dependencies": { + "decode-ico": "*", + "ico-endec": "*", + "sharp": "*" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4594,6 +5147,21 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -5078,6 +5646,12 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/to-data-view": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-1.1.0.tgz", + "integrity": "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5446,9 +6020,9 @@ "license": "MIT" }, "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -5542,9 +6116,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5668,9 +6242,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 27ff771..fe86056 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start_ts": "ts-node src/index.ts", "typeorm": "typeorm-ts-node-commonjs", "migrate": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:generate ./src/migrations/%npm_config_name% -d ./src/data-source.ts", + "migrate-empty": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:create ./src/migrations/%npm_config_name%", "synchronize-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs schema:sync -d ./src/data-source.ts", "update-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts", "revert-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts", @@ -25,52 +26,59 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", - "dotenv": "^16.4.5", + "crypto": "^1.0.1", + "dotenv": "^16.5.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", "handlebars": "^4.7.8", - "helmet": "^8.0.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", + "lodash.clonedeep": "^4.5.0", "lodash.uniqby": "^4.7.0", "moment": "^2.30.1", "morgan": "^1.10.0", "ms": "^2.1.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "node-schedule": "^2.1.1", - "nodemailer": "^6.10.1", + "nodemailer": "^7.0.2", "pdf-lib": "^1.17.1", - "pg": "^8.13.1", - "puppeteer": "^24.6.1", + "pg": "^8.15.6", + "puppeteer": "^24.8.0", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", - "socket.io": "^4.7.5", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", - "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "typeorm": "^0.3.22", + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { - "@types/cors": "^2.8.14", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "^9.0.9", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", "@types/multer": "^1.4.12", - "@types/mysql": "^2.15.21", - "@types/node": "^22.14.1", - "@types/node-schedule": "^2.1.6", - "@types/nodemailer": "^6.4.14", - "@types/pg": "~8.11.12", + "@types/mysql": "^2.15.27", + "@types/node": "^22.15.12", + "@types/node-schedule": "^2.1.7", + "@types/nodemailer": "^6.4.17", + "@types/pg": "~8.12.0", "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", + "@types/validator": "^13.15.0", "ts-node": "10.9.2", "typescript": "^5.8.3" } diff --git a/src/assets/admin-logo.png b/src/assets/admin-logo.png new file mode 100644 index 0000000..a2760bc Binary files /dev/null and b/src/assets/admin-logo.png differ diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..39a8174 Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts b/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts index f07de54..5573062 100644 --- a/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts +++ b/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts @@ -1,8 +1,8 @@ -import { NewsletterConfigType } from "../../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../../enums/newsletterConfigEnum"; export interface SetNewsletterConfigCommand { comTypeId: number; - config: NewsletterConfigType; + config: NewsletterConfigEnum; } export interface DeleteNewsletterConfigCommand { diff --git a/src/command/management/setting/settingCommand.ts b/src/command/management/setting/settingCommand.ts new file mode 100644 index 0000000..e0c9d84 --- /dev/null +++ b/src/command/management/setting/settingCommand.ts @@ -0,0 +1,10 @@ +export interface CreateOrUpdateSettingCommand { + topic: string; + key: string; + value: string; +} + +export interface DeleteSettingCommand { + topic: string; + key: string; +} diff --git a/src/command/management/setting/settingCommandHandler.ts b/src/command/management/setting/settingCommandHandler.ts new file mode 100644 index 0000000..9890f35 --- /dev/null +++ b/src/command/management/setting/settingCommandHandler.ts @@ -0,0 +1,50 @@ +import { dataSource } from "../../../data-source"; +import { setting } from "../../../entity/management/setting"; +import DatabaseActionException from "../../../exceptions/databaseActionException"; +import { CreateOrUpdateSettingCommand, DeleteSettingCommand } from "./settingCommand"; + +export default abstract class SettingCommandHandler { + /** + * @description create setting + * @param {CreateOrUpdateSettingCommand} createSetting + * @returns {Promise} + */ + static async create(createSetting: CreateOrUpdateSettingCommand): Promise { + return await dataSource + .createQueryBuilder() + .insert() + .into(setting) + .values({ + topic: createSetting.topic, + key: createSetting.key, + value: createSetting.value, + }) + .orUpdate(["value"], ["topic", "key"]) + .execute() + .then((result) => { + return createSetting.value; + }) + .catch((err) => { + throw new DatabaseActionException("CREATE OR UPDATE", "setting", err); + }); + } + + /** + * @description delete setting by topic and key + * @param {DeleteRefreshCommand} deleteSetting + * @returns {Promise} + */ + static async delete(deleteSetting: DeleteSettingCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(setting) + .where("setting.topic = :topic", { topic: deleteSetting.topic }) + .andWhere("setting.key = :key", { key: deleteSetting.key }) + .execute() + .then((res) => {}) + .catch((err) => { + throw new DatabaseActionException("DELETE", "setting", err); + }); + } +} diff --git a/src/command/management/user/userCommand.ts b/src/command/management/user/userCommand.ts index 90f9872..cb19989 100644 --- a/src/command/management/user/userCommand.ts +++ b/src/command/management/user/userCommand.ts @@ -1,3 +1,5 @@ +import { LoginRoutineEnum } from "../../../enums/loginRoutineEnum"; + export interface CreateUserCommand { mail: string; username: string; @@ -5,6 +7,7 @@ export interface CreateUserCommand { lastname: string; secret: string; isOwner: boolean; + routine: LoginRoutineEnum; } export interface UpdateUserCommand { @@ -18,6 +21,7 @@ export interface UpdateUserCommand { export interface UpdateUserSecretCommand { id: string; secret: string; + routine: LoginRoutineEnum; } export interface TransferUserOwnerCommand { diff --git a/src/command/management/user/userCommandHandler.ts b/src/command/management/user/userCommandHandler.ts index 590b2de..daa5535 100644 --- a/src/command/management/user/userCommandHandler.ts +++ b/src/command/management/user/userCommandHandler.ts @@ -31,6 +31,7 @@ export default abstract class UserCommandHandler { lastname: createUser.lastname, secret: createUser.secret, isOwner: createUser.isOwner, + routine: createUser.routine, }) .execute() .then((result) => { @@ -75,6 +76,7 @@ export default abstract class UserCommandHandler { .update(user) .set({ secret: updateUser.secret, + routine: updateUser.routine, }) .where("id = :id", { id: updateUser.id }) .execute() diff --git a/src/command/refreshCommandHandler.ts b/src/command/refreshCommandHandler.ts index df6a8ea..15f4f77 100644 --- a/src/command/refreshCommandHandler.ts +++ b/src/command/refreshCommandHandler.ts @@ -1,10 +1,8 @@ import { dataSource } from "../data-source"; import { refresh } from "../entity/refresh"; -import { PWA_REFRESH_EXPIRATION, REFRESH_EXPIRATION } from "../env.defaults"; import DatabaseActionException from "../exceptions/databaseActionException"; -import InternalException from "../exceptions/internalException"; +import SettingHelper from "../helpers/settingsHelper"; import { StringHelper } from "../helpers/stringHelper"; -import UserService from "../service/management/userService"; import { CreateRefreshCommand, DeleteRefreshCommand } from "./refreshCommand"; import ms from "ms"; @@ -25,8 +23,8 @@ export default abstract class RefreshCommandHandler { token: refreshToken, userId: createRefresh.userId, expiry: createRefresh.isFromPwa - ? new Date(Date.now() + ms(PWA_REFRESH_EXPIRATION)) - : new Date(Date.now() + ms(REFRESH_EXPIRATION)), + ? new Date(Date.now() + ms(SettingHelper.getSetting("session.pwa_refresh_expiration"))) + : new Date(Date.now() + ms(SettingHelper.getSetting("session.refresh_expiration"))), }) .execute() .then((result) => { diff --git a/src/controller/admin/club/memberController.ts b/src/controller/admin/club/memberController.ts index 9ef0bf4..c0b2cd7 100644 --- a/src/controller/admin/club/memberController.ts +++ b/src/controller/admin/club/memberController.ts @@ -92,6 +92,18 @@ export async function getMembersByIds(req: Request, res: Response): Promise }); } +/** + * @description get member latest inserted InternalId + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMemberLastInternalId(req: Request, res: Response): Promise { + let latest = await MemberService.getLatestInternalId(); + + res.send(latest); +} + /** * @description get member by id * @param req {Request} Express req object diff --git a/src/controller/admin/management/settingController.ts b/src/controller/admin/management/settingController.ts new file mode 100644 index 0000000..643ba44 --- /dev/null +++ b/src/controller/admin/management/settingController.ts @@ -0,0 +1,116 @@ +import { Request, Response } from "express"; +import SettingHelper from "../../../helpers/settingsHelper"; +import { SettingString, SettingValueMapping } from "../../../type/settingTypes"; +import MailHelper from "../../../helpers/mailHelper"; +import InternalException from "../../../exceptions/internalException"; + +/** + * @description get All settings + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getSettings(req: Request, res: Response): Promise { + res.json({ ...SettingHelper.getAllSettings(), ["mail.password"]: undefined }); +} + +/** + * @description get setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getSetting(req: Request, res: Response): Promise { + let setting = req.params.setting as SettingString; + + let value = SettingHelper.getSetting(setting); + + if (setting == "mail.password") { + value = undefined; + } + + res.send(value); +} + +/** + * @description set setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setSetting(req: Request, res: Response): Promise { + let setting = req.body.setting as SettingString; + let value = req.body.value as string; + + await SettingHelper.checkMail([{ key: setting, value }]).catch((err) => { + if (err == "mail") { + throw new InternalException("Mail is not valid"); + } else { + throw new InternalException("Config is not valid"); + } + }); + + await SettingHelper.setSetting(setting, value); + + res.sendStatus(204); +} + +/** + * @description set settings + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setSettings(req: Request, res: Response): Promise { + let setting = req.body as Array<{ key: K; value: SettingValueMapping[K] }>; + + await SettingHelper.checkMail(setting).catch((err) => { + if (err == "mail") { + throw new InternalException("Mail is not valid"); + } else { + throw new InternalException("Config is not valid"); + } + }); + + for (let entry of setting) { + await SettingHelper.setSetting(entry.key, entry.value); + } + + res.sendStatus(204); +} + +/** + * @description set setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setImages(req: Request, res: Response): Promise { + if (req.files && !Array.isArray(req.files) && req.files.icon) { + await SettingHelper.setSetting("club.icon", "configured"); + } else if (req.body["club.icon"] != "keep") { + await SettingHelper.resetSetting("club.icon"); + } + + if (req.files && !Array.isArray(req.files) && req.files.logo) { + await SettingHelper.setSetting("club.logo", "configured"); + } else if (req.body["club.logo"] != "keep") { + await SettingHelper.resetSetting("club.logo"); + } + + res.sendStatus(204); +} + +/** + * @description reset setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function resetSetting(req: Request, res: Response): Promise { + let setting = req.params.setting as SettingString; + + await SettingHelper.resetSetting(setting); + + res.sendStatus(204); +} diff --git a/src/controller/admin/management/userController.ts b/src/controller/admin/management/userController.ts index ba2a134..7da2460 100644 --- a/src/controller/admin/management/userController.ts +++ b/src/controller/admin/management/userController.ts @@ -11,10 +11,10 @@ import { } from "../../../command/management/user/userCommand"; import UserCommandHandler from "../../../command/management/user/userCommandHandler"; import MailHelper from "../../../helpers/mailHelper"; -import { CLUB_NAME } from "../../../env.defaults"; import { UpdateUserPermissionsCommand } from "../../../command/management/user/userPermissionCommand"; import UserPermissionCommandHandler from "../../../command/management/user/userPermissionCommandHandler"; import BadRequestException from "../../../exceptions/badRequestException"; +import SettingHelper from "../../../helpers/settingsHelper"; /** * @description get All users @@ -157,7 +157,7 @@ export async function deleteUser(req: Request, res: Response): Promise { // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, `Ihr Nutzerkonto des Adminportals wurde erfolgreich gelöscht.` ); } catch (error) {} diff --git a/src/controller/admin/management/webapiController.ts b/src/controller/admin/management/webapiController.ts index 80df60a..75a3011 100644 --- a/src/controller/admin/management/webapiController.ts +++ b/src/controller/admin/management/webapiController.ts @@ -12,8 +12,8 @@ import WebapiCommandHandler from "../../../command/management/webapi/webapiComma import { UpdateWebapiPermissionsCommand } from "../../../command/management/webapi/webapiPermissionCommand"; import WebapiPermissionCommandHandler from "../../../command/management/webapi/webapiPermissionCommandHandler"; import { JWTHelper } from "../../../helpers/jwtHelper"; -import { CLUB_NAME } from "../../../env.defaults"; import { StringHelper } from "../../../helpers/stringHelper"; +import SettingHelper from "../../../helpers/settingsHelper"; /** * @description get All apis @@ -78,7 +78,7 @@ export async function createWebapi(req: Request, res: Response): Promise { let token = await JWTHelper.create( { - iss: CLUB_NAME, + iss: SettingHelper.getSetting("club.name"), sub: "api_token_retrieve", aud: StringHelper.random(32), }, diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 9ecfa64..6302a3c 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -8,6 +8,25 @@ import UserService from "../service/management/userService"; import speakeasy from "speakeasy"; import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; import RefreshService from "../service/refreshService"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; + +/** + * @description Check authentication status by token + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function kickof(req: Request, res: Response): Promise { + let username = req.body.username; + + let { routine } = await UserService.getByUsername(username).catch(() => { + throw new UnauthorizedRequestException("Username not found"); + }); + + res.json({ + routine, + }); +} /** * @description Check authentication status by token @@ -17,19 +36,27 @@ import RefreshService from "../service/refreshService"; */ export async function login(req: Request, res: Response): Promise { let username = req.body.username; - let totp = req.body.totp; + let passedSecret = req.body.secret; - let { id, secret } = await UserService.getByUsername(username); + let { id } = await UserService.getByUsername(username); + let { secret, routine } = await UserService.getUserSecretAndRoutine(id); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + console.log(secret, passedSecret); + + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret == secret; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let accessToken = await JWTHelper.buildToken(id); diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index 14e346c..9492e10 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -1,6 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; import InternalException from "../exceptions/internalException"; import RefreshCommandHandler from "../command/refreshCommandHandler"; import { CreateRefreshCommand } from "../command/refreshCommand"; @@ -15,10 +14,9 @@ import MailHelper from "../helpers/mailHelper"; import InviteService from "../service/management/inviteService"; import UserService from "../service/management/userService"; import CustomRequestException from "../exceptions/customRequestException"; -import { CLUB_NAME } from "../env.defaults"; -import { CreateUserPermissionCommand } from "../command/management/user/userPermissionCommand"; -import UserPermissionCommandHandler from "../command/management/user/userPermissionCommandHandler"; import InviteFactory from "../factory/admin/management/invite"; +import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get all invites @@ -38,7 +36,7 @@ export async function getInvites(req: Request, res: Response): Promise { * @param res {Response} Express res object * @returns {Promise<*>} */ -export async function inviteUser(req: Request, res: Response, isInvite: boolean = true): Promise { +export async function inviteUser(req: Request, res: Response, isSetup: boolean = false): Promise { let origin = req.headers.origin; let username = req.body.username; let mail = req.body.mail; @@ -59,7 +57,7 @@ export async function inviteUser(req: Request, res: Response, isInvite: boolean throw new CustomRequestException(409, "Username and Mail are already in use"); } - var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${CLUB_NAME}` }); + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); let createInvite: CreateInviteCommand = { username: username, @@ -73,8 +71,8 @@ export async function inviteUser(req: Request, res: Response, isInvite: boolean // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, - `Öffne folgenden Link: ${origin}/${isInvite ? "invite" : "setup"}/verify?mail=${mail}&token=${token}` + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, + `Öffne folgenden Link: ${origin}/${isSetup ? "setup" : "invite"}/verify?mail=${mail}&token=${token}` ); res.sendStatus(204); @@ -92,7 +90,7 @@ export async function verifyInvite(req: Request, res: Response): Promise { let { secret, username } = await InviteService.getByMailAndToken(mail, token); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { @@ -115,20 +113,26 @@ export async function verifyInvite(req: Request, res: Response): Promise { */ export async function finishInvite(req: Request, res: Response, grantAdmin: boolean = false): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username, firstname, lastname } = await InviteService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let createUser: CreateUserCommand = { @@ -136,8 +140,9 @@ export async function finishInvite(req: Request, res: Response, grantAdmin: bool firstname: firstname, lastname: lastname, mail: mail, - secret: secret, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, isOwner: grantAdmin, + routine, }; let id = await UserCommandHandler.create(createUser); diff --git a/src/controller/publicController.ts b/src/controller/publicController.ts index f44100d..e48c008 100644 --- a/src/controller/publicController.ts +++ b/src/controller/publicController.ts @@ -2,11 +2,13 @@ import { Request, Response } from "express"; import CalendarService from "../service/club/calendarService"; import CalendarTypeService from "../service/configuration/calendarTypeService"; import { calendar } from "../entity/club/calendar"; -import { createEvents } from "ics"; -import moment from "moment"; import InternalException from "../exceptions/internalException"; import CalendarFactory from "../factory/admin/club/calendar"; import { CalendarHelper } from "../helpers/calendarHelper"; +import SettingHelper from "../helpers/settingsHelper"; +import sharp from "sharp"; +import ico from "sharp-ico"; +import { FileSystemHelper } from "../helpers/fileSystemHelper"; /** * @description get all calendar items by types or nscdr @@ -51,3 +53,155 @@ export async function getCalendarItemsByTypes(req: Request, res: Response): Prom res.type("ics").send(value); } } + +/** + * @description get configuration of UI + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationConfig(req: Request, res: Response): Promise { + let config = { + "club.name": SettingHelper.getSetting("club.name"), + "club.imprint": SettingHelper.getSetting("club.imprint"), + "club.privacy": SettingHelper.getSetting("club.privacy"), + "club.website": SettingHelper.getSetting("club.website"), + "app.custom_login_message": SettingHelper.getSetting("app.custom_login_message"), + "app.show_link_to_calendar": SettingHelper.getSetting("app.show_link_to_calendar"), + }; + + res.json(config); +} + +/** + * @description get application Manifest + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationManifest(req: Request, res: Response): Promise { + const backendUrl = `${req.protocol}://${req.get("host")}`; + const frontenUrl = `${req.get("referer")}`; + + const manifest = { + id: "ff_admin_webapp", + lang: "de", + name: SettingHelper.getSetting("club.name"), + short_name: SettingHelper.getSetting("club.name"), + theme_color: "#990b00", + display: "standalone", + orientation: "portrait-primary", + start_url: frontenUrl, + icons: [ + { + src: `${backendUrl}/api/public/favicon.ico`, + sizes: "48x48", + type: "image/ico", + }, + { + src: `${backendUrl}/api/public/icon.png?width=512&height=512`, + sizes: "512x512", + type: "image/png", + }, + ], + }; + + res.set({ + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/manifest+json", + }); + + res.json(manifest); +} + +/** + * @description get application Logo + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationLogo(req: Request, res: Response): Promise { + let setLogo = SettingHelper.getSetting("club.logo"); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + }); + + if (setLogo != "" && FileSystemHelper.getFilesInDirectory("/app", ".png").includes("admin-icon.png")) { + res.sendFile(FileSystemHelper.formatPath("/app/admin-logo.png")); + } else { + res.sendFile(FileSystemHelper.readAssetFile("admin-logo.png", true)); + } +} + +/** + * @description get application Favicon + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationFavicon(req: Request, res: Response): Promise { + let icon = FileSystemHelper.readAssetFile("icon.png", true); + let setLogo = SettingHelper.getSetting("club.icon"); + + if (setLogo != "" && FileSystemHelper.getFilesInDirectory("/app", ".png").includes("admin-icon.png")) { + icon = FileSystemHelper.formatPath("/app/admin-icon.png"); + } + + let image = await sharp(icon) + .resize(48, 48, { + fit: "inside", + }) + .png() + .toBuffer(); + + let buffer = ico.encode([image]); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + "Content-Type": "image/x-icon", + }); + + res.send(buffer); +} + +/** + * @description get application Icon + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationIcon(req: Request, res: Response): Promise { + const width = parseInt((req.query.width as string) ?? "512"); + const height = parseInt((req.query.height as string) ?? "512"); + + let icon = FileSystemHelper.readAssetFile("icon.png", true); + let setLogo = SettingHelper.getSetting("club.icon"); + + if (setLogo != "" && FileSystemHelper.getFilesInDirectory("/app", ".png").includes("admin-icon.png")) { + icon = FileSystemHelper.formatPath("/app/admin-icon.png"); + } + + let image = await sharp(icon) + .resize(width, height, { + fit: "inside", + }) + .png() + .toBuffer(); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + "Content-Type": "image/png", + }); + + res.send(image); +} diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index fc0dc3e..7ffafe5 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -1,6 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; import InternalException from "../exceptions/internalException"; import RefreshCommandHandler from "../command/refreshCommandHandler"; import { CreateRefreshCommand } from "../command/refreshCommand"; @@ -12,12 +11,10 @@ import ResetCommandHandler from "../command/resetCommandHandler"; import MailHelper from "../helpers/mailHelper"; import ResetService from "../service/resetService"; import UserService from "../service/management/userService"; -import { CLUB_NAME } from "../env.defaults"; -import PermissionHelper from "../helpers/permissionHelper"; -import RolePermissionService from "../service/management/rolePermissionService"; -import UserPermissionService from "../service/management/userPermissionService"; import { UpdateUserSecretCommand } from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; +import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description request totp reset @@ -31,7 +28,7 @@ export async function startReset(req: Request, res: Response): Promise { let { mail } = await UserService.getByUsername(username); - var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${CLUB_NAME}` }); + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); let createReset: CreateResetCommand = { username: username, @@ -43,7 +40,7 @@ export async function startReset(req: Request, res: Response): Promise { // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, `Öffne folgenden Link: ${origin}/reset/reset?mail=${mail}&token=${token}` ); @@ -62,7 +59,7 @@ export async function verifyReset(req: Request, res: Response): Promise { let { secret } = await ResetService.getByMailAndToken(mail, token); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { @@ -84,27 +81,34 @@ export async function verifyReset(req: Request, res: Response): Promise { */ export async function finishReset(req: Request, res: Response): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username } = await ResetService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let { id } = await UserService.getByUsername(username); let updateUserSecret: UpdateUserSecretCommand = { id, - secret, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, + routine, }; await UserCommandHandler.updateSecret(updateUserSecret); diff --git a/src/controller/setupController.ts b/src/controller/setupController.ts index f71194f..f64ca65 100644 --- a/src/controller/setupController.ts +++ b/src/controller/setupController.ts @@ -1,4 +1,7 @@ import { Request, Response } from "express"; +import SettingHelper from "../helpers/settingsHelper"; +import MailHelper from "../helpers/mailHelper"; +import InternalException from "../exceptions/internalException"; /** * @description Service is currently not configured @@ -9,3 +12,131 @@ import { Request, Response } from "express"; export async function isSetup(req: Request, res: Response): Promise { res.sendStatus(204); } + +/** + * @description set club identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setClubIdentity(req: Request, res: Response): Promise { + const name = req.body.name; + const imprint = req.body.imprint; + const privacy = req.body.privacy; + const website = req.body.website; + + if (name) { + await SettingHelper.setSetting("club.name", name); + } else { + await SettingHelper.resetSetting("club.name"); + } + + if (imprint) { + await SettingHelper.setSetting("club.imprint", imprint); + } else { + await SettingHelper.resetSetting("club.imprint"); + } + + if (privacy) { + await SettingHelper.setSetting("club.privacy", privacy); + } else { + await SettingHelper.resetSetting("club.privacy"); + } + + if (website) { + await SettingHelper.setSetting("club.website", website); + } else { + await SettingHelper.resetSetting("club.website"); + } + + res.sendStatus(204); +} + +/** + * @description set applucation icon and logo + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function uploadClubImages(req: Request, res: Response): Promise { + if (req.files && !Array.isArray(req.files) && req.files.icon) { + await SettingHelper.setSetting("club.icon", "configured"); + } else { + await SettingHelper.resetSetting("club.icon"); + } + + if (req.files && !Array.isArray(req.files) && req.files.logo) { + await SettingHelper.setSetting("club.logo", "configured"); + } else { + await SettingHelper.resetSetting("club.logo"); + } + + res.sendStatus(204); +} + +/** + * @description set app identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setAppIdentity(req: Request, res: Response): Promise { + const custom_login_message = req.body.custom_login_message; + const show_link_to_calendar = req.body.show_link_to_calendar; + + if (custom_login_message) { + await SettingHelper.setSetting("app.custom_login_message", custom_login_message); + } else { + await SettingHelper.resetSetting("app.custom_login_message"); + } + + if (show_link_to_calendar == false || show_link_to_calendar == true) { + await SettingHelper.setSetting("app.show_link_to_calendar", show_link_to_calendar); + } else { + await SettingHelper.resetSetting("app.show_link_to_calendar"); + } + + res.sendStatus(204); +} + +/** + * @description set app identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setMailConfig(req: Request, res: Response): Promise { + const mail = req.body.mail; + const username = req.body.username; + const password = req.body.password; + const host = req.body.host; + const port = req.body.port; + const secure = req.body.secure; + + let checkMail = await MailHelper.checkMail(mail); + + if (!checkMail) { + throw new InternalException("Mail is not valid"); + } + + let checkConfig = await MailHelper.verifyTransport({ + user: username, + password, + host, + port, + secure, + }); + + if (!checkConfig) { + throw new InternalException("Config is not valid"); + } + + await SettingHelper.setSetting("mail.email", mail); + await SettingHelper.setSetting("mail.username", username); + await SettingHelper.setSetting("mail.password", password); + await SettingHelper.setSetting("mail.host", host); + await SettingHelper.setSetting("mail.port", port); + await SettingHelper.setSetting("mail.secure", secure); + + res.sendStatus(204); +} diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 1e764f1..1fe92c5 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -2,12 +2,17 @@ import { Request, Response } from "express"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import InternalException from "../exceptions/internalException"; -import { CLUB_NAME } from "../env.defaults"; import UserService from "../service/management/userService"; import UserFactory from "../factory/admin/management/user"; -import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/management/user/userCommand"; +import { + TransferUserOwnerCommand, + UpdateUserCommand, + UpdateUserSecretCommand, +} from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get my by id @@ -22,6 +27,21 @@ export async function getMeById(req: Request, res: Response): Promise { res.json(UserFactory.mapToSingle(user)); } +/** + * @description get my routine by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMyRoutine(req: Request, res: Response): Promise { + const id = req.userId; + let user = await UserService.getById(id); + + res.json({ + routine: user.routine, + }); +} + /** * @description get my totp * @param req {Request} Express req object @@ -31,9 +51,9 @@ export async function getMeById(req: Request, res: Response): Promise { export async function getMyTotp(req: Request, res: Response): Promise { const userId = req.userId; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { @@ -57,7 +77,12 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { const userId = req.userId; let totp = req.body.totp; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine != LoginRoutineEnum.totp) { + throw new ForbiddenRequestException("only allowed for totp login"); + } + let valid = speakeasy.totp.verify({ secret: secret, encoding: "base32", @@ -71,6 +96,106 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { res.sendStatus(204); } +/** + * @description change my password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeMyPassword(req: Request, res: Response): Promise { + const userId = req.userId; + let current = req.body.current; + let newpassword = req.body.newpassword; + + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine == LoginRoutineEnum.password && current != secret) { + throw new ForbiddenRequestException("passwords do not match"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description get change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getChangeToTOTP(req: Request, res: Response): Promise { + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); + + QRCode.toDataURL(secret.otpauth_url) + .then((result) => { + res.json({ + dataUrl: result, + otp: secret.base32, + }); + }) + .catch((err) => { + throw new InternalException("QRCode not created", err); + }); +} + +/** + * @description change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToTOTP(req: Request, res: Response): Promise { + const userId = req.userId; + let otp = req.body.otp; + let totp = req.body.totp; + + let valid = speakeasy.totp.verify({ + secret: otp, + encoding: "base32", + token: totp, + window: 2, + }); + + if (!valid) { + throw new InternalException("Token not valid or expired"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: otp, + routine: LoginRoutineEnum.totp, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description change to password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToPW(req: Request, res: Response): Promise { + const userId = req.userId; + let newpassword = req.body.newpassword; + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + /** * @description transferOwnership * @param req {Request} Express req object diff --git a/src/controller/webapiController.ts b/src/controller/webapiController.ts index e8e4206..c1153fe 100644 --- a/src/controller/webapiController.ts +++ b/src/controller/webapiController.ts @@ -1,13 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; -import InternalException from "../exceptions/internalException"; -import RefreshCommandHandler from "../command/refreshCommandHandler"; -import { CreateRefreshCommand, DeleteRefreshCommand } from "../command/refreshCommand"; -import UserService from "../service/management/userService"; -import speakeasy from "speakeasy"; -import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; -import RefreshService from "../service/refreshService"; import WebapiService from "../service/management/webapiService"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; import WebapiCommandHandler from "../command/management/webapi/webapiCommandHandler"; diff --git a/src/data-source.ts b/src/data-source.ts index 37f957d..fb60d49 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; -import { DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME, DB_TYPE, DB_PORT } from "./env.defaults"; +import { configCheck, DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; import { user } from "./entity/management/user"; import { refresh } from "./entity/refresh"; @@ -44,6 +44,7 @@ import { newsletterConfig } from "./entity/configuration/newsletterConfig"; import { webapi } from "./entity/management/webapi"; import { webapiPermission } from "./entity/management/webapi_permission"; import { salutation } from "./entity/configuration/salutation"; +import { setting } from "./entity/management/setting"; import { BackupAndResetDatabase1738166124200 } from "./migrations/1738166124200-BackupAndResetDatabase"; import { CreateSchema1738166167472 } from "./migrations/1738166167472-CreateSchema"; @@ -51,6 +52,12 @@ import { TemplatesAndProtocolSort1742549956787 } from "./migrations/174254995678 import { QueryToUUID1742922178643 } from "./migrations/1742922178643-queryToUUID"; import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-newsletterColumnType"; import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; +import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; +import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberCreatedAt"; +import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; +import { SettingsFromEnv_SET1745059495808 } from "./migrations/1745059495808-settingsFromEnv_set"; + +configCheck(); const dataSource = new DataSource({ type: DB_TYPE as any, @@ -103,6 +110,7 @@ const dataSource = new DataSource({ membershipView, webapi, webapiPermission, + setting, ], migrations: [ BackupAndResetDatabase1738166124200, @@ -111,6 +119,10 @@ const dataSource = new DataSource({ QueryToUUID1742922178643, NewsletterColumnType1744351418751, QueryUpdatedAt1744795756230, + SettingsFromEnv1745059495808, + SettingsFromEnv_SET1745059495808, + MemberCreatedAt1746006549262, + UserLoginRoutine1746252454922, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/club/member/member.ts b/src/entity/club/member/member.ts index 1678e39..e724155 100644 --- a/src/entity/club/member/member.ts +++ b/src/entity/club/member/member.ts @@ -1,4 +1,14 @@ -import { Column, ColumnType, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn } from "typeorm"; +import { + Column, + ColumnType, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + PrimaryColumn, +} from "typeorm"; import { membership } from "./membership"; import { memberAwards } from "./memberAwards"; import { memberQualifications } from "./memberQualifications"; @@ -30,6 +40,9 @@ export class member { @Column() salutationId: number; + @CreateDateColumn() + createdAt: Date; + @ManyToOne(() => salutation, (salutation) => salutation.members, { nullable: false, onDelete: "RESTRICT", diff --git a/src/entity/configuration/newsletterConfig.ts b/src/entity/configuration/newsletterConfig.ts index 17dde81..2d5d69c 100644 --- a/src/entity/configuration/newsletterConfig.ts +++ b/src/entity/configuration/newsletterConfig.ts @@ -1,5 +1,5 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; -import { NewsletterConfigType } from "../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../enums/newsletterConfigEnum"; import { communicationType } from "./communicationType"; @Entity() @@ -11,15 +11,15 @@ export class newsletterConfig { type: "varchar", length: "255", transformer: { - to(value: NewsletterConfigType) { + to(value: NewsletterConfigEnum) { return value.toString(); }, from(value: string) { - return NewsletterConfigType[value as keyof typeof NewsletterConfigType]; + return NewsletterConfigEnum[value as keyof typeof NewsletterConfigEnum]; }, }, }) - config: NewsletterConfigType; + config: NewsletterConfigEnum; @ManyToOne(() => communicationType, { nullable: false, diff --git a/src/entity/management/setting.ts b/src/entity/management/setting.ts new file mode 100644 index 0000000..99d3cda --- /dev/null +++ b/src/entity/management/setting.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity() +export class setting { + @PrimaryColumn({ type: "varchar", length: 255 }) + topic: string; + + @PrimaryColumn({ type: "varchar", length: 255 }) + key: string; + + @Column({ type: "text" }) + value: string; +} diff --git a/src/entity/management/user.ts b/src/entity/management/user.ts index 94ab3a3..d659acb 100644 --- a/src/entity/management/user.ts +++ b/src/entity/management/user.ts @@ -1,6 +1,9 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from "typeorm"; import { role } from "./role"; import { userPermission } from "./user_permission"; +import { LoginRoutineEnum } from "../../enums/loginRoutineEnum"; +import { CodingHelper } from "../../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../../env.defaults"; @Entity() export class user { @@ -19,11 +22,27 @@ export class user { @Column({ type: "varchar", length: 255 }) lastname: string; - @Column({ type: "varchar", length: 255 }) + @Column({ + type: "text", + select: false, + transformer: CodingHelper.entityBaseCoding(APPLICATION_SECRET, ""), + }) secret: string; - @Column({ type: "boolean", default: false }) - static: boolean; + @Column({ + type: "varchar", + length: "255", + default: LoginRoutineEnum.totp, + transformer: { + to(value: LoginRoutineEnum) { + return value.toString(); + }, + from(value: string) { + return LoginRoutineEnum[value as keyof typeof LoginRoutineEnum]; + }, + }, + }) + routine: LoginRoutineEnum; @Column({ type: "boolean", default: false }) isOwner: boolean; diff --git a/src/enums/loginRoutineEnum.ts b/src/enums/loginRoutineEnum.ts new file mode 100644 index 0000000..4d42334 --- /dev/null +++ b/src/enums/loginRoutineEnum.ts @@ -0,0 +1,4 @@ +export enum LoginRoutineEnum { + password = "password", // login with self defined password + totp = "totp", // login with totp by auth apps +} diff --git a/src/enums/newsletterConfigEnum.ts b/src/enums/newsletterConfigEnum.ts new file mode 100644 index 0000000..1e7313f --- /dev/null +++ b/src/enums/newsletterConfigEnum.ts @@ -0,0 +1,5 @@ +export enum NewsletterConfigEnum { + pdf = "pdf", + mail = "mail", + none = "none", +} diff --git a/src/enums/newsletterConfigType.ts b/src/enums/newsletterConfigType.ts deleted file mode 100644 index 4703494..0000000 --- a/src/enums/newsletterConfigType.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum NewsletterConfigType { - pdf = "pdf", - mail = "mail", -} diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 739fd87..c6df8ae 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -11,23 +11,7 @@ export const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000); -export const JWT_SECRET = process.env.JWT_SECRET ?? "my_jwt_secret_string_ilughfnadiuhgq§$IUZGFVRweiouarbt1oub3h5q4a"; -export const JWT_EXPIRATION = (process.env.JWT_EXPIRATION ?? "15m") as ms.StringValue; -export const REFRESH_EXPIRATION = (process.env.REFRESH_EXPIRATION ?? "1d") as ms.StringValue; -export const PWA_REFRESH_EXPIRATION = (process.env.PWA_REFRESH_EXPIRATION ?? "5d") as ms.StringValue; - -export const MAIL_USERNAME = process.env.MAIL_USERNAME ?? ""; -export const MAIL_PASSWORD = process.env.MAIL_PASSWORD ?? ""; -export const MAIL_HOST = process.env.MAIL_HOST ?? ""; -export const MAIL_PORT = Number(process.env.MAIL_PORT ?? "587"); -export const MAIL_SECURE = process.env.MAIL_SECURE ?? "false"; - -export const CLUB_NAME = process.env.CLUB_NAME ?? "FF Admin"; -export const CLUB_WEBSITE = process.env.CLUB_WEBSITE ?? ""; - -export const BACKUP_INTERVAL = Number(process.env.BACKUP_INTERVAL ?? "1"); -export const BACKUP_COPIES = Number(process.env.BACKUP_COPIES ?? "7"); -export const BACKUP_AUTO_RESTORE = process.env.BACKUP_AUTO_RESTORE ?? "true"; +export const APPLICATION_SECRET = process.env.APPLICATION_SECRET ?? ""; export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true"; export const SECURITY_STRICT_LIMIT_WINDOW = (process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m") as ms.StringValue; @@ -55,40 +39,16 @@ export const TRUST_PROXY = ((): Array | string | boolean | number | null })(); export function configCheck() { - if (DB_TYPE != "mysql" && DB_TYPE != "sqlite" && DB_TYPE != "postgres") - throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); - if ((DB_HOST == "" || typeof DB_HOST != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_HOST"); + if (DB_TYPE != "mysql" && DB_TYPE != "postgres") throw new Error("set valid value to DB_TYPE (mysql|postgres)"); + if (DB_HOST == "" || typeof DB_HOST != "string") throw new Error("set valid value to DB_HOST"); if (DB_NAME == "" || typeof DB_NAME != "string") throw new Error("set valid value to DB_NAME"); - if ((DB_USERNAME == "" || typeof DB_USERNAME != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_USERNAME"); - if ((DB_PASSWORD == "" || typeof DB_PASSWORD != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_PASSWORD"); + if (DB_USERNAME == "" || typeof DB_USERNAME != "string") throw new Error("set valid value to DB_USERNAME"); + if (DB_PASSWORD == "" || typeof DB_PASSWORD != "string") throw new Error("set valid value to DB_PASSWORD"); + + if (APPLICATION_SECRET == "") throw new Error("set valid APPLICATION_SECRET"); if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); - if (JWT_SECRET == "" || typeof JWT_SECRET != "string") throw new Error("set valid value to JWT_SECRET"); - checkMS(JWT_EXPIRATION, "JWT_EXPIRATION"); - checkMS(REFRESH_EXPIRATION, "REFRESH_EXPIRATION"); - checkMS(PWA_REFRESH_EXPIRATION, "PWA_REFRESH_EXPIRATION"); - - if (MAIL_USERNAME == "" || typeof MAIL_USERNAME != "string") throw new Error("set valid value to MAIL_USERNAME"); - if (MAIL_PASSWORD == "" || typeof MAIL_PASSWORD != "string") throw new Error("set valid value to MAIL_PASSWORD"); - if (MAIL_HOST == "" || typeof MAIL_HOST != "string") throw new Error("set valid value to MAIL_HOST"); - if (isNaN(MAIL_PORT)) throw new Error("set valid numeric value to MAIL_PORT"); - if (MAIL_SECURE != "true" && MAIL_SECURE != "false") throw new Error("set 'true' or 'false' to MAIL_SECURE"); - - if ( - CLUB_WEBSITE != "" && - !/^(http(s):\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/.test(CLUB_WEBSITE) - ) - throw new Error("CLUB_WEBSITE is not valid url"); - - if (BACKUP_AUTO_RESTORE != "true" && BACKUP_AUTO_RESTORE != "false") - throw new Error("set 'true' or 'false' to BACKUP_AUTO_RESTORE"); - if (BACKUP_INTERVAL < 1) throw new Error("BACKUP_INTERVAL has to be at least 1"); - if (BACKUP_COPIES < 1) throw new Error("BACKUP_COPIES has to be at least 1"); - if (USE_SECURITY_STRICT_LIMIT != "true" && USE_SECURITY_STRICT_LIMIT != "false") throw new Error("set 'true' or 'false' to USE_SECURITY_STRICT_LIMIT"); checkMS(SECURITY_STRICT_LIMIT_WINDOW, "SECURITY_STRICT_LIMIT_WINDOW"); diff --git a/src/exceptions/databaseActionException.ts b/src/exceptions/databaseActionException.ts index b0b145c..eba9bb3 100644 --- a/src/exceptions/databaseActionException.ts +++ b/src/exceptions/databaseActionException.ts @@ -2,7 +2,7 @@ import CustomRequestException from "./customRequestException"; export default class DatabaseActionException extends CustomRequestException { constructor(action: string, table: string, err: any) { - let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? "XX"}`; + let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? err?.message ?? "XX"}`; super(500, errstring, err); } } diff --git a/src/factory/admin/club/newsletter/newsletter.ts b/src/factory/admin/club/newsletter/newsletter.ts index 39c19e3..6cc9685 100644 --- a/src/factory/admin/club/newsletter/newsletter.ts +++ b/src/factory/admin/club/newsletter/newsletter.ts @@ -18,7 +18,6 @@ export default abstract class NewsletterFactory { newsletterSignatur: record.newsletterSignatur, isSent: record.isSent, recipientsByQueryId: record?.recipientsByQuery ? record.recipientsByQuery.id : null, - recipientsByQuery: record?.recipientsByQuery ? QueryStoreFactory.mapToSingle(record.recipientsByQuery) : null, }; } diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 319a298..2f737ad 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -4,9 +4,10 @@ import { EntityManager } from "typeorm"; import uniqBy from "lodash.uniqby"; import InternalException from "../exceptions/internalException"; import UserService from "../service/management/userService"; -import { BACKUP_COPIES, BACKUP_INTERVAL } from "../env.defaults"; import DatabaseActionException from "../exceptions/databaseActionException"; import { availableTemplates } from "../type/templateTypes"; +import SettingHelper from "./settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; export type BackupSection = | "member" @@ -18,7 +19,8 @@ export type BackupSection = | "query" | "template" | "user" - | "webapi"; + | "webapi" + | "settings"; export type BackupSectionRefered = { [key in BackupSection]?: Array; @@ -42,6 +44,7 @@ export default abstract class BackupHelper { { type: "template", orderOnInsert: 2, orderOnClear: 1 }, // INSERT depends on member com { type: "user", orderOnInsert: 1, orderOnClear: 1 }, { type: "webapi", orderOnInsert: 1, orderOnClear: 1 }, + { type: "settings", orderOnInsert: 1, orderOnClear: 1 }, ]; private static readonly backupSectionRefered: BackupSectionRefered = { @@ -76,6 +79,7 @@ export default abstract class BackupHelper { template: ["template", "template_usage"], user: ["user", "user_permission", "role", "role_permission", "invite"], webapi: ["webapi", "webapi_permission"], + settings: ["setting"], }; private static transactionManager: EntityManager; @@ -103,7 +107,7 @@ export default abstract class BackupHelper { let files = FileSystemHelper.getFilesInDirectory("backup", ".json"); let sorted = files.sort((a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime()); - const filesToDelete = sorted.slice(BACKUP_COPIES); + const filesToDelete = sorted.slice(SettingHelper.getSetting("backup.copies")); for (const file of filesToDelete) { FileSystemHelper.deleteFile("backup", file); } @@ -117,7 +121,7 @@ export default abstract class BackupHelper { let diffInMs = new Date().getTime() - lastBackup.getTime(); let diffInDays = diffInMs / (1000 * 60 * 60 * 24); - if (diffInDays >= BACKUP_INTERVAL) { + if (diffInDays >= SettingHelper.getSetting("backup.interval")) { await this.createBackup({}); } } @@ -220,6 +224,8 @@ export default abstract class BackupHelper { return await this.getUser(collectIds); case "webapi": return await this.getWebapi(); + case "settings": + return await this.getSettings(); default: return []; } @@ -435,6 +441,7 @@ export default abstract class BackupHelper { "user.firstname", "user.lastname", "user.secret", + "user.routine", "user.isOwner", ]) .addSelect(["permissions.permission"]) @@ -460,6 +467,13 @@ export default abstract class BackupHelper { .addSelect(["permissions.permission"]) .getMany(); } + private static async getSettings(): Promise> { + return await dataSource + .getRepository("setting") + .createQueryBuilder("setting") + .select(["setting.topic", "setting.key", "setting.value"]) + .getMany(); + } private static async setSectionData( section: BackupSection, @@ -476,6 +490,7 @@ export default abstract class BackupHelper { if (section == "template" && !Array.isArray(data)) await this.setTemplate(data); if (section == "user" && !Array.isArray(data)) await this.setUser(data); if (section == "webapi" && Array.isArray(data)) await this.setWebapi(data); + if (section == "settings" && Array.isArray(data)) await this.setSettings(data); } private static async setMemberData(data: Array): Promise { @@ -793,6 +808,7 @@ export default abstract class BackupHelper { let roles = await this.transactionManager.getRepository("role").find(); let dataWithMappedIds = (data?.["user"] ?? []).map((u) => ({ ...u, + routine: u.routine ?? LoginRoutineEnum.totp, roles: u.roles.map((r: any) => ({ ...r, id: roles.find((role) => role.role == r.role)?.id ?? undefined, @@ -810,4 +826,7 @@ export default abstract class BackupHelper { private static async setWebapi(data: Array): Promise { await this.transactionManager.getRepository("webapi").save(data); } + private static async setSettings(data: Array): Promise { + await this.transactionManager.getRepository("setting").save(data); + } } diff --git a/src/helpers/calendarHelper.ts b/src/helpers/calendarHelper.ts index 7418089..e10e337 100644 --- a/src/helpers/calendarHelper.ts +++ b/src/helpers/calendarHelper.ts @@ -1,7 +1,7 @@ import { createEvents } from "ics"; import { calendar } from "../entity/club/calendar"; import moment from "moment"; -import { CLUB_NAME, CLUB_WEBSITE, MAIL_USERNAME } from "../env.defaults"; +import SettingHelper from "./settingsHelper"; export abstract class CalendarHelper { public static buildICS(entries: Array): { error?: Error; value?: string } { @@ -35,7 +35,10 @@ export abstract class CalendarHelper { description: i.content, location: i.location, categories: [i.type.type], - organizer: { name: CLUB_NAME, email: MAIL_USERNAME }, + organizer: { + name: SettingHelper.getSetting("club.name"), + email: SettingHelper.getSetting("mail.username"), + }, created: moment(i.createdAt) .format("YYYY-M-D-H-m") .split("-") @@ -46,7 +49,7 @@ export abstract class CalendarHelper { .map((a) => parseInt(a)) as [number, number, number, number, number], transp: "OPAQUE" as "OPAQUE", status: "CONFIRMED", - ...(CLUB_WEBSITE != "" ? { url: CLUB_WEBSITE } : {}), + ...(SettingHelper.getSetting("club.website") != "" ? { url: SettingHelper.getSetting("club.website") } : {}), alarms: [ { action: "display", diff --git a/src/helpers/codingHelper.ts b/src/helpers/codingHelper.ts new file mode 100644 index 0000000..354abc8 --- /dev/null +++ b/src/helpers/codingHelper.ts @@ -0,0 +1,95 @@ +import { createCipheriv, createDecipheriv, scryptSync, randomBytes } from "crypto"; +import { ValueTransformer } from "typeorm"; + +export abstract class CodingHelper { + private static readonly algorithm = "aes-256-gcm"; + private static readonly ivLength = 16; + private static readonly authTagLength = 16; + + static entityBaseCoding(key: string = "", fallback: string = ""): ValueTransformer { + return { + from(val: string | null | undefined): string { + if (!val || val == "") return fallback; + try { + return CodingHelper.decrypt(key, val, true); + } catch (error) { + console.error("Decryption error:", error); + if (fallback == "") return val; + else return fallback; + } + }, + to(val: string | null | undefined): string { + const valueToEncrypt = val || fallback; + if (valueToEncrypt === "") return ""; + + try { + return CodingHelper.encrypt(key, valueToEncrypt, true); + } catch (error) { + console.error("Encryption error:", error); + if (fallback == "") return val; + return ""; + } + }, + }; + } + + public static encrypt(phrase: string, content: string, passError = false): string { + if (!content) return ""; + + try { + // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) + const iv = randomBytes(this.ivLength); + const key = scryptSync(phrase, "salt", 32); + + const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + + // Verschlüssele den Inhalt + let encrypted = cipher.update(content, "utf8", "hex"); + encrypted += cipher.final("hex"); + + // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) + const authTag = cipher.getAuthTag(); + + // Gib das Format: iv:verschlüsselter_text:authTag zurück + return Buffer.concat([ + Uint8Array.from(iv), + Uint8Array.from(Buffer.from(encrypted, "hex")), + Uint8Array.from(authTag), + ]).toString("base64"); + } catch (error) { + if (passError) throw error; + console.error("Encryption failed:", error); + return ""; + } + } + + public static decrypt(phrase: string, content: string, passError = false): string { + if (!content) return ""; + + try { + // Dekodiere den Base64-String + const buffer = Buffer.from(content, "base64"); + + // Extrahiere IV, verschlüsselten Text und Auth-Tag + const iv = buffer.subarray(0, this.ivLength); + const authTag = buffer.subarray(buffer.length - this.authTagLength); + const encryptedText = buffer.subarray(this.ivLength, buffer.length - this.authTagLength).toString("hex"); + + const key = scryptSync(phrase, "salt", 32); + + // Erstelle Decipher und setze Auth-Tag + const decipher = createDecipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + decipher.setAuthTag(Uint8Array.from(authTag)); + + // Entschlüssele den Text + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + if (passError) throw error; + console.error("Decryption failed:", error); + return ""; + } + } +} diff --git a/src/helpers/convertHelper.ts b/src/helpers/convertHelper.ts new file mode 100644 index 0000000..d797126 --- /dev/null +++ b/src/helpers/convertHelper.ts @@ -0,0 +1,83 @@ +import ms from "ms"; +import validator from "validator"; + +export abstract class TypeConverter { + abstract fromString(value: string): T; + abstract toString(value: T): string; + abstract validate(value: string): boolean; +} + +export abstract class StringTypeConverter extends TypeConverter { + fromString(value: string): string { + return value; + } + toString(value: string): string { + return value; + } + validate(value: string): boolean { + return typeof value === "string"; + } +} + +export abstract class NumberTypeConverter extends TypeConverter { + fromString(value: string): number { + return Number(value); + } + toString(value: number): string { + return String(value); + } + validate(value: string): boolean { + const num = Number(value); + return !isNaN(num); + } +} + +export abstract class BooleanTypeConverter extends TypeConverter { + fromString(value: string): boolean { + return value === "true"; + } + toString(value: boolean): string { + return value ? "true" : "false"; + } + validate(value: string): boolean { + return value === "true" || value === "false"; + } +} + +export abstract class MsTypeConverter extends TypeConverter { + fromString(value: string): ms.StringValue { + return value as ms.StringValue; + } + toString(value: ms.StringValue): string { + return String(value); + } + validate(value: string): boolean { + try { + const result = ms(value as ms.StringValue); + return result !== undefined; + } catch (e) { + return false; + } + } +} + +export abstract class EmailTypeConverter extends TypeConverter { + fromString(value: string): string { + return value; + } + toString(value: string): string { + return value; + } + validate(value: string): boolean { + return validator.isEmail(value); + } +} + +// Konkrete Implementierungen der Converter +export class StringConverter extends StringTypeConverter {} +export class LongStringConverter extends StringTypeConverter {} +export class UrlConverter extends StringTypeConverter {} +export class NumberConverter extends NumberTypeConverter {} +export class BooleanConverter extends BooleanTypeConverter {} +export class MsConverter extends MsTypeConverter {} +export class EmailConverter extends EmailTypeConverter {} diff --git a/src/helpers/fileSystemHelper.ts b/src/helpers/fileSystemHelper.ts index f77bef2..9bc409a 100644 --- a/src/helpers/fileSystemHelper.ts +++ b/src/helpers/fileSystemHelper.ts @@ -20,9 +20,20 @@ export abstract class FileSystemHelper { return readFileSync(this.formatPath(...filePath), "base64"); } + static readRootFile(filePath: string) { + return readFileSync(this.normalizePath(process.cwd(), filePath), "utf8"); + } + static readTemplateFile(filePath: string) { - this.createFolder(filePath); - return readFileSync(process.cwd() + filePath, "utf8"); + return readFileSync(this.normalizePath(process.cwd(), "src", "templates", filePath), "utf8"); + } + + static readAssetFile(filePath: string, returnPath: boolean = false) { + let path = this.normalizePath(process.cwd(), "src", "assets", filePath); + if (returnPath) { + return path; + } + return readFileSync(path, "utf8"); } static writeFile(filePath: string, filename: string, file: any) { diff --git a/src/helpers/jwtHelper.ts b/src/helpers/jwtHelper.ts index 5708ab8..066bb8b 100644 --- a/src/helpers/jwtHelper.ts +++ b/src/helpers/jwtHelper.ts @@ -1,6 +1,5 @@ import jwt from "jsonwebtoken"; import { JWTData, JWTToken } from "../type/jwtTypes"; -import { JWT_SECRET, JWT_EXPIRATION } from "../env.defaults"; import InternalException from "../exceptions/internalException"; import RolePermissionService from "../service/management/rolePermissionService"; import UserPermissionService from "../service/management/userPermissionService"; @@ -9,11 +8,13 @@ import PermissionHelper from "./permissionHelper"; import WebapiService from "../service/management/webapiService"; import WebapiPermissionService from "../service/management/webapiPermissionService"; import ms from "ms"; +import SettingHelper from "./settingsHelper"; +import { APPLICATION_SECRET } from "../env.defaults"; export abstract class JWTHelper { static validate(token: string): Promise { return new Promise((resolve, reject) => { - jwt.verify(token, JWT_SECRET, (err, decoded) => { + jwt.verify(token, APPLICATION_SECRET, (err, decoded) => { if (err) reject(err.message); else resolve(decoded); }); @@ -27,9 +28,11 @@ export abstract class JWTHelper { return new Promise((resolve, reject) => { jwt.sign( data, - JWT_SECRET, + APPLICATION_SECRET, { - ...(useExpiration ?? true ? { expiresIn: expOverwrite ?? JWT_EXPIRATION } : {}), + ...(useExpiration ?? true + ? { expiresIn: expOverwrite ?? (SettingHelper.getSetting("session.jwt_expiration") as ms.StringValue) } + : {}), }, (err, token) => { if (err) reject(err.message); @@ -100,7 +103,8 @@ export abstract class JWTHelper { }; let overwriteExpiration = - ms(JWT_EXPIRATION) < new Date().getTime() - new Date(expiration).getTime() + ms(SettingHelper.getSetting("session.jwt_expiration") as ms.StringValue) < + new Date().getTime() - new Date(expiration).getTime() ? null : Date.now() - new Date(expiration).getTime(); diff --git a/src/helpers/mailHelper.ts b/src/helpers/mailHelper.ts index ab44a26..ff778b9 100644 --- a/src/helpers/mailHelper.ts +++ b/src/helpers/mailHelper.ts @@ -1,17 +1,78 @@ import { Transporter, createTransport, TransportOptions } from "nodemailer"; -import { CLUB_NAME, MAIL_HOST, MAIL_PASSWORD, MAIL_PORT, MAIL_SECURE, MAIL_USERNAME } from "../env.defaults"; import { Attachment } from "nodemailer/lib/mailer"; +import SettingHelper from "./settingsHelper"; +import validator from "validator"; export default abstract class MailHelper { - private static readonly transporter: Transporter = createTransport({ - host: MAIL_HOST, - port: MAIL_PORT, - secure: (MAIL_SECURE as "true" | "false") == "true", - auth: { - user: MAIL_USERNAME, - pass: MAIL_PASSWORD, - }, - } as TransportOptions); + private static transporter: Transporter; + + static createTransport() { + this.transporter?.close(); + + this.transporter = createTransport({ + host: SettingHelper.getSetting("mail.host"), + port: SettingHelper.getSetting("mail.port"), + secure: SettingHelper.getSetting("mail.secure"), + auth: { + user: SettingHelper.getSetting("mail.username"), + pass: SettingHelper.getSetting("mail.password"), + }, + } as TransportOptions); + } + + static async verifyTransport({ + host, + port, + secure, + user, + password, + }: { + host: string; + port: number; + secure: boolean; + user: string; + password: string; + }): Promise { + let transport = createTransport({ + host, + port, + secure, + auth: { user, pass: password }, + }); + + return await transport + .verify() + .then(() => { + return true; + }) + .catch((err) => { + console.log(err); + return false; + }) + .finally(() => { + try { + transport?.close(); + } catch (error) {} + }); + } + + static async checkMail(mail: string): Promise { + return validator.isEmail(mail); + // return await emailCheck(mail) + // .then((res) => { + // return res; + // }) + // .catch((err) => { + // return false; + // }); + } + + static initialize() { + SettingHelper.onSettingTopicChanged("mail", () => { + this.createTransport(); + }); + this.createTransport(); + } /** * @description send mail @@ -29,7 +90,7 @@ export default abstract class MailHelper { return new Promise((resolve, reject) => { this.transporter .sendMail({ - from: `"${CLUB_NAME}" <${MAIL_USERNAME}>`, + from: `"${SettingHelper.getSetting("club.name")}" <${SettingHelper.getSetting("mail.email")}>`, to: target, subject, text: content, diff --git a/src/helpers/newsletterHelper.ts b/src/helpers/newsletterHelper.ts index e84427f..c09da94 100644 --- a/src/helpers/newsletterHelper.ts +++ b/src/helpers/newsletterHelper.ts @@ -10,13 +10,13 @@ import { CalendarHelper } from "./calendarHelper"; import DynamicQueryBuilder from "./dynamicQueryBuilder"; import { FileSystemHelper } from "./fileSystemHelper"; import MailHelper from "./mailHelper"; -import { CLUB_NAME } from "../env.defaults"; import { TemplateHelper } from "./templateHelper"; import { PdfExport } from "./pdfExport"; import NewsletterConfigService from "../service/configuration/newsletterConfigService"; -import { NewsletterConfigType } from "../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../enums/newsletterConfigEnum"; import InternalException from "../exceptions/internalException"; import EventEmitter from "events"; +import SettingHelper from "./settingsHelper"; export interface NewsletterEventType { kind: "pdf" | "mail"; @@ -145,7 +145,7 @@ export abstract class NewsletterHelper { return []; } else { let members = await MemberService.getAll({ noLimit: true, ids: queryMemberIds }); - return members[0]; + return members[0].filter((m) => m.sendNewsletter != null); } } @@ -154,14 +154,11 @@ export abstract class NewsletterHelper { let recipients = await NewsletterRecipientsService.getAll(newsletterId); let config = await NewsletterConfigService.getAll(); - let allowedForMail = config.filter((c) => c.config == NewsletterConfigType.mail).map((c) => c.comTypeId); + let allowedForMail = config.filter((c) => c.config == NewsletterConfigEnum.mail).map((c) => c.comTypeId); const members = await this.transformRecipientsToMembers(newsletter, recipients); const mailRecipients = members.filter( - (m) => - m.sendNewsletter != null && - m.sendNewsletter?.email != null && - allowedForMail.includes(m.sendNewsletter?.type?.id) + (m) => m.sendNewsletter?.email != "" && allowedForMail.includes(m.sendNewsletter?.type?.id) ); return mailRecipients; @@ -172,17 +169,17 @@ export abstract class NewsletterHelper { let recipients = await NewsletterRecipientsService.getAll(newsletterId); let config = await NewsletterConfigService.getAll(); - let notAllowedForPdf = config.filter((c) => c.config == NewsletterConfigType.mail).map((c) => c.comTypeId); + let notAllowedForPdf = config + .filter((c) => c.config == NewsletterConfigEnum.none || c.config == NewsletterConfigEnum.mail) + .map((c) => c.comTypeId); const members = await this.transformRecipientsToMembers(newsletter, recipients); - const pdfRecipients = members.filter( - (m) => !notAllowedForPdf.includes(m.sendNewsletter?.type?.id) || m.sendNewsletter == null - ); + const pdfRecipients = members.filter((m) => !notAllowedForPdf.includes(m.sendNewsletter?.type?.id)); pdfRecipients.unshift({ id: "0", firstname: "Alle Mitglieder", - lastname: CLUB_NAME, + lastname: SettingHelper.getSetting("club.name"), nameaffix: "", salutation: { salutation: "" }, } as member); @@ -224,11 +221,14 @@ export abstract class NewsletterHelper { const { body } = await TemplateHelper.renderFileForModule({ module: "newsletter", bodyData: data, - title: `Newsletter von ${CLUB_NAME}`, + title: `Newsletter von ${SettingHelper.getSetting("club.name")}`, }); - await MailHelper.sendMail(rec.sendNewsletter.email, `Newsletter von ${CLUB_NAME}`, body, [ - { filename: "events.ics", path: this.getICSFilePath(newsletter) }, - ]) + await MailHelper.sendMail( + rec.sendNewsletter.email, + `Newsletter von ${SettingHelper.getSetting("club.name")}`, + body, + [{ filename: "events.ics", path: this.getICSFilePath(newsletter) }] + ) .then(() => { this.formatJobEmit( "progress", @@ -278,7 +278,7 @@ export abstract class NewsletterHelper { if (error) throw new InternalException("Failed Building ICS form Pdf", error); this.saveIcsToFile(newsletter, value); - let printWithAdress = config.filter((c) => c.config == NewsletterConfigType.pdf).map((c) => c.comTypeId); + let printWithAdress = config.filter((c) => c.config == NewsletterConfigEnum.pdf).map((c) => c.comTypeId); const pdfRecipients = await this.getPrintRecipients(newsletterId); @@ -289,7 +289,7 @@ export abstract class NewsletterHelper { await PdfExport.renderFile({ template: "newsletter", - title: `Newsletter von ${CLUB_NAME}`, + title: `Newsletter von ${SettingHelper.getSetting("club.name")}`, filename: `${rec.lastname}_${rec.firstname}_${rec.id}`.replaceAll(" ", "-"), folder: `newsletter/${newsletter.id}_${newsletter.title.replaceAll(" ", "")}`, data: data, diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts new file mode 100644 index 0000000..526e10a --- /dev/null +++ b/src/helpers/settingsHelper.ts @@ -0,0 +1,288 @@ +import { SettingString, settingsType, SettingTopic, SettingTypeAtom, SettingValueMapping } from "../type/settingTypes"; +import { CodingHelper } from "./codingHelper"; +import SettingCommandHandler from "../command/management/setting/settingCommandHandler"; +import SettingService from "../service/management/settingService"; +import { APPLICATION_SECRET } from "../env.defaults"; +import { + BooleanConverter, + EmailConverter, + LongStringConverter, + MsConverter, + NumberConverter, + StringConverter, + TypeConverter, + UrlConverter, +} from "./convertHelper"; +import cloneDeep from "lodash.clonedeep"; +import { rejects } from "assert"; +import InternalException from "../exceptions/internalException"; +import MailHelper from "./mailHelper"; + +export default abstract class SettingHelper { + private static settings: { [key in SettingString]?: string } = {}; + + private static listeners: Map void>> = new Map(); + private static topicListeners: Map void>> = new Map(); + + private static readonly converters: Record> = { + longstring: new LongStringConverter(), + string: new StringConverter(), + url: new UrlConverter(), + number: new NumberConverter(), + boolean: new BooleanConverter(), + ms: new MsConverter(), + email: new EmailConverter(), + }; + + public static getAllSettings(): { [key in SettingString]: SettingValueMapping[key] } { + return Object.keys(settingsType).reduce((acc, key) => { + const typedKey = key as SettingString; + //@ts-expect-error + acc[typedKey] = this.getSetting(typedKey); + return acc; + }, {} as { [key in SettingString]: SettingValueMapping[key] }); + } + + /** + * Returns the value of a setting with the correct type based on the key + * @param key The key of the setting + * @returns The typed value of the setting + */ + public static getSetting(key: K): SettingValueMapping[K] { + const settingType = settingsType[key]; + const rawValue = this.settings[key] ?? String(settingType.default ?? ""); + + if (Array.isArray(settingType.type)) { + return rawValue as unknown as SettingValueMapping[K]; + } + + let processedValue = rawValue; + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); + } + + const baseType = + typeof settingType.type === "string" + ? (settingType.type.split("/")[0] as SettingTypeAtom) + : (settingType.type as SettingTypeAtom); + + return this.converters[baseType].fromString(processedValue) as unknown as SettingValueMapping[K]; + } + + /** + * Sets a setting + * undefined value leads to reset of key + * @param key The key of the setting + * @param value The value to set + */ + public static async setSetting(key: K, value: SettingValueMapping[K]): Promise { + if (value === undefined || value === null) { + if (key != "mail.password") this.resetSetting(key); + return; + } + + const stringValue = String(value); + + const settingType = settingsType[key]; + this.validateSetting(key, stringValue); + + const oldValue = cloneDeep(this.settings[key]); + let newValue = stringValue; + + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + newValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); + } + + this.settings[key] = stringValue; + const [topic, settingKey] = key.split(".") as [SettingTopic, string]; + + await SettingCommandHandler.create({ + topic, + key: settingKey, + value: newValue, + }); + + this.notifyListeners(key, newValue, oldValue); + } + + /** + * Resets a setting to its default value + * @param key The key of the setting + */ + public static async resetSetting(key: SettingString): Promise { + if (this.getSetting(key) == String(settingsType[key].default ?? "")) return; + + const oldValue = this.getSetting(key); + + const settingType = settingsType[key]; + this.settings[key] = String(settingType.default ?? ""); + + const [topic, settingKey] = key.split(".") as [SettingTopic, string]; + await SettingCommandHandler.delete({ + topic, + key: settingKey, + }); + + const newValue = this.getSetting(key); + this.notifyListeners(key, newValue, oldValue); + } + + public static async configure(): Promise { + console.log("Configuring Settings"); + const settings = await SettingService.getSettings(); + + for (const element of settings) { + const ref = `${element.topic}.${element.key}` as SettingString; + this.settings[ref] = element.value; + + try { + this.validateSetting(ref); + } catch (error) { + console.warn(`Invalid setting ${ref}: ${error.message}`); + } + } + } + + public static async checkMail( + setting: Array<{ key: K; value: SettingValueMapping[K] }> + ): Promise { + return new Promise(async (resolve, reject) => { + if (setting.some((t) => t.key == "mail.email" && t.value != undefined)) { + let emailValue = setting.find((t) => t.key == "mail.email").value as string; + let checkMail = await MailHelper.checkMail(emailValue); + if (!checkMail) { + return reject("mail"); + } + } + + if (setting.some((t) => t.key.startsWith("mail"))) { + let checkConfig = await MailHelper.verifyTransport({ + user: + (setting.find((t) => t.key == "mail.username").value as string) ?? + SettingHelper.getSetting("mail.username"), + password: + (setting.find((t) => t.key == "mail.password").value as string) ?? + SettingHelper.getSetting("mail.password"), + host: (setting.find((t) => t.key == "mail.host").value as string) ?? SettingHelper.getSetting("mail.host"), + port: (setting.find((t) => t.key == "mail.port").value as number) ?? SettingHelper.getSetting("mail.port"), + secure: + (setting.find((t) => t.key == "mail.secure").value as boolean) ?? SettingHelper.getSetting("mail.secure"), + }); + if (!checkConfig) { + return reject("Config is not valid"); + } + } + resolve(); + }); + } + + /** + * Validates a setting + * @param key The key of the setting + * @param value Optional value to validate + */ + private static validateSetting(key: SettingString, value?: string): void { + const settingType = settingsType[key]; + const valueToCheck = value ?? this.settings[key] ?? String(settingType.default ?? ""); + + if (Array.isArray(settingType.type)) { + return; + } + + const baseType = + typeof settingType.type === "string" + ? (settingType.type.split("/")[0] as SettingTypeAtom) + : (settingType.type as SettingTypeAtom); + + if (!this.converters[baseType].validate(valueToCheck)) { + throw new Error(`Invalid value for ${key} of type ${baseType}`); + } + + if (baseType === "number" && settingType.min !== undefined) { + const numValue = Number(valueToCheck); + if (numValue < settingType.min) { + throw new Error(`${key} must be at least ${settingType.min}`); + } + } + } + + /** + * Registers a listener for changes to a specific setting + * @param key The setting to monitor + * @param callback Function to be called when changes occur + */ + public static onSettingChanged( + key: K, + callback: (newValue: SettingValueMapping[K], oldValue: SettingValueMapping[K]) => void + ): void { + if (!this.listeners.has(key)) { + this.listeners.set(key, []); + } + + this.listeners.get(key)!.push(callback); + } + + /** + * Registers a listener for changes to a specific setting + * @param key The setting to monitor + * @param callback Function to be called when changes occur + */ + public static onSettingTopicChanged(key: K, callback: () => void): void { + if (!this.topicListeners.has(key)) { + this.topicListeners.set(key, []); + } + + this.topicListeners.get(key)!.push(callback); + } + + /** + * Removes a registered listener + * @param key The setting + * @param callback The callback to remove + */ + public static removeSettingListener( + key: K, + callback: (newValue: SettingValueMapping[K], oldValue: SettingValueMapping[K]) => void + ): void { + if (!this.listeners.has(key)) return; + + const callbacks = this.listeners.get(key)!; + const index = callbacks.indexOf(callback); + + if (index !== -1) { + callbacks.splice(index, 1); + } + + if (callbacks.length === 0) { + this.listeners.delete(key); + } + } + + /** + * Notifies all registered listeners about changes + * @param key The changed setting + * @param newValue The new value + * @param oldValue The old value + */ + private static notifyListeners(key: SettingString, newValue: any, oldValue: any): void { + if (!this.listeners.has(key)) return; + + const callbacks = this.listeners.get(key)!; + for (const callback of callbacks) { + try { + callback(newValue, oldValue); + } catch (error) { + console.error(`Error in setting listener for ${key}:`, error); + } + } + + const topicCallbacks = this.topicListeners.get(key.split(".")[0] as SettingTopic)!; + for (const callback of topicCallbacks) { + try { + callback(); + } catch (error) { + console.error(`Error in setting listener for ${key.split(".")[0]}:`, error); + } + } + } +} diff --git a/src/helpers/templateHelper.ts b/src/helpers/templateHelper.ts index 3dc5ad8..dd77a74 100644 --- a/src/helpers/templateHelper.ts +++ b/src/helpers/templateHelper.ts @@ -9,10 +9,10 @@ export abstract class TemplateHelper { static getTemplateFromFile(template: string) { let tmpFile; try { - tmpFile = FileSystemHelper.readTemplateFile(`/src/templates/${template}.template.html`); + tmpFile = FileSystemHelper.readTemplateFile(`${template}.template.html`); } catch (err) { tmpFile = FileSystemHelper.readTemplateFile( - `/src/templates/${template.split(".")[template.split(".").length - 1]}.template.html` + `${template.split(".")[template.split(".").length - 1]}.template.html` ); } return tmpFile; diff --git a/src/index.ts b/src/index.ts index a282863..13de2ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import "./handlebars.config"; import express from "express"; -import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults"; +import { configCheck } from "./env.defaults"; configCheck(); import { PermissionObject } from "./type/permissionTypes"; @@ -21,23 +21,29 @@ declare global { import { dataSource } from "./data-source"; import BackupHelper from "./helpers/backupHelper"; +import SettingHelper from "./helpers/settingsHelper"; dataSource.initialize().then(async () => { - if ((BACKUP_AUTO_RESTORE as "true" | "false") == "true" && (await dataSource.createQueryRunner().hasTable("user"))) { + if (await dataSource.createQueryRunner().hasTable("user")) { await BackupHelper.autoRestoreBackup().catch((err) => { console.log(`${new Date().toISOString()}: failed auto-restoring database`, err); }); } + await SettingHelper.configure(); + MailHelper.initialize(); }); const app = express(); import router from "./routes/index"; router(app); -app.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => { - console.log(`${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? SERVER_PORT : 5000}`); +app.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { + console.log( + `${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000}` + ); }); import schedule from "node-schedule"; import RefreshCommandHandler from "./command/refreshCommandHandler"; +import MailHelper from "./helpers/mailHelper"; const job = schedule.scheduleJob("0 0 * * *", async () => { console.log(`${new Date().toISOString()}: running Cron`); await RefreshCommandHandler.deleteExpired(); diff --git a/src/middleware/multer.ts b/src/middleware/multer.ts new file mode 100644 index 0000000..ebcec4e --- /dev/null +++ b/src/middleware/multer.ts @@ -0,0 +1,35 @@ +import multer from "multer"; +import { FileSystemHelper } from "../helpers/fileSystemHelper"; +import path from "path"; +import BadRequestException from "../exceptions/badRequestException"; + +export const clubImageStorage = multer.diskStorage({ + destination: FileSystemHelper.formatPath("/app"), + filename: function (req, file, cb) { + const fileExtension = path.extname(file.originalname).toLowerCase(); + + if (file.fieldname === "icon") { + cb(null, "admin-icon" + fileExtension); + } else if (file.fieldname === "logo") { + cb(null, "admin-logo" + fileExtension); + } else { + cb(null, file.originalname); + } + }, +}); + +export const clubImageMulter = multer({ + storage: clubImageStorage, + fileFilter(req, file, cb) { + if (file.mimetype.startsWith("image/png")) { + cb(null, true); + } else { + cb(new BadRequestException("Wrong file format")); + } + }, +}); + +export const clubImageUpload = clubImageMulter.fields([ + { name: "icon", maxCount: 1 }, + { name: "logo", maxCount: 1 }, +]); diff --git a/src/migrations/1742922178643-queryToUUID.ts b/src/migrations/1742922178643-queryToUUID.ts index b52990e..3902fb9 100644 --- a/src/migrations/1742922178643-queryToUUID.ts +++ b/src/migrations/1742922178643-queryToUUID.ts @@ -10,8 +10,8 @@ export class QueryToUUID1742922178643 implements MigrationInterface { const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf("recipientsByQueryId") !== -1); await queryRunner.dropForeignKey("newsletter", foreignKey); - const entries = await queryRunner.manager.getRepository(query).find({ select: { title: true, query: true } }); - await queryRunner.clearTable("query"); + // const entries = await queryRunner.manager.getRepository("query").find({ select: { title: true, query: true } }); + // await queryRunner.clearTable("query"); await queryRunner.dropColumn("newsletter", "recipientsByQueryId"); await queryRunner.dropColumn("query", "id"); @@ -32,7 +32,7 @@ export class QueryToUUID1742922178643 implements MigrationInterface { }) ); - await queryRunner.manager.createQueryBuilder().insert().into("query").values(entries).execute(); + // await queryRunner.manager.getRepository("query").save(entries); await queryRunner.createForeignKey( "newsletter", diff --git a/src/migrations/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts new file mode 100644 index 0000000..1faee5e --- /dev/null +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { setting_table } from "./baseSchemaTables/admin"; +import SettingHelper from "../helpers/settingsHelper"; +import ms from "ms"; + +export class SettingsFromEnv1745059495808 implements MigrationInterface { + name = "SettingsFromEnv1745059495808"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(setting_table, true, true, true); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable(setting_table.name, true, true, true); + } +} diff --git a/src/migrations/1745059495808-settingsFromEnv_set.ts b/src/migrations/1745059495808-settingsFromEnv_set.ts new file mode 100644 index 0000000..c99c7b0 --- /dev/null +++ b/src/migrations/1745059495808-settingsFromEnv_set.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { setting_table } from "./baseSchemaTables/admin"; +import SettingHelper from "../helpers/settingsHelper"; +import ms from "ms"; + +export class SettingsFromEnv_SET1745059495808 implements MigrationInterface { + name = "SettingsFromEnv_SET1745059495808"; + + public async up(queryRunner: QueryRunner): Promise { + // transfer settings of env to database + await SettingHelper.setSetting("club.name", process.env.CLUB_NAME); + await SettingHelper.setSetting("club.website", process.env.CLUB_WEBSITE); + await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting( + "session.pwa_refresh_expiration", + process.env.PWA_REFRESH_EXPIRATION as ms.StringValue + ); + await SettingHelper.setSetting("mail.username", process.env.MAIL_USERNAME); + await SettingHelper.setSetting("mail.password", process.env.MAIL_PASSWORD); + await SettingHelper.setSetting("mail.host", process.env.MAIL_HOST); + await SettingHelper.setSetting("mail.port", Number(process.env.MAIL_PORT ?? "578")); + await SettingHelper.setSetting("mail.secure", Boolean(process.env.MAIL_SECURE ?? "false")); + await SettingHelper.setSetting("backup.interval", Number(process.env.BACKUP_INTERVAL ?? "1")); + await SettingHelper.setSetting("backup.copies", Number(process.env.BACKUP_COPIES ?? "7")); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/src/migrations/1746006549262-memberCreatedAt.ts b/src/migrations/1746006549262-memberCreatedAt.ts new file mode 100644 index 0000000..08f7669 --- /dev/null +++ b/src/migrations/1746006549262-memberCreatedAt.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { getTypeByORM, getDefaultByORM } from "./ormHelper"; + +export class MemberCreatedAt1746006549262 implements MigrationInterface { + name = "MemberCreatedAt1746006549262"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "member", + new TableColumn({ + name: "createdAt", + ...getTypeByORM("datetime", false, 6), + default: getDefaultByORM("currentTimestamp", 6), + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("member", "createdAt"); + } +} diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts new file mode 100644 index 0000000..b11f9b0 --- /dev/null +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { getDefaultByORM, getTypeByORM } from "./ormHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; +import { CodingHelper } from "../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../env.defaults"; + +export class UserLoginRoutine1746252454922 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumns("user", ["secret", "static"]); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("text"), default: getDefaultByORM("string") }), + new TableColumn({ + name: "routine", + ...getTypeByORM("varchar"), + default: getDefaultByORM("string", LoginRoutineEnum.totp), + }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + } + + public async down(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumn("user", "secret"); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("varchar"), default: getDefaultByORM("string") }), + new TableColumn({ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + + await queryRunner.dropColumn("user", "routine"); + } +} diff --git a/src/migrations/baseSchemaTables/admin.ts b/src/migrations/baseSchemaTables/admin.ts index c3eb94b..6ece9ad 100644 --- a/src/migrations/baseSchemaTables/admin.ts +++ b/src/migrations/baseSchemaTables/admin.ts @@ -148,3 +148,12 @@ export const reset_table = new Table({ { name: "secret", ...getTypeByORM("varchar") }, ], }); + +export const setting_table = new Table({ + name: "setting", + columns: [ + { name: "topic", ...getTypeByORM("varchar"), isPrimary: true }, + { name: "key", ...getTypeByORM("varchar"), isPrimary: true }, + { name: "value", ...getTypeByORM("text") }, + ], +}); diff --git a/src/migrations/ormHelper.ts b/src/migrations/ormHelper.ts index 20a57ad..8ebe5c9 100644 --- a/src/migrations/ormHelper.ts +++ b/src/migrations/ormHelper.ts @@ -1,3 +1,5 @@ +import { DB_TYPE } from "../env.defaults"; + export type ORMType = "int" | "bigint" | "boolean" | "date" | "datetime" | "time" | "text" | "varchar" | "uuid"; export type ORMDefault = "currentTimestamp" | "string" | "boolean" | "number" | "null"; export type ColumnConfig = { @@ -13,7 +15,7 @@ export type Primary = { }; export function getTypeByORM(type: ORMType, nullable: boolean = false, length: number = 255): ColumnConfig { - const dbType = process.env.DB_TYPE; + const dbType = DB_TYPE; const typeMap: Record> = { mysql: { @@ -63,7 +65,7 @@ export function getTypeByORM(type: ORMType, nullable: boolean = false, length: n } export function getDefaultByORM(type: ORMDefault, data?: string | number | boolean): T { - const dbType = process.env.DB_TYPE; + const dbType = DB_TYPE; const typeMap: Record> = { mysql: { diff --git a/src/routes/admin/club/member.ts b/src/routes/admin/club/member.ts index efd6e05..804b595 100644 --- a/src/routes/admin/club/member.ts +++ b/src/routes/admin/club/member.ts @@ -20,6 +20,7 @@ import { getExecutivePositionByMemberAndRecord, getExecutivePositionsByMember, getMemberById, + getMemberLastInternalId, getMemberPrintoutById, getMembersByIds, getMembershipByMemberAndRecord, @@ -43,6 +44,10 @@ router.get("/", async (req: Request, res: Response) => { await getAllMembers(req, res); }); +router.get("/last/internalId", async (req: Request, res: Response) => { + await getMemberLastInternalId(req, res); +}); + router.post("/ids", async (req: Request, res: Response) => { await getMembersByIds(req, res); }); diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index c2bab0f..5c3444b 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -26,6 +26,7 @@ import user from "./management/user"; import invite from "./management/invite"; import api from "./management/webapi"; import backup from "./management/backup"; +import setting from "./management/setting"; var router = express.Router({ mergeParams: true }); @@ -159,5 +160,6 @@ router.use( PermissionHelper.passCheckMiddleware("read", "management", "backup"), backup ); +router.use("/setting", PermissionHelper.passCheckMiddleware("read", "management", "setting"), setting); export default router; diff --git a/src/routes/admin/management/setting.ts b/src/routes/admin/management/setting.ts new file mode 100644 index 0000000..c284a32 --- /dev/null +++ b/src/routes/admin/management/setting.ts @@ -0,0 +1,56 @@ +import express, { Request, Response } from "express"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import { + getSetting, + getSettings, + resetSetting, + setImages, + setSetting, + setSettings, +} from "../../../controller/admin/management/settingController"; +import { clubImageUpload } from "../../../middleware/multer"; + +var router = express.Router({ mergeParams: true }); + +router.get("/", async (req: Request, res: Response) => { + await getSettings(req, res); +}); + +router.get("/:setting", async (req: Request, res: Response) => { + await getSetting(req, res); +}); + +router.put( + "/", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + async (req: Request, res: Response) => { + await setSetting(req, res); + } +); + +router.put( + "/multi", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + async (req: Request, res: Response) => { + await setSettings(req, res); + } +); + +router.put( + "/images", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + clubImageUpload, + async (req: Request, res: Response) => { + await setImages(req, res); + } +); + +router.delete( + "/:setting", + PermissionHelper.passCheckMiddleware("delete", "management", "setting"), + async (req: Request, res: Response) => { + await resetSetting(req, res); + } +); + +export default router; diff --git a/src/routes/admin/management/user.ts b/src/routes/admin/management/user.ts index 3a419d6..15d3e27 100644 --- a/src/routes/admin/management/user.ts +++ b/src/routes/admin/management/user.ts @@ -10,7 +10,6 @@ import { updateUserPermissions, updateUserRoles, } from "../../../controller/admin/management/userController"; -import { inviteUser } from "../../../controller/inviteController"; var router = express.Router({ mergeParams: true }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b1200bc..c15f7cb 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,8 +1,12 @@ import express from "express"; -import { login, logout, refresh } from "../controller/authController"; +import { kickof, login, logout, refresh } from "../controller/authController"; var router = express.Router({ mergeParams: true }); +router.post("/kickof", async (req, res) => { + await kickof(req, res); +}); + router.post("/login", async (req, res) => { await login(req, res); }); diff --git a/src/routes/invite.ts b/src/routes/invite.ts index 783ecef..ebb4ddd 100644 --- a/src/routes/invite.ts +++ b/src/routes/invite.ts @@ -1,6 +1,5 @@ import express from "express"; -import { isSetup } from "../controller/setupController"; -import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController"; +import { finishInvite, verifyInvite } from "../controller/inviteController"; import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; var router = express.Router({ mergeParams: true }); @@ -9,8 +8,12 @@ router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mai await verifyInvite(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine "]), + async (req, res) => { + await finishInvite(req, res); + } +); export default router; diff --git a/src/routes/public.ts b/src/routes/public.ts index 56b4784..d557dd7 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,5 +1,12 @@ import express from "express"; -import { getCalendarItemsByTypes } from "../controller/publicController"; +import { + getApplicationConfig, + getApplicationFavicon, + getApplicationIcon, + getApplicationLogo, + getApplicationManifest, + getCalendarItemsByTypes, +} from "../controller/publicController"; var router = express.Router({ mergeParams: true }); @@ -7,4 +14,24 @@ router.get("/calendar", async (req, res) => { await getCalendarItemsByTypes(req, res); }); +router.get("/configuration", async (req, res) => { + await getApplicationConfig(req, res); +}); + +router.get("/manifest.webmanifest", async (req, res) => { + await getApplicationManifest(req, res); +}); + +router.get("/applogo.png", async (req, res) => { + await getApplicationLogo(req, res); +}); + +router.get("/favicon.ico", async (req, res) => { + await getApplicationFavicon(req, res); +}); + +router.get("/icon.png", async (req, res) => { + await getApplicationIcon(req, res); +}); + export default router; diff --git a/src/routes/reset.ts b/src/routes/reset.ts index acb1516..31df6c4 100644 --- a/src/routes/reset.ts +++ b/src/routes/reset.ts @@ -12,8 +12,12 @@ router.post("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username" await startReset(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishReset(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine"]), + async (req, res) => { + await finishReset(req, res); + } +); export default router; diff --git a/src/routes/server.ts b/src/routes/server.ts index 964d207..782609d 100644 --- a/src/routes/server.ts +++ b/src/routes/server.ts @@ -5,7 +5,7 @@ import Parser from "rss-parser"; var router = express.Router({ mergeParams: true }); router.get("/version", async (req: Request, res: Response) => { - let serverPackage = FileSystemHelper.readTemplateFile("/package.json"); + let serverPackage = FileSystemHelper.readRootFile("/package.json"); let serverJson = JSON.parse(serverPackage); res.send({ name: serverJson.name, diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 159e04e..4830d4a 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -1,7 +1,14 @@ import express from "express"; -import { isSetup } from "../controller/setupController"; +import { + isSetup, + setAppIdentity, + setClubIdentity, + setMailConfig, + uploadClubImages, +} from "../controller/setupController"; import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController"; import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; +import { clubImageUpload } from "../middleware/multer"; var router = express.Router({ mergeParams: true }); @@ -9,20 +16,44 @@ router.get("/", async (req, res) => { await isSetup(req, res); }); +router.post("/club", async (req, res) => { + await setClubIdentity(req, res); +}); + +router.post("/club/images", clubImageUpload, async (req, res) => { + await uploadClubImages(req, res); +}); + +router.post("/app", async (req, res) => { + await setAppIdentity(req, res); +}); + +router.post( + "/mail", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "username", "password", "host", "port", "secure"]), + async (req, res) => { + await setMailConfig(req, res); + } +); + router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => { await verifyInvite(req, res); }); router.post( - "/", + "/me", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username", "mail", "firstname", "lastname"]), async (req, res) => { - await inviteUser(req, res, false); + await inviteUser(req, res, true); } ); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res, true); -}); +router.post( + "/finish", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), + async (req, res) => { + await finishInvite(req, res, true); + } +); export default router; diff --git a/src/routes/user.ts b/src/routes/user.ts index d196e16..90ba489 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,5 +1,16 @@ import express from "express"; -import { getMeById, getMyTotp, transferOwnership, updateMe, verifyMyTotp } from "../controller/userController"; +import { + changeMyPassword, + changeToPW, + changeToTOTP, + getChangeToTOTP, + getMeById, + getMyRoutine, + getMyTotp, + transferOwnership, + updateMe, + verifyMyTotp, +} from "../controller/userController"; var router = express.Router({ mergeParams: true }); @@ -7,14 +18,34 @@ router.get("/me", async (req, res) => { await getMeById(req, res); }); +router.get("/routine", async (req, res) => { + await getMyRoutine(req, res); +}); + router.get("/totp", async (req, res) => { await getMyTotp(req, res); }); +router.get("/changeToTOTP", async (req, res) => { + await getChangeToTOTP(req, res); +}); + router.post("/verify", async (req, res) => { await verifyMyTotp(req, res); }); +router.post("/changepw", async (req, res) => { + await changeMyPassword(req, res); +}); + +router.post("/changeToTOTP", async (req, res) => { + await changeToTOTP(req, res); +}); + +router.post("/changeToPW", async (req, res) => { + await changeToPW(req, res); +}); + router.put("/transferOwner", async (req, res) => { await transferOwnership(req, res); }); diff --git a/src/service/club/member/memberService.ts b/src/service/club/member/memberService.ts index 3eae4e6..5149114 100644 --- a/src/service/club/member/memberService.ts +++ b/src/service/club/member/memberService.ts @@ -1,9 +1,7 @@ -import { Brackets, Like, SelectQueryBuilder } from "typeorm"; +import { Brackets, Like, Not, SelectQueryBuilder } from "typeorm"; import { dataSource } from "../../../data-source"; import { member } from "../../../entity/club/member/member"; -import { membership } from "../../../entity/club/member/membership"; import DatabaseActionException from "../../../exceptions/databaseActionException"; -import InternalException from "../../../exceptions/internalException"; import { memberView } from "../../../views/memberView"; import { DB_TYPE } from "../../../env.defaults"; @@ -31,9 +29,12 @@ export default abstract class MemberService { let searchBits = search.split(" "); if (searchBits.length < 2) { - query = query.where(`member.firstname LIKE :searchQuery OR member.lastname LIKE :searchQuery`, { - searchQuery: `%${searchBits[0]}%`, - }); + query = query.where( + `member.firstname LIKE :searchQuery OR member.lastname LIKE :searchQuery OR member.internalId LIKE :searchQuery`, + { + searchQuery: `%${searchBits[0]}%`, + } + ); } else { searchBits .flatMap((v, i) => searchBits.slice(i + 1).map((w) => [v, w])) @@ -157,6 +158,28 @@ export default abstract class MemberService { }); } + /** + * @description get latest inserted memberId + * @returns {Promise} + */ + static async getLatestInternalId(): Promise { + return await dataSource + .getRepository(member) + .createQueryBuilder("member") + .where("member.internalId IS NOT NULL") + .andWhere({ internalId: Not("") }) + .orderBy("member.createdAt", "DESC") + .addOrderBy("member.internalId", "DESC") + .limit(1) + .getOne() + .then((res) => { + return res?.internalId ?? ""; + }) + .catch((err) => { + throw new DatabaseActionException("SELECT", "memberId", err); + }); + } + /** * @description apply member joins to query * @returns {SelectQueryBuilder} diff --git a/src/service/management/settingService.ts b/src/service/management/settingService.ts new file mode 100644 index 0000000..4f9b5e2 --- /dev/null +++ b/src/service/management/settingService.ts @@ -0,0 +1,43 @@ +import { dataSource } from "../../data-source"; +import { setting } from "../../entity/management/setting"; +import InternalException from "../../exceptions/internalException"; +import { SettingString } from "../../type/settingTypes"; + +export default abstract class SettingService { + /** + * @description get settings + * @returns {Promise} + */ + static async getSettings(): Promise { + return await dataSource + .getRepository(setting) + .createQueryBuilder("setting") + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("setting not found", err); + }); + } + + /** + * @description get setting + * @param token SettingString + * @returns {Promise} + */ + static async getBySettingString(key: SettingString): Promise { + return await dataSource + .getRepository(setting) + .createQueryBuilder("setting") + .where("setting.topic = :topic", { topic: key.split(".")[0] }) + .andWhere("setting.key >= :key", { key: key.split(".")[1] }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("setting not found", err); + }); + } +} diff --git a/src/service/management/userService.ts b/src/service/management/userService.ts index 8fe4dd4..d273315 100644 --- a/src/service/management/userService.ts +++ b/src/service/management/userService.ts @@ -129,4 +129,27 @@ export default abstract class UserService { throw new DatabaseActionException("SELECT", "userRoles", err); }); } + + /** + * @description get secret and routine by iser + * @param userId string + * @returns {Promise} + */ + static async getUserSecretAndRoutine(userId: string): Promise { + return await dataSource + .getRepository(user) + .createQueryBuilder("user") + .select("user.id") + .addSelect("user.secret") + .addSelect("user.routine") + .where("user.id = :id", { id: userId }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + console.log(err); + throw new DatabaseActionException("SELECT", "user credentials", err); + }); + } } diff --git a/src/type/permissionTypes.ts b/src/type/permissionTypes.ts index 1061d49..c3babac 100644 --- a/src/type/permissionTypes.ts +++ b/src/type/permissionTypes.ts @@ -21,7 +21,8 @@ export type PermissionModule = | "query_store" | "template" | "template_usage" - | "backup"; + | "backup" + | "setting"; export type PermissionType = "read" | "create" | "update" | "delete"; @@ -78,6 +79,7 @@ export const permissionModules: Array = [ "template", "template_usage", "backup", + "setting", ]; export const permissionTypes: Array = ["read", "create", "update", "delete"]; export const sectionsAndModules: SectionsAndModulesObject = { @@ -95,6 +97,6 @@ export const sectionsAndModules: SectionsAndModulesObject = { "template_usage", "newsletter_config", ], - management: ["user", "role", "webapi", "backup"], + management: ["user", "role", "webapi", "backup", "setting"], additional: [], }; diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts new file mode 100644 index 0000000..ff6e728 --- /dev/null +++ b/src/type/settingTypes.ts @@ -0,0 +1,121 @@ +import ms from "ms"; + +export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security"; +export type SettingString = + | "club.icon" + | "club.logo" + | "club.name" + | "club.imprint" + | "club.privacy" + | "club.website" + | "app.custom_login_message" + | "app.show_link_to_calendar" + | "session.jwt_expiration" + | "session.refresh_expiration" + | "session.pwa_refresh_expiration" + | "mail.email" + | "mail.username" + | "mail.password" + | "mail.host" + | "mail.port" + | "mail.secure" + | "backup.interval" + | "backup.copies"; + +export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url" | "email"; +export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`; + +export type SettingValueMapping = { + "club.icon": string; + "club.logo": string; + "club.name": string; + "club.imprint": string; + "club.privacy": string; + "club.website": string; + "app.custom_login_message": string; + "app.show_link_to_calendar": boolean; + "session.jwt_expiration": ms.StringValue; + "session.refresh_expiration": ms.StringValue; + "session.pwa_refresh_expiration": ms.StringValue; + "mail.email": string; + "mail.username": string; + "mail.password": string; + "mail.host": string; + "mail.port": number; + "mail.secure": boolean; + "backup.interval": number; + "backup.copies": number; +}; + +// Typsicherer Zugriff auf Settings +export type SettingDefinition = { + type: T; + default?: string | number | boolean | ms.StringValue; + optional?: boolean; + min?: T extends "number" | `number/crypt` | `number/rand` ? number : never; +}; + +export type SettingsSchema = { + [key in SettingString]: SettingDefinition; +}; + +export const settingsType: SettingsSchema = { + "club.icon": { type: "string", optional: true }, + "club.logo": { type: "string", optional: true }, + "club.name": { type: "string", default: "FF Admin" }, + "club.imprint": { type: "url", optional: true }, + "club.privacy": { type: "url", optional: true }, + "club.website": { type: "url", optional: true }, + "app.custom_login_message": { type: "string", optional: true }, + "app.show_link_to_calendar": { type: "boolean", default: true }, + "session.jwt_expiration": { type: "ms", default: "15m" }, + "session.refresh_expiration": { type: "ms", default: "1d" }, + "session.pwa_refresh_expiration": { type: "ms", default: "5d" }, + "mail.email": { type: "email", optional: false }, + "mail.username": { type: "string", optional: false }, + "mail.password": { type: "string/crypt", optional: false }, + "mail.host": { type: "url", optional: false }, + "mail.port": { type: "number", default: 587 }, + "mail.secure": { type: "boolean", default: false }, + "backup.interval": { type: "number", default: 1, min: 1 }, + "backup.copies": { type: "number", default: 7, min: 1 }, +}; + +/** ENV Settings */ +export type EnvSettingString = + | "database.type" + | "database.host" + | "database.port" + | "database.name" + | "database.username" + | "database.password" + | "application.secret" + | "security.strict_limit" + | "security.strict_limit_window" + | "security.strict_limit_request_count" + | "security.limit" + | "security.limit_window" + | "security.limit_request_count" + | "security.trust_proxy"; + +export const envSettingsType: { + [key in EnvSettingString]: { + type: SettingType | SettingType[]; + default?: string | number | boolean; + }; +} = { + "database.type": { type: "string", default: "postgres" }, + "database.host": { type: "string" }, + "database.port": { type: "string", default: "5432" }, + "database.name": { type: "string" }, + "database.username": { type: "string" }, + "database.password": { type: "string" }, + "application.secret": { type: "string" }, + "security.strict_limit": { type: "boolean", default: true }, + "security.strict_limit_window": { type: "ms", default: "15m" }, + "security.strict_limit_request_count": { type: "number", default: 15 }, + "security.limit": { type: "boolean", default: true }, + "security.limit_window": { type: "ms", default: "1m" }, + "security.limit_request_count": { type: "number", default: 500 }, + "security.trust_proxy": { type: ["boolean", "number", "string"] }, +}; diff --git a/src/viewmodel/admin/club/newsletter/newsletter.models.ts b/src/viewmodel/admin/club/newsletter/newsletter.models.ts index 37bf30c..3a1c47f 100644 --- a/src/viewmodel/admin/club/newsletter/newsletter.models.ts +++ b/src/viewmodel/admin/club/newsletter/newsletter.models.ts @@ -1,5 +1,3 @@ -import { QueryStoreViewModel } from "../../configuration/queryStore.models"; - export interface NewsletterViewModel { id: number; title: string; @@ -9,5 +7,4 @@ export interface NewsletterViewModel { newsletterSignatur: string; isSent: boolean; recipientsByQueryId?: string; - recipientsByQuery?: QueryStoreViewModel; } diff --git a/src/viewmodel/admin/configuration/newsletterConfig.models.ts b/src/viewmodel/admin/configuration/newsletterConfig.models.ts index 3538bfe..6bb2041 100644 --- a/src/viewmodel/admin/configuration/newsletterConfig.models.ts +++ b/src/viewmodel/admin/configuration/newsletterConfig.models.ts @@ -1,8 +1,8 @@ -import { NewsletterConfigType } from "../../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../../enums/newsletterConfigEnum"; import { CommunicationTypeViewModel } from "./communicationType.models"; export interface NewsletterConfigViewModel { comTypeId: number; - config: NewsletterConfigType; + config: NewsletterConfigEnum; comType: CommunicationTypeViewModel; }