diff --git a/.github/workflows/dependency.yml b/.github/workflows/dependency.yml index 380f57f..b8d20dd 100644 --- a/.github/workflows/dependency.yml +++ b/.github/workflows/dependency.yml @@ -1,8 +1,5 @@ name: "Dependency Review" -on: - push: - branches: - - main +on: pull_request permissions: contents: read diff --git a/package-lock.json b/package-lock.json index b151914..bb7f874 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/auth": "^4.6.1", "@fastify/cookie": "^9.3.1", + "@fastify/rate-limit": "^9.1.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", "bcryptjs": "^2.4.3", @@ -20,286 +21,28 @@ "jose": "^5.2.3", "morgan": "^1.10.0", "mysql2": "^3.9.2", + "node-cron": "^3.0.3", "nodemailer": "^6.9.12", + "otp-generator": "^4.0.1", + "otplib": "^12.0.1", "sequelize": "^6.37.1", "sharp": "^0.33.2", + "ua-parser-js": "^1.0.37", "zod": "^3.22.4" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/node": "^20.11.19", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/otp-generator": "^4.0.2", + "@types/ua-parser-js": "^0.7.39", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" } }, - "node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/linux-x64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", @@ -316,102 +59,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -474,6 +121,16 @@ "fast-json-stringify": "^5.7.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-9.1.0.tgz", + "integrity": "sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.3.1" + } + }, "node_modules/@fastify/send": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", @@ -506,187 +163,32 @@ "@fastify/send": "^2.0.0", "content-disposition": "^0.5.3", "fastify-plugin": "^4.0.0", - "fastq": "^1.17.0", - "glob": "^10.3.4" - } - }, - "node_modules/@fastify/swagger": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.14.0.tgz", - "integrity": "sha512-sGiznEb3rl6pKGGUZ+JmfI7ct5cwbTQGo+IjewaTvtzfrshnryu4dZwEsjw0YHABpBA+kCz3kpRaHB7qpa67jg==", - "dependencies": { - "fastify-plugin": "^4.0.0", - "json-schema-resolver": "^2.0.0", - "openapi-types": "^12.0.0", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" - } - }, - "node_modules/@fastify/swagger-ui": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-3.0.0.tgz", - "integrity": "sha512-8P5OwHVv6QR4XSE6cW4fsENeMbW4yWWWj6Dz/5tvQN2pwNyTiSWxYpsY3+VP+uiZucNaDrAE2xm11rqytqAocA==", - "dependencies": { - "@fastify/static": "^7.0.0", - "fastify-plugin": "^4.0.0", - "openapi-types": "^12.0.2", - "rfdc": "^1.3.0", - "yaml": "^2.2.2" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", - "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", - "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.1" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", - "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "macos": ">=10.13", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", - "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "fastq": "^1.17.0", + "glob": "^10.3.4" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", - "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@fastify/swagger": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.14.0.tgz", + "integrity": "sha512-sGiznEb3rl6pKGGUZ+JmfI7ct5cwbTQGo+IjewaTvtzfrshnryu4dZwEsjw0YHABpBA+kCz3kpRaHB7qpa67jg==", + "dependencies": { + "fastify-plugin": "^4.0.0", + "json-schema-resolver": "^2.0.0", + "openapi-types": "^12.0.0", + "rfdc": "^1.3.0", + "yaml": "^2.2.2" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", - "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@fastify/swagger-ui": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-3.0.0.tgz", + "integrity": "sha512-8P5OwHVv6QR4XSE6cW4fsENeMbW4yWWWj6Dz/5tvQN2pwNyTiSWxYpsY3+VP+uiZucNaDrAE2xm11rqytqAocA==", + "dependencies": { + "@fastify/static": "^7.0.0", + "fastify-plugin": "^4.0.0", + "openapi-types": "^12.0.2", + "rfdc": "^1.3.0", + "yaml": "^2.2.2" } }, "node_modules/@img/sharp-libvips-linux-x64": { @@ -710,27 +212,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", - "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", @@ -752,81 +233,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", - "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", - "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.1" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", - "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.1" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.2", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", @@ -852,31 +258,6 @@ "@img/sharp-libvips-linux-x64": "1.0.1" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", - "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" - } - }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.33.2", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", @@ -902,69 +283,6 @@ "@img/sharp-libvips-linuxmusl-x64": "1.0.1" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", - "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/runtime": "^0.45.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", - "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", - "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.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", @@ -989,6 +307,48 @@ "node": ">=8" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1081,6 +441,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/nodemailer": { "version": "6.4.14", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", @@ -1090,6 +456,12 @@ "@types/node": "*" } }, + "node_modules/@types/otp-generator": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/otp-generator/-/otp-generator-4.0.2.tgz", + "integrity": "sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.7", "dev": true, @@ -1119,6 +491,12 @@ "@types/node": "*" } }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.11.1", "license": "MIT" @@ -1654,20 +1032,6 @@ "node": ">= 0.6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/generate-function": { "version": "2.3.1", "license": "MIT", @@ -2009,6 +1373,17 @@ "node": ">=12" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemailer": { "version": "6.9.12", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.12.tgz", @@ -2037,6 +1412,24 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/otp-generator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/otp-generator/-/otp-generator-4.0.1.tgz", + "integrity": "sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==", + "engines": { + "node": ">=14.10.0" + } + }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2591,6 +1984,14 @@ "node": ">=8" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/thread-stream": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", @@ -2600,9 +2001,9 @@ } }, "node_modules/toad-cache": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", - "integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", "engines": { "node": ">=12" } @@ -2618,12 +2019,6 @@ "version": "1.0.1", "license": "MIT" }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "optional": true - }, "node_modules/tsx": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz", @@ -2656,6 +2051,28 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", + "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 266c06c..c94fac4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/auth": "^4.6.1", "@fastify/cookie": "^9.3.1", + "@fastify/rate-limit": "^9.1.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", "bcryptjs": "^2.4.3", @@ -20,16 +21,23 @@ "jose": "^5.2.3", "morgan": "^1.10.0", "mysql2": "^3.9.2", + "node-cron": "^3.0.3", "nodemailer": "^6.9.12", + "otp-generator": "^4.0.1", + "otplib": "^12.0.1", "sequelize": "^6.37.1", "sharp": "^0.33.2", + "ua-parser-js": "^1.0.37", "zod": "^3.22.4" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/node": "^20.11.19", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.14", + "@types/otp-generator": "^4.0.2", + "@types/ua-parser-js": "^0.7.39", "prettier": "3.2.5", "tsx": "^4.7.1", "typescript": "^5.4.2" diff --git a/src/app.ts b/src/app.ts index fe7169a..897c45b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,29 @@ import * as dotenv from "dotenv"; -import fs from "fs"; - import Fastify from "fastify"; - import { + ZodTypeProvider, jsonSchemaTransform, serializerCompiler, validatorCompiler, } from "fastify-type-provider-zod"; -import { ensureValidEnv, env } from "./env"; -import Db from "./model/db"; - +import fs from "fs"; +import cron from "node-cron"; +import { isAdminLoggedIn } from "./auth/isAdminLoggedIn"; import { isApplicationLoggedIn } from "./auth/isApplicationLoggedIn"; +import { isEmailValidated } from "./auth/isEmailValidated"; import { isSessionLoggedIn } from "./auth/isSessionLoggedIn"; +import { ensureValidEnv, env } from "./env"; +import { ApiError } from "./errors/ApiError/ApiError"; import { BiotopeModel } from "./model/BiotopeModel"; +import { EmailValidationOTPModel } from "./model/EmailValidationOTPModel"; import { MeasurementTypeModel } from "./model/MeasurementTypeModel"; import { UserModel } from "./model/UserModel"; +import { UserSessionModel } from "./model/UserSessionModel"; +import Db from "./model/db"; +import { injectSchemaInRouteOption } from "./utils/routeOptionInjection"; +import { NotLoggedApiError } from "./errors/ApiError/NotLoggedApiError"; +import { EmailNotValidatedApiError } from "./errors/ApiError/EmailNotValidatedApiError"; +import { UserNotAdminApiError } from "./errors/ApiError/UserNotAdminApiError"; // - - - - - Environment variables - - - - - // if (fs.existsSync(".env")) { @@ -36,12 +44,12 @@ ensureValidEnv(); declare module "fastify" { export interface FastifyRequest { user?: UserModel; + session?: UserSessionModel; biotope?: BiotopeModel; measurementType?: MeasurementTypeModel; } } -// - - - - - Serveur Express - - - - - // (async () => { // - - - - - Database - - - - - // console.log("Connecting to database..."); @@ -107,6 +115,36 @@ declare module "fastify" { await fastify.register(import("@fastify/cookie")); + // - - - - - Rate limiting - - - - - // + await fastify.register(import("@fastify/rate-limit"), {}); + + // - - - - - Error handling - - - - - // + fastify + .withTypeProvider() + .setErrorHandler((error, request, reply) => { + const finalError = { + statusCode: 500, + error: "Internal Server Error", + code: "INTERNAL_SERVER_ERROR", + data: undefined as unknown, + }; + + if (error instanceof ApiError) { + finalError.statusCode = error.statusCode; + finalError.error = error.error; + finalError.code = error.code; + finalError.data = error.data; + } + + if (error.statusCode === 429) { + finalError.statusCode = 429; + finalError.error = "Too Many Requests"; + finalError.code = "TOO_MANY_REQUESTS"; + } + + return reply.status(finalError.statusCode).send(finalError); + }); + // - - - - - Routes - - - - - // await fastify.register(import("./routes/auth"), { prefix: "/auth", @@ -118,20 +156,69 @@ declare module "fastify" { instance.auth([isSessionLoggedIn, isApplicationLoggedIn]), ); - await fastify.register(import("./routes/users"), { - prefix: "/users", + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 401, + NotLoggedApiError.schema, + ); }); - await fastify.register(import("./routes/applications"), { - prefix: "/applications", + await fastify.register(import("./routes/users/me"), { + prefix: "/users/me", }); - await fastify.register(import("./routes/biotopes/aquariums"), { - prefix: "/aquariums", + instance.register(async (instance) => { + instance.addHook("preHandler", instance.auth([isEmailValidated])); + + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 403, + EmailNotValidatedApiError.schema, + ); + }); + + await fastify.register(import("./routes/applications"), { + prefix: "/applications", + }); + + await fastify.register(import("./routes/biotopes/aquariums"), { + prefix: "/aquariums", + }); + + await fastify.register(import("./routes/biotopes/terrariums"), { + prefix: "/terrariums", + }); + + instance.register( + async (instance) => { + instance.addHook( + "preHandler", + instance.auth([isAdminLoggedIn]), + ); + + instance.addHook("onRoute", (routeOptions) => { + injectSchemaInRouteOption( + routeOptions, + 403, + UserNotAdminApiError.schema, + ); + }); + + await fastify.register(import("./routes/admin/users"), { + prefix: "/users", + }); + }, + { prefix: "/admin" }, + ); }); + }); - await fastify.register(import("./routes/biotopes/terrariums"), { - prefix: "/terrariums", - }); + // - - - - - Setup cron jobs - - - - - // + // Every day at 00:00 + cron.schedule("0 0 * * *", () => { + EmailValidationOTPModel.destroyExpiredTokens(); + UserModel.destroyExpiredDeletedUsers(); }); })(); diff --git a/src/auth/isAdminLoggedIn.ts b/src/auth/isAdminLoggedIn.ts new file mode 100644 index 0000000..b9b4cad --- /dev/null +++ b/src/auth/isAdminLoggedIn.ts @@ -0,0 +1,13 @@ +import { FastifyAuthFunction } from "@fastify/auth"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; +import { UserNotAdminApiError } from "../errors/ApiError/UserNotAdminApiError"; + +export const isAdminLoggedIn = (async (req, res) => { + if (!req.user) { + throw new NotLoggedApiError(); + } + + if (!req.user.isAdmin) { + throw new UserNotAdminApiError(); + } +}) satisfies FastifyAuthFunction; diff --git a/src/auth/isApplicationLoggedIn.ts b/src/auth/isApplicationLoggedIn.ts index 963636f..0deeb1c 100644 --- a/src/auth/isApplicationLoggedIn.ts +++ b/src/auth/isApplicationLoggedIn.ts @@ -1,6 +1,6 @@ import { FastifyAuthFunction } from "@fastify/auth"; import { env } from "../env"; -import { NotLoggedError } from "../errors/NotLoggedError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; import * as jwt from "../jwt"; import { ApplicationModel } from "../model/ApplicationModel"; @@ -8,13 +8,13 @@ export const isApplicationLoggedIn = (async (req, res) => { const token = req.headers["x-api-key"] as string; if (!token) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } - const jwtUser = await jwt.verify(token, env.APPLICATION_TOKEN_SECRET!); + const jwtUser = await jwt.verify(token, env.APPLICATION_TOKEN_SECRET); if (!jwtUser.id) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } const application = await ApplicationModel.findOne({ @@ -26,8 +26,8 @@ export const isApplicationLoggedIn = (async (req, res) => { const user = await application?.getUserModel(); - if (!application || !user) { - throw new NotLoggedError(); + if (!application || !user || user.deleteAt) { + throw new NotLoggedApiError(); } req.user = user; diff --git a/src/auth/isEmailValidated.ts b/src/auth/isEmailValidated.ts new file mode 100644 index 0000000..db993f9 --- /dev/null +++ b/src/auth/isEmailValidated.ts @@ -0,0 +1,13 @@ +import { FastifyAuthFunction } from "@fastify/auth"; +import { EmailNotValidatedApiError } from "../errors/ApiError/EmailNotValidatedApiError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; + +export const isEmailValidated = (async (req, res) => { + if (!req.user) { + throw new NotLoggedApiError(); + } + + if (!req.user.verified) { + throw new EmailNotValidatedApiError(); + } +}) satisfies FastifyAuthFunction; diff --git a/src/auth/isSessionLoggedIn.ts b/src/auth/isSessionLoggedIn.ts index b34ad90..ea4590d 100644 --- a/src/auth/isSessionLoggedIn.ts +++ b/src/auth/isSessionLoggedIn.ts @@ -1,5 +1,5 @@ import { FastifyAuthFunction } from "@fastify/auth"; -import { NotLoggedError } from "../errors/NotLoggedError"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; import * as jwt from "../jwt"; import { UserSessionModel } from "../model/UserSessionModel"; import { env } from "../env"; @@ -8,7 +8,7 @@ export const isSessionLoggedIn = (async (req, res) => { const token = req.cookies["session-token"]; if (!token) { - throw new NotLoggedError(); + throw new NotLoggedApiError(); } const jwtUser = await jwt.verify(token, env.ACCESS_TOKEN_SECRET); @@ -22,8 +22,8 @@ export const isSessionLoggedIn = (async (req, res) => { const user = await session?.getUserModel(); - if (!session || !user) { - throw new NotLoggedError(); + if (!session || !user || user.deleteAt) { + throw new NotLoggedApiError(); } session.lastConnectionDate = new Date(); @@ -31,4 +31,5 @@ export const isSessionLoggedIn = (async (req, res) => { await session.save(); req.user = user; + req.session = session; }) satisfies FastifyAuthFunction; diff --git a/src/dto/admin/user/AdminUserCreateDto.ts b/src/dto/admin/user/AdminUserCreateDto.ts new file mode 100644 index 0000000..848b5c1 --- /dev/null +++ b/src/dto/admin/user/AdminUserCreateDto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const AdminUserCreateDtoSchema = z.object({ + username: z.string().min(3).max(50), + email: z.string().email(), + password: z.string().min(8).max(100), + isAdmin: z.boolean().optional(), +}); + +export type AdminUserCreateDto = z.infer; diff --git a/src/dto/admin/user/AdminUserDto.ts b/src/dto/admin/user/AdminUserDto.ts new file mode 100644 index 0000000..51ed234 --- /dev/null +++ b/src/dto/admin/user/AdminUserDto.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const AdminUserDtoSchema = z.object({ + id: z.string().uuid(), + username: z.string(), + email: z.string().email(), + verified: z.boolean(), + totpEnabled: z.boolean(), + isAdmin: z.boolean(), + deleteAt: z.date().nullable(), +}); + +export type AdminUserDto = z.infer; diff --git a/src/dto/admin/user/AdminUserUpdateDto.ts b/src/dto/admin/user/AdminUserUpdateDto.ts new file mode 100644 index 0000000..f5a2dd3 --- /dev/null +++ b/src/dto/admin/user/AdminUserUpdateDto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const AdminUserUpdateDtoSchema = z.object({ + username: z.string().min(3).max(50).optional(), + email: z.string().email().optional(), + password: z.string().min(8).max(100).optional(), + isAdmin: z.boolean().optional(), +}); + +export type AdminUserUpdateDto = z.infer; diff --git a/src/dto/user/userCreateDto.ts b/src/dto/user/userCreateDto.ts index bb67fd8..b5ba317 100644 --- a/src/dto/user/userCreateDto.ts +++ b/src/dto/user/userCreateDto.ts @@ -1,9 +1,9 @@ import { z } from "zod"; export const UserCreateDtoSchema = z.object({ - username: z.string(), + username: z.string().min(3).max(50), email: z.string().email(), - password: z.string(), + password: z.string().min(8).max(100), }); export type UserCreateDto = z.infer; diff --git a/src/dto/user/userDto.ts b/src/dto/user/userDto.ts index 195b6e3..3dcb1ad 100644 --- a/src/dto/user/userDto.ts +++ b/src/dto/user/userDto.ts @@ -4,6 +4,9 @@ export const UserDtoSchema = z.object({ id: z.string().uuid(), username: z.string(), email: z.string().email(), + verified: z.boolean(), + totpEnabled: z.boolean(), + isAdmin: z.boolean(), }); export type UserDto = z.infer; diff --git a/src/dto/userSession/userSessionDto.ts b/src/dto/userSession/userSessionDto.ts new file mode 100644 index 0000000..a20f99d --- /dev/null +++ b/src/dto/userSession/userSessionDto.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const UserSessionDtoSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + firstConnectionDate: z.date(), + lastConnectionDate: z.date(), + current: z.boolean().optional(), +}); + +export type UserSessionDto = z.infer; diff --git a/src/errors/ApiError/ApiError.ts b/src/errors/ApiError/ApiError.ts new file mode 100644 index 0000000..95a9413 --- /dev/null +++ b/src/errors/ApiError/ApiError.ts @@ -0,0 +1,19 @@ +export abstract class ApiError extends Error { + statusCode: number; + error: string; + code: string; + data?: unknown; + + constructor( + statusCode: number, + error: string, + code: string, + data?: unknown, + ) { + super(); + this.statusCode = statusCode; + this.error = error; + this.code = code; + this.data = data; + } +} diff --git a/src/errors/ApiError/CantDeleteItSelf.ts b/src/errors/ApiError/CantDeleteItSelf.ts new file mode 100644 index 0000000..3566eb4 --- /dev/null +++ b/src/errors/ApiError/CantDeleteItSelf.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class CantDeleteItSelfApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("CANT_DELETE_IT_SELF"), + }); + + constructor() { + super(400, "Bad Request", "CANT_DELETE_IT_SELF"); + } +} diff --git a/src/errors/ApiError/EmailAlreadyExistApiError.ts b/src/errors/ApiError/EmailAlreadyExistApiError.ts new file mode 100644 index 0000000..d9ad0e3 --- /dev/null +++ b/src/errors/ApiError/EmailAlreadyExistApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailAlreadyExistApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(409), + error: z.literal("Conflict"), + code: z.literal("EMAIL_ALREADY_EXIST"), + }); + + constructor() { + super(409, "Conflict", "EMAIL_ALREADY_EXIST"); + } +} diff --git a/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts b/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts new file mode 100644 index 0000000..5c04f03 --- /dev/null +++ b/src/errors/ApiError/EmailAlreadyVerifiedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailAlreadyVerifiedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("EMAIL_ALREADY_VERIFIED"), + }); + + constructor() { + super(400, "Bad Request", "EMAIL_ALREADY_VERIFIED"); + } +} diff --git a/src/errors/ApiError/EmailNotValidatedApiError.ts b/src/errors/ApiError/EmailNotValidatedApiError.ts new file mode 100644 index 0000000..4b5846e --- /dev/null +++ b/src/errors/ApiError/EmailNotValidatedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class EmailNotValidatedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("EMAIL_NOT_VALIDATED"), + }); + + constructor() { + super(403, "Forbidden", "EMAIL_NOT_VALIDATED"); + } +} diff --git a/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts b/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..913ed26 --- /dev/null +++ b/src/errors/ApiError/ExpiredEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class ExpiredEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("EXPIRED_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "EXPIRED_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts b/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..0b08dfe --- /dev/null +++ b/src/errors/ApiError/InvalidEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class InvalidEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("INVALID_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "INVALID_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/NoEmailVerificationCodeApiError.ts b/src/errors/ApiError/NoEmailVerificationCodeApiError.ts new file mode 100644 index 0000000..f601ee5 --- /dev/null +++ b/src/errors/ApiError/NoEmailVerificationCodeApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NoEmailVerificationCodeApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("NO_EMAIL_VERIFICATION_CODE"), + }); + + constructor() { + super(403, "Forbidden", "NO_EMAIL_VERIFICATION_CODE"); + } +} diff --git a/src/errors/ApiError/NoTOTPSecretApiError.ts b/src/errors/ApiError/NoTOTPSecretApiError.ts new file mode 100644 index 0000000..0c3be93 --- /dev/null +++ b/src/errors/ApiError/NoTOTPSecretApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NoTOTPSecretApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("NO_TOTP_SECRET"), + }); + + constructor() { + super(400, "Bad Request", "NO_TOTP_SECRET"); + } +} diff --git a/src/errors/ApiError/NotLoggedApiError.ts b/src/errors/ApiError/NotLoggedApiError.ts new file mode 100644 index 0000000..89c1bda --- /dev/null +++ b/src/errors/ApiError/NotLoggedApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NotLoggedApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(401), + error: z.literal("Unauthorized"), + code: z.literal("NOT_LOGGED"), + }); + + constructor() { + super(401, "Unauthorized", "NOT_LOGGED"); + } +} diff --git a/src/errors/ApiError/NotSessionLoggerUserApiError.ts b/src/errors/ApiError/NotSessionLoggerUserApiError.ts new file mode 100644 index 0000000..46a42cf --- /dev/null +++ b/src/errors/ApiError/NotSessionLoggerUserApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class NotSessionLoggerUserApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("NOT_SESSION_LOGGER_USER"), + }); + + constructor() { + super(403, "Forbidden", "NOT_SESSION_LOGGER_USER"); + } +} diff --git a/src/errors/ApiError/OTPRequiredApiError.ts b/src/errors/ApiError/OTPRequiredApiError.ts new file mode 100644 index 0000000..808ee8c --- /dev/null +++ b/src/errors/ApiError/OTPRequiredApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class OTPRequiredApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("OTP_REQUIRED"), + }); + + constructor() { + super(403, "Forbidden", "OTP_REQUIRED"); + } +} diff --git a/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts b/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts new file mode 100644 index 0000000..59a129c --- /dev/null +++ b/src/errors/ApiError/TOTPAlreadyEnabledApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class TOTPAlreadyEnabledApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("TOTP_ALREADY_ENABLED"), + }); + + constructor() { + super(400, "Bad Request", "TOTP_ALREADY_ENABLED"); + } +} diff --git a/src/errors/ApiError/TOTPNotEnabledApiError.ts b/src/errors/ApiError/TOTPNotEnabledApiError.ts new file mode 100644 index 0000000..28f8137 --- /dev/null +++ b/src/errors/ApiError/TOTPNotEnabledApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class TOTPNotEnabledApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(400), + error: z.literal("Bad Request"), + code: z.literal("TOTP_NOT_ENABLED"), + }); + + constructor() { + super(400, "Bad Request", "TOTP_NOT_ENABLED"); + } +} diff --git a/src/errors/ApiError/UserNotAdminApiError.ts b/src/errors/ApiError/UserNotAdminApiError.ts new file mode 100644 index 0000000..8f0913e --- /dev/null +++ b/src/errors/ApiError/UserNotAdminApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserNotAdminApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("USER_NOT_ADMIN"), + }); + + constructor() { + super(403, "Forbidden", "USER_NOT_ADMIN"); + } +} diff --git a/src/errors/ApiError/UserNotFoundApiError.ts b/src/errors/ApiError/UserNotFoundApiError.ts new file mode 100644 index 0000000..fe8f687 --- /dev/null +++ b/src/errors/ApiError/UserNotFoundApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserNotFoundApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(404), + error: z.literal("Not Found"), + code: z.literal("USER_NOT_FOUND"), + }); + + constructor() { + super(404, "Not Found", "USER_NOT_FOUND"); + } +} diff --git a/src/errors/ApiError/UserSessionNotFoundApiError.ts b/src/errors/ApiError/UserSessionNotFoundApiError.ts new file mode 100644 index 0000000..ab266fb --- /dev/null +++ b/src/errors/ApiError/UserSessionNotFoundApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UserSessionNotFoundApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(404), + error: z.literal("Not Found"), + code: z.literal("USER_SESSION_NOT_FOUND"), + }); + + constructor() { + super(404, "Not Found", "USER_SESSION_NOT_FOUND"); + } +} diff --git a/src/errors/ApiError/UsernameAlreadyExistApiError.ts b/src/errors/ApiError/UsernameAlreadyExistApiError.ts new file mode 100644 index 0000000..be9a72a --- /dev/null +++ b/src/errors/ApiError/UsernameAlreadyExistApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class UsernameAlreadyExistApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(409), + error: z.literal("Conflict"), + code: z.literal("USERNAME_ALREADY_EXIST"), + }); + + constructor() { + super(409, "Conflict", "USERNAME_ALREADY_EXIST"); + } +} diff --git a/src/errors/ApiError/WrongOTPApiError.ts b/src/errors/ApiError/WrongOTPApiError.ts new file mode 100644 index 0000000..51ae994 --- /dev/null +++ b/src/errors/ApiError/WrongOTPApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class WrongOTPApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("WRONG_OTP"), + }); + + constructor() { + super(403, "Forbidden", "WRONG_OTP"); + } +} diff --git a/src/errors/ApiError/WrongPasswordApiError.ts b/src/errors/ApiError/WrongPasswordApiError.ts new file mode 100644 index 0000000..fcebe7e --- /dev/null +++ b/src/errors/ApiError/WrongPasswordApiError.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { ApiError } from "./ApiError"; + +export class WrongPasswordApiError extends ApiError { + static readonly schema = z.object({ + statusCode: z.literal(403), + error: z.literal("Forbidden"), + code: z.literal("WRONG_PASSWORD"), + }); + + constructor() { + super(403, "Forbidden", "WRONG_PASSWORD"); + } +} diff --git a/src/errors/BadRequestError.ts b/src/errors/BadRequestError.ts deleted file mode 100644 index f841f3b..0000000 --- a/src/errors/BadRequestError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class BadRequestError extends Error { - constructor(message?: string) { - super(message); - } -} diff --git a/src/errors/EmailAlreadyExistError.ts b/src/errors/EmailAlreadyExistError.ts deleted file mode 100644 index e2f5eb0..0000000 --- a/src/errors/EmailAlreadyExistError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class EmailAlreadyExistError extends Error {} diff --git a/src/errors/NotFoundError.ts b/src/errors/NotFoundError.ts deleted file mode 100644 index 1f587e5..0000000 --- a/src/errors/NotFoundError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class NotFoundError extends Error {} diff --git a/src/errors/NotLoggedError.ts b/src/errors/NotLoggedError.ts deleted file mode 100644 index 6b44c73..0000000 --- a/src/errors/NotLoggedError.ts +++ /dev/null @@ -1 +0,0 @@ -export class NotLoggedError extends Error {} diff --git a/src/errors/UsernameAlreadyExistError.ts b/src/errors/UsernameAlreadyExistError.ts deleted file mode 100644 index 2ca27e7..0000000 --- a/src/errors/UsernameAlreadyExistError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class UsernameAlreadyExistError extends Error {} diff --git a/src/errors/WrongPasswordError.ts b/src/errors/WrongPasswordError.ts deleted file mode 100644 index 89d13ae..0000000 --- a/src/errors/WrongPasswordError.ts +++ /dev/null @@ -1 +0,0 @@ -export default class WrongPasswordError extends Error {} diff --git a/src/model/EmailValidationOTPModel.ts b/src/model/EmailValidationOTPModel.ts new file mode 100644 index 0000000..b5ae0f9 --- /dev/null +++ b/src/model/EmailValidationOTPModel.ts @@ -0,0 +1,20 @@ +import { InferAttributes, InferCreationAttributes, Model, Op } from "sequelize"; + +export class EmailValidationOTPModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare email: string; + declare code: string; + declare expiresAt: Date; + + static destroyExpiredTokens() { + return EmailValidationOTPModel.destroy({ + where: { + expiresAt: { + [Op.lt]: new Date(), + }, + }, + }); + } +} diff --git a/src/model/UserModel.ts b/src/model/UserModel.ts index 4203044..09a940d 100644 --- a/src/model/UserModel.ts +++ b/src/model/UserModel.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcryptjs"; import { CreationOptional, HasManyCreateAssociationMixin, @@ -5,9 +6,15 @@ import { InferAttributes, InferCreationAttributes, Model, + Op, } from "sequelize"; import { ApplicationModel } from "./ApplicationModel"; import { BiotopeModel } from "./BiotopeModel"; +import { UserSessionModel } from "./UserSessionModel"; +import { authenticator } from "otplib"; +import { WrongPasswordApiError } from "../errors/ApiError/WrongPasswordApiError"; +import { OTPRequiredApiError } from "../errors/ApiError/OTPRequiredApiError"; +import { WrongOTPApiError } from "../errors/ApiError/WrongOTPApiError"; export class UserModel extends Model< InferAttributes, @@ -17,10 +24,60 @@ export class UserModel extends Model< declare username: string; declare email: string; declare password: string; + declare verified: CreationOptional; + declare totpEnabled: CreationOptional; + declare totpSecret?: CreationOptional; + declare deleteAt?: CreationOptional; + declare isAdmin: CreationOptional; declare getBiotopeModels: HasManyGetAssociationsMixin; declare createBiotopeModel: HasManyCreateAssociationMixin; declare getApplicationModels: HasManyGetAssociationsMixin; declare createApplicationModel: HasManyCreateAssociationMixin; + + declare getUserSessionModels: HasManyGetAssociationsMixin; + + async checkPassword(password: string): Promise { + const isPasswordValid = await bcrypt.compare(password, this.password); + if (!isPasswordValid) { + throw new WrongPasswordApiError(); + } + } + + checkOTP(otp?: string): void { + if (!this.totpEnabled) { + return; + } + + if (!this.totpSecret) { + throw new Error("TOTP secret not found"); + } + + if (!otp) { + throw new OTPRequiredApiError(); + } + + if (!authenticator.verify({ token: otp, secret: this.totpSecret })) { + throw new WrongOTPApiError(); + } + } + + destroyAllSessions() { + return UserSessionModel.destroy({ + where: { + userId: this.id, + }, + }); + } + + static destroyExpiredDeletedUsers() { + return UserModel.destroy({ + where: { + deleteAt: { + [Op.lt]: new Date(), + }, + }, + }); + } } diff --git a/src/model/db.ts b/src/model/db.ts index c05c369..fdba2c7 100644 --- a/src/model/db.ts +++ b/src/model/db.ts @@ -9,6 +9,7 @@ import { MeasurementTypeModel } from "./MeasurementTypeModel"; import { UserModel } from "./UserModel"; import { UserSessionModel } from "./UserSessionModel"; import { TerrariumModel } from "./TerrariumModel"; +import { EmailValidationOTPModel } from "./EmailValidationOTPModel"; export default class Db { private static sequelize: Sequelize; @@ -22,6 +23,7 @@ export default class Db { dialect: "mysql", host: env.MARIADB_HOST, port: env.MARIADB_PORT, + logging: false, }, ); await Db.sequelize.authenticate(); @@ -50,10 +52,56 @@ export default class Db { type: DataTypes.STRING, allowNull: false, }, + verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + totpEnabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + totpSecret: { + type: DataTypes.STRING, + }, + deleteAt: { + type: DataTypes.DATE, + }, + isAdmin: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, }, { sequelize, tableName: "users" }, ); + EmailValidationOTPModel.init( + { + email: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + references: { + model: UserModel, + key: "email", + }, + }, + code: { + type: DataTypes.STRING, + allowNull: false, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, tableName: "email_validation_otp" }, + ); + UserModel.hasOne(EmailValidationOTPModel, { foreignKey: "email" }); + EmailValidationOTPModel.belongsTo(UserModel, { foreignKey: "email" }); + UserSessionModel.init( { id: { diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts new file mode 100644 index 0000000..b092216 --- /dev/null +++ b/src/routes/admin/users.ts @@ -0,0 +1,246 @@ +import bcrypt from "bcryptjs"; +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import { z } from "zod"; +import { AdminUserCreateDtoSchema } from "../../dto/admin/user/AdminUserCreateDto"; +import { AdminUserDtoSchema } from "../../dto/admin/user/AdminUserDto"; +import { AdminUserUpdateDtoSchema } from "../../dto/admin/user/AdminUserUpdateDto"; +import { CantDeleteItSelfApiError } from "../../errors/ApiError/CantDeleteItSelf"; +import { EmailAlreadyExistApiError } from "../../errors/ApiError/EmailAlreadyExistApiError"; +import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; +import { UserNotFoundApiError } from "../../errors/ApiError/UserNotFoundApiError"; +import { UsernameAlreadyExistApiError } from "../../errors/ApiError/UsernameAlreadyExistApiError"; +import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; +import { UserModel } from "../../model/UserModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.get( + "/", + { + schema: { + tags: ["admin", "users"], + description: "Get all users", + response: { + 200: AdminUserDtoSchema.array(), + }, + }, + }, + async function () { + const users = await UserModel.findAll(); + + return users.map((user) => AdminUserDtoSchema.parse(user)); + }, + ); + + instance.get( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: "Get a user", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 200: AdminUserDtoSchema, + 404: UserNotFoundApiError.schema, + }, + }, + }, + async function (req, res) { + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + return AdminUserDtoSchema.parse(user); + }, + ); + + instance.delete( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: + "Delete a user. The data will be definitely lost after 30 days.", + params: z.object({ + id: z.string().uuid(), + }), + body: z.object({ + password: z.string(), + otp: z.string().length(6).optional(), + }), + response: { + 204: z.void(), + 400: CantDeleteItSelfApiError.schema, + 404: UserNotFoundApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const connectedUser = req.user!; + + await connectedUser.checkPassword(req.body.password); + connectedUser.checkOTP(req.body.otp); + + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + if (user === connectedUser) { + throw new CantDeleteItSelfApiError(); + } + + user.deleteAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + user.destroyAllSessions(); + + await user.save(); + + return res.status(204).send(); + }, + ); + + instance.post( + "/", + { + schema: { + tags: ["admin", "users"], + description: "Create a user", + body: AdminUserCreateDtoSchema, + response: { + 201: AdminUserDtoSchema, + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (emailExists) { + throw new EmailAlreadyExistApiError(); + } + + if (usernameExists) { + throw new UsernameAlreadyExistApiError(); + } + + const hashPassword = await bcrypt.hash(req.body.password, 10); + + const user = await UserModel.create({ + username: req.body.username, + email: req.body.email, + password: hashPassword, + verified: true, + }); + + res.status(201).send(AdminUserDtoSchema.parse(user)); + }, + ); + + instance.patch( + "/:id", + { + schema: { + tags: ["admin", "users"], + description: "Update a user", + params: z.object({ + id: z.string().uuid(), + }), + body: AdminUserUpdateDtoSchema, + response: { + 200: AdminUserDtoSchema, + 404: UserNotFoundApiError.schema, + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const user = await UserModel.findOne({ + where: { + id: req.params.id, + }, + }); + + if (!user) { + throw new UserNotFoundApiError(); + } + + if (req.body.email && req.body.email !== user.email) { + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + if (emailExists) { + throw new EmailAlreadyExistApiError(); + } + + user.email = req.body.email; + } + + if (req.body.username && req.body.username !== user.username) { + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (usernameExists) { + throw new UsernameAlreadyExistApiError(); + } + + user.username = req.body.username; + } + + if (req.body.password) { + const hashPassword = await bcrypt.hash(req.body.password, 10); + user.password = hashPassword; + } + + if (req.body.isAdmin && req.body.isAdmin !== user.isAdmin) { + user.isAdmin = req.body.isAdmin; + } + + await user.save(); + + return AdminUserDtoSchema.parse(user); + }, + ); +}) satisfies FastifyPluginAsync; diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 66f3f4a..07ae3e2 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,15 +1,22 @@ -import { FastifyPluginAsync } from "fastify"; -import { UserCreateDtoSchema } from "../dto/user/userCreateDto"; -import { UserDtoSchema } from "../dto/user/userDto"; -import { UserModel } from "../model/UserModel"; - import bcrypt from "bcryptjs"; +import { FastifyPluginAsync } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; import MailSender from "../agents/MailSender"; +import { UserCreateDtoSchema } from "../dto/user/userCreateDto"; +import { UserDtoSchema } from "../dto/user/userDto"; +import { env } from "../env"; +import { EmailAlreadyExistApiError } from "../errors/ApiError/EmailAlreadyExistApiError"; +import { OTPRequiredApiError } from "../errors/ApiError/OTPRequiredApiError"; +import { UserNotFoundApiError } from "../errors/ApiError/UserNotFoundApiError"; +import { UsernameAlreadyExistApiError } from "../errors/ApiError/UsernameAlreadyExistApiError"; +import { WrongOTPApiError } from "../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../errors/ApiError/WrongPasswordApiError"; +import { UserModel } from "../model/UserModel"; import { UserSessionModel } from "../model/UserSessionModel"; import UserTokenUtil from "../utils/UserTokenUtil"; -import { env } from "../env"; +import { NotLoggedApiError } from "../errors/ApiError/NotLoggedApiError"; +import { UAParser } from "ua-parser-js"; export default (async (fastify) => { const instance = fastify.withTypeProvider(); @@ -23,6 +30,16 @@ export default (async (fastify) => { body: UserCreateDtoSchema, response: { 201: UserDtoSchema, + 409: z.union([ + UsernameAlreadyExistApiError.schema, + EmailAlreadyExistApiError.schema, + ]), + }, + }, + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", }, }, }, @@ -31,6 +48,26 @@ export default (async (fastify) => { return res.status(403).send(); } + const emailExists = await UserModel.findOne({ + where: { + email: req.body.email, + }, + }); + + if (emailExists) { + throw new EmailAlreadyExistApiError(); + } + + const usernameExists = await UserModel.findOne({ + where: { + username: req.body.username, + }, + }); + + if (usernameExists) { + throw new UsernameAlreadyExistApiError(); + } + const hashPassword = await bcrypt.hash(req.body.password, 10); const user = await UserModel.create({ @@ -53,28 +90,39 @@ export default (async (fastify) => { body: z.object({ email: z.string().email(), password: z.string(), + otp: z.string().length(6).optional(), }), + response: { + 200: UserDtoSchema, + 404: UserNotFoundApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, + }, + config: { + rateLimit: { + max: 10, + timeWindow: "5 minute", + }, }, }, async function (req, res) { - console.log(req.headers["user-agent"]); const user = await UserModel.findOne({ where: { email: req.body.email, + deleteAt: null, }, }); if (!user) { - return res.status(404).send(); + throw new UserNotFoundApiError(); } - const isPasswordValid = await bcrypt.compare( - req.body.password, - user.password, - ); - if (!isPasswordValid) { - return res.status(403).send(); - } + await user.checkPassword(req.body.password); + user.checkOTP(req.body.otp); const userDto = UserDtoSchema.parse(user); @@ -86,8 +134,16 @@ export default (async (fastify) => { httpOnly: true, }); + // Get User Agent + const ua = new UAParser(req.headers["user-agent"]); + const browser = ua.getBrowser(); + const os = ua.getOS(); + UserSessionModel.create({ - name: "Unknown device", + name: + browser.name || os.name + ? `${browser.name ?? "Unknown"} - ${os.name ?? "Unknown"}` + : "Unknown", userId: userDto.id, token: token, }); @@ -110,6 +166,7 @@ export default (async (fastify) => { description: "Logout a user and delete his session", response: { 204: z.void(), + 401: NotLoggedApiError.schema, }, }, }, @@ -117,7 +174,7 @@ export default (async (fastify) => { const token = req.cookies["session-token"]; if (!token) { - return res.status(401).send(); + throw new NotLoggedApiError(); } const session = await UserSessionModel.findOne({ @@ -127,7 +184,7 @@ export default (async (fastify) => { }); if (!session) { - return res.status(401).send(); + throw new NotLoggedApiError(); } await session.destroy(); diff --git a/src/routes/users.ts b/src/routes/users.ts deleted file mode 100644 index 5d667e0..0000000 --- a/src/routes/users.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { FastifyPluginAsync } from "fastify"; -import { UserDtoSchema } from "../dto/user/userDto"; -import { UserModel } from "../model/UserModel"; - -import { ZodTypeProvider } from "fastify-type-provider-zod"; -import { z } from "zod"; - -export default (async (fastify) => { - const instance = fastify.withTypeProvider(); - - // TODO: to move in a admin dedicated route - // instance.get( - // "/", - // { - // schema: { - // tags: ["users"], - // description: "Get all users", - // response: { - // 200: UserDtoSchema.array(), - // }, - // }, - // }, - // async function () { - // const users = await UserModel.findAll(); - - // return users.map((user) => UserDtoSchema.parse(user)); - // }, - // ); - - instance.get( - "/:id", - { - schema: { - tags: ["users"], - description: "Get a user", - params: z.object({ - id: z.string().uuid(), - }), - response: { - 200: UserDtoSchema, - }, - }, - }, - async function (req, res) { - const user = await UserModel.findOne({ - where: { - id: req.params.id, - }, - }); - - if (!user) { - return res.status(404); - } - - return UserDtoSchema.parse(user); - }, - ); - - instance.delete( - "/:id", - { - schema: { - tags: ["users"], - description: "Delete a user", - params: z.object({ - id: z.string().uuid(), - }), - }, - }, - async function (req, res) { - const user = await UserModel.findOne({ - where: { - id: req.params.id, - }, - }); - - if (!user) { - return res.status(404); - } - - await user.destroy(); - - return res.status(204).send(); - }, - ); - - instance.get( - "/me", - { - schema: { - tags: ["users"], - description: "Get the current user", - response: { - 200: UserDtoSchema, - }, - }, - }, - async function (req, res) { - return UserDtoSchema.parse(req.user); - }, - ); -}) satisfies FastifyPluginAsync; diff --git a/src/routes/users/me.ts b/src/routes/users/me.ts new file mode 100644 index 0000000..756dc9e --- /dev/null +++ b/src/routes/users/me.ts @@ -0,0 +1,302 @@ +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import otpGenerator from "otp-generator"; +import { authenticator } from "otplib"; +import { z } from "zod"; +import MailSender from "../../agents/MailSender"; +import { UserDtoSchema } from "../../dto/user/userDto"; +import { EmailAlreadyVerifiedApiError } from "../../errors/ApiError/EmailAlreadyVerifiedApiError"; +import { ExpiredEmailVerificationCodeApiError } from "../../errors/ApiError/ExpiredEmailVerificationCodeApiError"; +import { InvalidEmailVerificationCodeApiError } from "../../errors/ApiError/InvalidEmailVerificationCodeApiError"; +import { NoEmailVerificationCodeApiError } from "../../errors/ApiError/NoEmailVerificationCodeApiError"; +import { NoTOTPSecretApiError } from "../../errors/ApiError/NoTOTPSecretApiError"; +import { OTPRequiredApiError } from "../../errors/ApiError/OTPRequiredApiError"; +import { TOTPAlreadyEnabledApiError } from "../../errors/ApiError/TOTPAlreadyEnabledApiError"; +import { TOTPNotEnabledApiError } from "../../errors/ApiError/TOTPNotEnabledApiError"; +import { WrongOTPApiError } from "../../errors/ApiError/WrongOTPApiError"; +import { WrongPasswordApiError } from "../../errors/ApiError/WrongPasswordApiError"; +import { EmailValidationOTPModel } from "../../model/EmailValidationOTPModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.post( + "/verify-email/send-code", + { + schema: { + tags: ["users"], + description: + "Send a code to the connected user's email to verify it. The code is valid for 5 minutes.", + response: { + 200: z.void(), + 409: EmailAlreadyVerifiedApiError.schema, + }, + }, + }, + async function (req, res) { + const userEmail = req.user!.email; + + if (req.user!.verified) { + throw new EmailAlreadyVerifiedApiError(); + } + + let oldEmailToken = await EmailValidationOTPModel.findOne({ + where: { + email: userEmail, + }, + }); + + if (oldEmailToken) { + oldEmailToken.destroy(); + } + + const emailToken = await EmailValidationOTPModel.create({ + email: userEmail, + code: otpGenerator.generate(6, { + digits: true, + lowerCaseAlphabets: false, + upperCaseAlphabets: false, + specialChars: false, + }), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + MailSender.send( + userEmail, + "Validation de votre adresse email", + `Bonjour,\n\nVoici votre code de validation: ${emailToken.code}\n\nCe code est valable 5 minutes.`, + ); + + return res.status(200).send(); + }, + ); + + instance.post( + "/verify-email/verify-code", + { + schema: { + tags: ["users"], + description: `Verify the user's email with the code sent. Use ${instance.prefix}/verify-email/send-code to send a code.`, + body: z.object({ + code: z.string().length(6), + }), + response: { + 200: z.void(), + 403: z.union([ + NoEmailVerificationCodeApiError.schema, + ExpiredEmailVerificationCodeApiError.schema, + InvalidEmailVerificationCodeApiError.schema, + ]), + 409: EmailAlreadyVerifiedApiError.schema, + }, + }, + }, + async function (req, res) { + if (req.user!.verified) { + throw new EmailAlreadyVerifiedApiError(); + } + + let emailToken = await EmailValidationOTPModel.findOne({ + where: { + email: req.user!.email, + }, + }); + + if (!emailToken) { + throw new NoEmailVerificationCodeApiError(); + } else if (emailToken.expiresAt < new Date()) { + await emailToken.destroy(); + throw new ExpiredEmailVerificationCodeApiError(); + } else if (emailToken.code !== req.body.code) { + throw new InvalidEmailVerificationCodeApiError(); + } + + req.user!.verified = true; + await req.user!.save(); + + await emailToken.destroy(); + + return res.status(200).send(); + }, + ); + + instance.post( + "/totp/enable", + { + schema: { + tags: ["users"], + description: `Enable TOTP for the connected user. Will need verify with ${instance.prefix}/totp/enable/verify.`, + body: z.object({ + password: z.string(), + }), + response: { + 200: z.object({ + otpUri: z.string(), + }), + 400: TOTPAlreadyEnabledApiError.schema, + 403: WrongPasswordApiError.schema, + }, + }, + }, + async function (req, res) { + if (req.user!.totpEnabled) { + throw new TOTPAlreadyEnabledApiError(); + } + + await req.user!.checkPassword(req.body.password); + + const secret = authenticator.generateSecret(); + + req.user!.totpSecret = secret; + + await req.user!.save(); + + return res.status(200).send({ + otpUri: authenticator.keyuri( + req.user!.username, + "Aquatracking", + secret, + ), + }); + }, + ); + + instance.post( + "/totp/enable/verify", + { + schema: { + tags: ["users"], + description: `Verify the TOTP code for the connected user. Use ${instance.prefix}/totp/enable to enable TOTP.`, + body: z.object({ + otp: z.string().length(6), + }), + response: { + 200: z.void(), + 400: z.union([ + NoTOTPSecretApiError.schema, + TOTPAlreadyEnabledApiError.schema, + ]), + 403: WrongOTPApiError.schema, + }, + }, + }, + async function (req, res) { + if (!req.user!.totpSecret) { + throw new NoTOTPSecretApiError(); + } + + if (req.user!.totpEnabled) { + throw new TOTPAlreadyEnabledApiError(); + } + + const verified = authenticator.verify({ + token: req.body.otp, + secret: req.user!.totpSecret, + }); + + if (!verified) { + throw new WrongOTPApiError(); + } + + req.user!.totpEnabled = true; + + await req.user!.save(); + + return res.status(200).send(); + }, + ); + + instance.post( + "/totp/disable", + { + schema: { + tags: ["users"], + description: `Disable TOTP for the connected user.`, + body: z.object({ + password: z.string(), + otp: z.string().length(6), + }), + response: { + 200: z.void(), + 400: TOTPNotEnabledApiError.schema, + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + if (!req.user!.totpEnabled) { + throw new TOTPNotEnabledApiError(); + } + + await req.user!.checkPassword(req.body.password); + req.user!.checkOTP(req.body.otp); + + req.user!.totpSecret = null; + req.user!.totpEnabled = false; + + await req.user!.save(); + + return res.status(200).send(); + }, + ); + + instance.delete( + "/", + { + schema: { + tags: ["users"], + description: + "Delete the connected user. The data will be definitely lost after 30 days.", + body: z.object({ + password: z.string(), + otp: z.string().length(6).optional(), + }), + response: { + 204: z.void(), + 401: z.union([z.string(), z.number()]), + 403: z.union([ + WrongPasswordApiError.schema, + WrongOTPApiError.schema, + OTPRequiredApiError.schema, + ]), + }, + }, + }, + async function (req, res) { + const user = req.user!; + + await user.checkPassword(req.body.password); + user.checkOTP(req.body.otp); + + user.deleteAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + user.destroyAllSessions(); + + await user.save(); + + return res.status(204).send(); + }, + ); + + instance.get( + "/", + { + schema: { + tags: ["users"], + description: "Get the current user", + response: { + 200: UserDtoSchema, + }, + }, + }, + async function (req, res) { + return UserDtoSchema.parse(req.user); + }, + ); + + instance.register(import("./me/session"), { + prefix: "/session", + }); +}) satisfies FastifyPluginAsync; diff --git a/src/routes/users/me/session.ts b/src/routes/users/me/session.ts new file mode 100644 index 0000000..b3140a4 --- /dev/null +++ b/src/routes/users/me/session.ts @@ -0,0 +1,145 @@ +import { FastifyPluginAsync } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import { z } from "zod"; +import { UserSessionDtoSchema } from "../../../dto/userSession/userSessionDto"; +import { NotSessionLoggerUserApiError } from "../../../errors/ApiError/NotSessionLoggerUserApiError"; +import { UserSessionNotFoundApiError } from "../../../errors/ApiError/UserSessionNotFoundApiError"; +import { UserSessionModel } from "../../../model/UserSessionModel"; + +export default (async (fastify) => { + const instance = fastify.withTypeProvider(); + + instance.get( + "/", + { + schema: { + tags: ["users", "sessions"], + description: "Get the current user's sessions.", + response: { + 200: UserSessionDtoSchema.array(), + }, + }, + }, + async function (req) { + const sessions = await req.user!.getUserSessionModels(); + + const parsedSessions = sessions.map((session) => { + const parsed = UserSessionDtoSchema.parse(session); + parsed.current = session.id === req.session?.id; + + return parsed; + }); + + return parsedSessions; + }, + ); + + instance.get( + "/:id", + { + schema: { + tags: ["users", "sessions"], + description: "Get a session.", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 200: UserSessionDtoSchema, + 404: UserSessionNotFoundApiError.schema, + }, + }, + }, + async function (req) { + const session = await UserSessionModel.findOne({ + where: { + id: req.params.id, + userId: req.user!.id, + }, + }); + + if (!session) { + throw new UserSessionNotFoundApiError(); + } + + const parsed = UserSessionDtoSchema.parse(session); + parsed.current = session.id === req.session?.id; + + return parsed; + }, + ); + + instance.get( + "/current", + { + schema: { + tags: ["users", "sessions"], + description: "Get the current user's session.", + response: { + 200: UserSessionDtoSchema, + 403: NotSessionLoggerUserApiError.schema, + }, + }, + }, + async function (req) { + if (!req.session) { + throw new NotSessionLoggerUserApiError(); + } + + const parsed = UserSessionDtoSchema.parse(req.session); + parsed.current = true; + + return parsed; + }, + ); + + instance.delete( + "/", + { + schema: { + tags: ["users", "sessions"], + description: "Delete all the current user's sessions.", + response: { + 204: z.void(), + }, + }, + }, + async function (req, res) { + await req.user!.destroyAllSessions(); + + res.status(204).send(); + }, + ); + + instance.delete( + "/:id", + { + schema: { + tags: ["users", "sessions"], + description: "Delete a session.", + params: z.object({ + id: z.string().uuid(), + }), + response: { + 204: z.void(), + 404: UserSessionNotFoundApiError.schema, + }, + }, + }, + async function (req, res) { + const session = await UserSessionModel.findOne({ + where: { + id: req.params.id, + userId: req.user!.id, + }, + }); + + if (!session) { + throw new UserSessionNotFoundApiError(); + } + + await session.destroy(); + + res.status(204).send(); + }, + ); +}) satisfies FastifyPluginAsync; diff --git a/src/utils/routeOptionInjection.ts b/src/utils/routeOptionInjection.ts new file mode 100644 index 0000000..2b70752 --- /dev/null +++ b/src/utils/routeOptionInjection.ts @@ -0,0 +1,49 @@ +import { ZodTypeAny, z } from "zod"; + +export function injectSchemaInRouteOption( + routeOptions: any, + statusCode: number, + schema: z.ZodType, +): void { + if (routeOptions.method === "HEAD" || routeOptions.method === "OPTIONS") { + return; + } + + if (!routeOptions.schema) { + routeOptions.schema = {}; + } + + if (!routeOptions.schema.response) { + routeOptions.schema.response = {}; + } + + if (!routeOptions.schema.response[200] && statusCode !== 200) { + routeOptions.schema.response[200] = z.void(); + } + + if ( + !routeOptions.schema.response[statusCode] || + routeOptions.schema.response[statusCode] instanceof z.ZodVoid + ) { + routeOptions.schema.response[statusCode] = schema; + return; + } + + if (!(routeOptions.schema.response[statusCode] instanceof z.ZodType)) { + throw new Error("Not a valid Zod schema in route options."); + } + + if (routeOptions.schema.response[statusCode] instanceof z.ZodUnion) { + routeOptions.schema.response[statusCode] = z.union([ + ...(routeOptions.schema.response[statusCode]._def + .options as readonly [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]), + schema, + ]); + return; + } + + routeOptions.schema.response[statusCode] = z.union([ + routeOptions.schema.response[statusCode], + schema, + ]); +}