diff --git a/.env.example b/.env.example index 0d9bf9d..c1fe1dc 100644 --- a/.env.example +++ b/.env.example @@ -17,25 +17,10 @@ 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 901f487..9004e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^5.1.0", "express-rate-limit": "^7.5.0", @@ -19,6 +20,7 @@ "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", @@ -33,17 +35,21 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", "socket.io": "^4.7.5", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { "@types/cors": "^2.8.14", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", @@ -56,6 +62,7 @@ "@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" } @@ -83,6 +90,12 @@ "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", @@ -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", @@ -682,6 +1099,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", @@ -1348,6 +1772,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 +1803,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 +1969,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 +2017,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", @@ -2058,6 +2539,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 +2962,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 +3258,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", @@ -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" diff --git a/package.json b/package.json index 357a7de..bbda6c1 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,6 +26,7 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^5.1.0", "express-rate-limit": "^7.5.0", @@ -34,6 +36,7 @@ "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", @@ -48,17 +51,21 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", "socket.io": "^4.7.5", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { "@types/cors": "^2.8.14", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", @@ -71,6 +78,7 @@ "@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/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..1d50776 --- /dev/null +++ b/src/command/management/setting/settingCommandHandler.ts @@ -0,0 +1,53 @@ +import { dataSource } from "../../../data-source"; +import { setting } from "../../../entity/management/setting"; +import DatabaseActionException from "../../../exceptions/databaseActionException"; +import { StringHelper } from "../../../helpers/stringHelper"; +import { CreateOrUpdateSettingCommand, DeleteSettingCommand } from "./settingCommand"; + +export default abstract class SettingCommandHandler { + /** + * @description create setting + * @param {CreateOrUpdateSettingCommand} createSetting + * @returns {Promise} + */ + static async create(createSetting: CreateOrUpdateSettingCommand): Promise { + const token = StringHelper.random(32); + + 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 token; + }) + .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/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/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/inviteController.ts b/src/controller/inviteController.ts index 14e346c..a183cbc 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,8 @@ 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"; /** * @description get all invites @@ -38,7 +35,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 +56,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 +70,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 +89,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) => { 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..3592639 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,9 @@ 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"; /** * @description request totp reset @@ -31,7 +27,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 +39,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 +58,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) => { 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..8827dc9 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -2,12 +2,12 @@ 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 UserCommandHandler from "../command/management/user/userCommandHandler"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description get my by id @@ -33,7 +33,7 @@ export async function getMyTotp(req: Request, res: Response): Promise { let { secret } = await UserService.getById(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) => { diff --git a/src/data-source.ts b/src/data-source.ts index 2266f19..d35eaa6 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 { 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,7 @@ 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"; const dataSource = new DataSource({ @@ -104,6 +106,7 @@ const dataSource = new DataSource({ membershipView, webapi, webapiPermission, + setting, ], migrations: [ BackupAndResetDatabase1738166124200, @@ -112,6 +115,7 @@ const dataSource = new DataSource({ QueryToUUID1742922178643, NewsletterColumnType1744351418751, QueryUpdatedAt1744795756230, + SettingsFromEnv1745059495808, MemberCreatedAt1746006549262, ], migrationsRun: true, 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/env.defaults.ts b/src/env.defaults.ts index 739fd87..f7bc48d 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; @@ -67,28 +51,6 @@ export function configCheck() { 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/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 319a298..4534a30 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -4,9 +4,9 @@ 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"; export type BackupSection = | "member" @@ -18,7 +18,8 @@ export type BackupSection = | "query" | "template" | "user" - | "webapi"; + | "webapi" + | "settings"; export type BackupSectionRefered = { [key in BackupSection]?: Array; @@ -42,6 +43,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 +78,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 +106,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 +120,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 +223,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 []; } @@ -460,6 +465,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 +488,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 { @@ -810,4 +823,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..e6a79f9 --- /dev/null +++ b/src/helpers/codingHelper.ts @@ -0,0 +1,86 @@ +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) return fallback; + try { + return CodingHelper.decrypt(key, val) || fallback; + } catch (error) { + console.error("Decryption error:", error); + return fallback; + } + }, + to(val: string | null | undefined): string { + const valueToEncrypt = val || fallback; + if (valueToEncrypt === "") return ""; + + try { + return CodingHelper.encrypt(key, valueToEncrypt); + } catch (error) { + console.error("Encryption error:", error); + return ""; + } + }, + }; + } + + public static encrypt(phrase: string, content: string): string { + if (!content) return ""; + + // 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"); + } + + public static decrypt(phrase: string, content: string): 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) { + 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 32cdd16..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 { NewsletterConfigEnum } from "../enums/newsletterConfigEnum"; import InternalException from "../exceptions/internalException"; import EventEmitter from "events"; +import SettingHelper from "./settingsHelper"; export interface NewsletterEventType { kind: "pdf" | "mail"; @@ -179,7 +179,7 @@ export abstract class NewsletterHelper { pdfRecipients.unshift({ id: "0", firstname: "Alle Mitglieder", - lastname: CLUB_NAME, + lastname: SettingHelper.getSetting("club.name"), nameaffix: "", salutation: { salutation: "" }, } as member); @@ -221,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", @@ -286,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..b90cd07 --- /dev/null +++ b/src/helpers/settingsHelper.ts @@ -0,0 +1,284 @@ +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]; + } + + const baseType = + typeof settingType.type === "string" + ? (settingType.type.split("/")[0] as SettingTypeAtom) + : (settingType.type as SettingTypeAtom); + + return this.converters[baseType].fromString(rawValue) 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 = this.getSetting(key); + let finalValue = stringValue; + + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + finalValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); + } + + this.settings[key] = stringValue; + const [topic, settingKey] = key.split(".") as [SettingTopic, string]; + + await SettingCommandHandler.create({ + topic, + key: settingKey, + value: finalValue, + }); + + const newValue = this.getSetting(key); + 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/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts new file mode 100644 index 0000000..6028f1d --- /dev/null +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -0,0 +1,33 @@ +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); + + //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)); + await SettingHelper.setSetting("mail.secure", Boolean(process.env.MAIL_SECURE)); + await SettingHelper.setSetting("backup.interval", Number(process.env.BACKUP_INTERVAL)); + await SettingHelper.setSetting("backup.copies", Number(process.env.BACKUP_COPIES)); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable(setting_table.name, true, true, true); + } +} 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/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/invite.ts b/src/routes/invite.ts index 783ecef..5d8794e 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 }); 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/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/service/club/member/memberService.ts b/src/service/club/member/memberService.ts index 6480d58..5149114 100644 --- a/src/service/club/member/memberService.ts +++ b/src/service/club/member/memberService.ts @@ -1,9 +1,7 @@ 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"; 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/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"] }, +};