From a974f576b3102790779bf3ce180a0030723f58b2 Mon Sep 17 00:00:00 2001 From: Jose Conde Date: Sat, 6 Jul 2024 20:32:41 +0200 Subject: [PATCH] game flow revamp --- package-lock.json | 902 ++++++++++++++++++ package.json | 13 + src/common/LoggingService.ts | 56 +- src/game/DominoesGame.ts | 45 +- src/game/GameSession.ts | 149 --- src/game/MatchSession.ts | 250 +++++ src/game/NetworkClientNotifier.ts | 6 +- src/game/PlayerNotificationManager.ts | 41 +- ...meSessionState.ts => MatchSessionState.ts} | 6 +- src/game/dto/PlayerDto.ts | 4 +- src/game/dto/PlayerState.ts | 7 - src/game/entities/Board.ts | 5 + src/game/entities/player/AbstractPlayer.ts | 37 +- src/game/entities/player/NetworkPlayer.ts | 23 +- src/game/entities/player/PlayerInterface.ts | 13 +- src/server/controllers/ApiKeyController.ts | 90 ++ src/server/controllers/AuthController.ts | 213 +++++ src/server/controllers/BaseController.ts | 11 + .../controllers/MatchSessionController.ts | 19 + .../controllers/NamespacesController.ts | 62 ++ src/server/controllers/UserController.ts | 152 +++ src/server/db/DbAdapter.ts | 20 + src/server/db/interfaces.ts | 98 ++ src/server/db/mongo/ApiTokenMongoManager.ts | 39 + .../db/mongo/MatchSessionMongoManager.ts | 5 + src/server/db/mongo/NamespacesMongoManager.ts | 77 ++ .../db/mongo/TemporalTokenMongoManager.ts | 35 + src/server/db/mongo/UsersMongoManager.ts | 70 ++ .../db/mongo/common/BaseMongoManager.ts | 123 +++ src/server/db/mongo/common/PipelineLibrary.ts | 111 +++ src/server/db/mongo/common/mongoDBPool.ts | 55 ++ src/server/db/mongo/common/mongoUtils.ts | 5 + src/server/index.ts | 13 +- .../ManagerBase.ts} | 2 +- src/server/managers/SecurityManager.ts | 39 + .../SessionManager.ts} | 29 +- src/server/router/adminRouter.ts | 33 + src/server/router/apiRouter.ts | 22 + src/server/router/index.ts | 15 + src/server/router/userRouter.ts | 23 + src/server/router/validations.ts | 22 + src/server/services/CryptoService.ts | 25 + src/server/services/NamespacesService.ts | 45 + src/server/services/SessionService.ts | 19 + src/server/services/SocketIoService.ts | 55 +- src/server/services/UsersService.ts | 82 ++ src/server/services/mailer/MailerService.ts | 75 ++ src/server/types/environment.d.ts | 11 + src/server/types/express/index.d.ts | 8 + tsconfig.json | 2 +- 50 files changed, 2994 insertions(+), 268 deletions(-) delete mode 100644 src/game/GameSession.ts create mode 100644 src/game/MatchSession.ts rename src/game/dto/{GameSessionState.ts => MatchSessionState.ts} (67%) delete mode 100644 src/game/dto/PlayerState.ts create mode 100644 src/server/controllers/ApiKeyController.ts create mode 100644 src/server/controllers/AuthController.ts create mode 100644 src/server/controllers/BaseController.ts create mode 100644 src/server/controllers/MatchSessionController.ts create mode 100644 src/server/controllers/NamespacesController.ts create mode 100644 src/server/controllers/UserController.ts create mode 100644 src/server/db/DbAdapter.ts create mode 100644 src/server/db/interfaces.ts create mode 100644 src/server/db/mongo/ApiTokenMongoManager.ts create mode 100644 src/server/db/mongo/MatchSessionMongoManager.ts create mode 100644 src/server/db/mongo/NamespacesMongoManager.ts create mode 100644 src/server/db/mongo/TemporalTokenMongoManager.ts create mode 100644 src/server/db/mongo/UsersMongoManager.ts create mode 100644 src/server/db/mongo/common/BaseMongoManager.ts create mode 100644 src/server/db/mongo/common/PipelineLibrary.ts create mode 100644 src/server/db/mongo/common/mongoDBPool.ts create mode 100644 src/server/db/mongo/common/mongoUtils.ts rename src/server/{controllers/ControllerBase.ts => managers/ManagerBase.ts} (78%) create mode 100644 src/server/managers/SecurityManager.ts rename src/server/{controllers/SessionController.ts => managers/SessionManager.ts} (63%) create mode 100644 src/server/router/adminRouter.ts create mode 100644 src/server/router/apiRouter.ts create mode 100644 src/server/router/index.ts create mode 100644 src/server/router/userRouter.ts create mode 100644 src/server/router/validations.ts create mode 100644 src/server/services/CryptoService.ts create mode 100644 src/server/services/NamespacesService.ts create mode 100644 src/server/services/SessionService.ts create mode 100644 src/server/services/UsersService.ts create mode 100644 src/server/services/mailer/MailerService.ts create mode 100644 src/server/types/environment.d.ts create mode 100644 src/server/types/express/index.d.ts diff --git a/package-lock.json b/package-lock.json index 64bee94..62c5350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,29 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^2.4.3", "chalk": "^4.1.2", "cors": "^2.8.5", "express": "^4.19.2", + "express-validator": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.8.0", + "nodemailer": "^6.9.14", + "nodemailer-express-handlebars": "^6.1.2", "pino": "^9.2.0", + "pino-http": "^10.2.0", "pino-pretty": "^11.2.1", + "pino-rotating-file-stream": "^0.0.2", "seedrandom": "^3.0.5", "socket.io": "^4.7.5" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.8", + "@types/nodemailer": "^6.4.15", + "@types/nodemailer-express-handlebars": "^4.0.5", "@types/seedrandom": "^3.0.8", "ts-node": "^10.9.2", "typescript": "^5.5.2" @@ -40,6 +52,23 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -65,6 +94,24 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", + "integrity": "sha512-dCHW/oEX0KJ4NjDULBo3JiOaK5+6axtpBbS+ao2ZInoAL9/YRQLhXzSNAFz7hP4nzLkIqsfYAK/PDE3+XHny0Q==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -94,6 +141,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -138,6 +191,12 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-handlebars": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@types/express-handlebars/-/express-handlebars-5.3.1.tgz", + "integrity": "sha512-DSzaERLO4gHb8AqnrL58jzSDyT0yDdl6HqDc+bGz1Hf0nrG1FK30nHGzv8NBEGR8QV9eUGB/YaE0Qj3NjF7siw==", + "dev": true + }, "node_modules/@types/express-serve-static-core": { "version": "4.19.5", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", @@ -156,6 +215,15 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -170,6 +238,25 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.15", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", + "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/nodemailer-express-handlebars": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/nodemailer-express-handlebars/-/nodemailer-express-handlebars-4.0.5.tgz", + "integrity": "sha512-SuSYGNQPGtgMkDlQTO7zacXDENqQR3QXW1Ip5PvvookodvUKCbNVRF1tisY3Bgew1h8Wjfsf6dPQ5E45pJ1bJA==", + "dev": true, + "dependencies": { + "@types/express-handlebars": "^5", + "@types/nodemailer": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -209,6 +296,19 @@ "@types/send": "*" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -256,6 +356,18 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -289,6 +401,12 @@ "node": ">=8.0.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "peer": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -316,6 +434,11 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -339,6 +462,23 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bson": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz", + "integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -362,6 +502,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -474,6 +619,20 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -532,11 +691,31 @@ "node": ">=0.3.1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "peer": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "peer": true + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -699,6 +878,32 @@ "node": ">= 0.10.0" } }, + "node_modules/express-handlebars": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-7.1.3.tgz", + "integrity": "sha512-O0W4n14iQ8+iFIDdiMh9HRI2nbVQJ/h1qndlD1TXWxxcfbKjKoqJh+ti2tROkyx4C4VQrt0y3bANBQ5auQAiew==", + "peer": true, + "dependencies": { + "glob": "^10.4.2", + "graceful-fs": "^4.2.11", + "handlebars": "^4.7.8" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/express-validator": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.1.0.tgz", + "integrity": "sha512-ePn6NXjHRZiZkwTiU1Rl2hy6aUqmi6Cb4/s8sfUsKH7j2yYl9azSpl8xEHcOj1grzzQ+UBEoLWtE1s6FDxW++g==", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -734,6 +939,22 @@ "node": ">= 0.8" } }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -758,6 +979,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -776,6 +1005,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.3.tgz", + "integrity": "sha512-Q38SGlYRpVtDBPSWEylRyctn7uDeTp4NQERTLiCT1FqA9JXPYWqAVmQU6qh4r/zMM5ehxTcbaO8EjhWnvEhmyg==", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -787,6 +1039,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "peer": true + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "peer": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -902,6 +1181,39 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "peer": true + }, + "node_modules/jackspeak": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.1.tgz", + "integrity": "sha512-U23pQPDnmYybVkYjObcuYMk43VRlMLLqLI+RdZy8s8WV8WsxO9SnqSroKaluuvcNOdCAlauKszDwd+umbot5Mg==", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -910,6 +1222,100 @@ "node": ">=10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/lru-cache": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.1.tgz", + "integrity": "sha512-9/8QXrtbGeMB6LxwQd4x1tIMnsmUxMvIH/qWGsccz6bt9Uln3S+sgAaqfQNhbGA8ufzs2fHuP/yqapGgP9Hh2g==", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -924,6 +1330,11 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -967,6 +1378,21 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -975,6 +1401,69 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mongodb": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", + "integrity": "sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.7.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -988,6 +1477,32 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "peer": true + }, + "node_modules/nodemailer": { + "version": "6.9.14", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", + "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemailer-express-handlebars": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/nodemailer-express-handlebars/-/nodemailer-express-handlebars-6.1.2.tgz", + "integrity": "sha512-p5ZKPe8PmiDZquNqi+uDKE1eKXOcqOVPdnmhWELCT1HHoaYXcVwM7wQE1R7wtPPracZRH2hL7mEWiXhVwr5hng==", + "engines": { + "node": "14.* || 16.* || >= 18" + }, + "peerDependencies": { + "express-handlebars": ">= 6.0.0", + "nodemailer": ">= 6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1034,6 +1549,12 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "peer": true + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1042,6 +1563,31 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -1077,6 +1623,17 @@ "split2": "^4.0.0" } }, + "node_modules/pino-http": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.2.0.tgz", + "integrity": "sha512-am03BxnV3Ckx68OkbH0iZs3indsrH78wncQ6w1w51KroIbvJZNImBKX2X1wjdY8lSyaJ0UrX/dnO2DY3cTeCRw==", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^3.0.0" + } + }, "node_modules/pino-pretty": { "version": "11.2.1", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.2.1.tgz", @@ -1101,6 +1658,14 @@ "pino-pretty": "bin.js" } }, + "node_modules/pino-rotating-file-stream": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/pino-rotating-file-stream/-/pino-rotating-file-stream-0.0.2.tgz", + "integrity": "sha512-knF+ReDBMQMB7gzBfuFpUmCrXpRen6YYh5Q9Ymmj//dDHeH4QEMwAV7VoGEEM+30s7VHqfbabazs9wxkMO2BIQ==", + "dependencies": { + "rotating-file-stream": "^3.1.0" + } + }, "node_modules/pino-std-serializers": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", @@ -1140,6 +1705,14 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -1204,6 +1777,17 @@ "node": ">= 12.13.0" } }, + "node_modules/rotating-file-stream": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.3.tgz", + "integrity": "sha512-cfmm3tqdnbuYw2FBmRTPBDaohYEbMJ3211T35o6eZdr4d7v69+ZeK1Av84Br7FLj2dlzyeZSbN6qTuXXE6dawQ==", + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1246,6 +1830,17 @@ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -1309,6 +1904,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -1326,6 +1942,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/socket.io": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", @@ -1435,6 +2063,23 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1459,6 +2104,102 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "peer": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1497,6 +2238,17 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -1565,6 +2317,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz", + "integrity": "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==", + "optional": true, + "peer": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -1592,6 +2357,14 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "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==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1600,6 +2373,135 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "peer": true + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "peer": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 2817591..bba0d4f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "tsc", "dev": "node --env-file=.env --watch -r ts-node/register src/server/index.ts", + "create": "node --env-file=.env -r ts-node/register ./create.ts", "test": "node --env-file=.env -r ts-node/register src/test.ts", "test:watch": "node --env-file=.env --watch -r ts-node/register src/test.ts", "docker-build": "docker build -t arhuako/domino:latest .", @@ -22,17 +23,29 @@ "type": "commonjs", "reposityory": "github:jmconde/domino", "dependencies": { + "bcryptjs": "^2.4.3", "chalk": "^4.1.2", "cors": "^2.8.5", "express": "^4.19.2", + "express-validator": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.8.0", + "nodemailer": "^6.9.14", + "nodemailer-express-handlebars": "^6.1.2", "pino": "^9.2.0", + "pino-http": "^10.2.0", "pino-pretty": "^11.2.1", + "pino-rotating-file-stream": "^0.0.2", "seedrandom": "^3.0.5", "socket.io": "^4.7.5" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.8", + "@types/nodemailer": "^6.4.15", + "@types/nodemailer-express-handlebars": "^4.0.5", "@types/seedrandom": "^3.0.8", "ts-node": "^10.9.2", "typescript": "^5.5.2" diff --git a/src/common/LoggingService.ts b/src/common/LoggingService.ts index 04d318b..c39cf29 100644 --- a/src/common/LoggingService.ts +++ b/src/common/LoggingService.ts @@ -1,19 +1,20 @@ import pino, { BaseLogger } from 'pino'; import path from 'path'; +import httpPino from 'pino-http'; export class LoggingService { static instance: LoggingService; - logsPath: string = path.join(process.cwd(), 'app', 'server', 'logs'); + logsPath: string = path.join(process.cwd(), 'logs'); logger!: BaseLogger; level: string = process.env.LOG_LEVEL || 'info'; /* - * ogger.fatal('fatal'); - logger.error('error'); - logger.warn('warn'); - logger.info('info'); - logger.debug('debug'); - logger.trace('trace'); + * 1 - fatal + 2 - error + 3 - warn + 4 - info + 5 - debug + 6 - trace */ constructor() { if ((!LoggingService.instance)) { @@ -26,7 +27,7 @@ export class LoggingService { return LoggingService.instance; } - get commonRorationOptions() : any { + get commonRotationOptions() : any { return { interval: '1d', maxFiles: 10, @@ -39,14 +40,14 @@ export class LoggingService { get transports() { return pino.transport({ targets: [ - // { - // target: 'pino-rotating-file-stream', - // level: this.level, - // options: { - // filename: 'app.log', - // ...this.commonRorationOptions - // }, - // }, + { + target: 'pino-rotating-file-stream', + level: this.level, + options: { + filename: 'app.log', + ...this.commonRotationOptions + }, + }, { target: 'pino-pretty', level: this.level, @@ -59,6 +60,29 @@ export class LoggingService { }); } + get httpTransports() : any { + return pino.transport({ + targets: [ + { + target: 'pino-rotating-file-stream', + level: this.level, + options: { + filename: 'http.log', + ...this.commonRotationOptions + }, + }, + ], + }); + } + + middleware(): any { + return httpPino({ + logger: pino({ + level: this.level, + timestamp: pino.stdTimeFunctions.isoTime, + }, this.httpTransports) }); + } + debug(message: string, data?: any) { this.logger.debug(this._getMessageWidthObject(message, data)); } diff --git a/src/game/DominoesGame.ts b/src/game/DominoesGame.ts index 59146e9..5a653c1 100644 --- a/src/game/DominoesGame.ts +++ b/src/game/DominoesGame.ts @@ -25,7 +25,7 @@ export class DominoesGame { winner: PlayerInterface | null = null; rng: PRNG; handSize: number = 7; - notificationManager: PlayerNotificationManager = new PlayerNotificationManager(this); + notificationManager: PlayerNotificationManager = new PlayerNotificationManager(); lastMove: PlayerMove | null = null; constructor(public players: PlayerInterface[], seed: PRNG) { @@ -44,6 +44,14 @@ export class DominoesGame { this.board.boneyard = this.generateTiles(); } + reset() { + this.board.reset(); + this.initializeGame(); + for (let player of this.players) { + player.hand = []; + } + } + generateTiles(): Tile[] { const tiles: Tile[] = []; for (let i = 6; i >= 0; i--) { @@ -142,18 +150,25 @@ export class DominoesGame { this.nextPlayer(); } + resetPlayersScore() { + for (let player of this.players) { + player.score = 0; + } + } + async start(): Promise { + this.resetPlayersScore(); this.gameInProgress = false; this.tileSelectionPhase = true; - await this.notificationManager.notifyGameState(); - await this.notificationManager.notifyPlayersState(); + await this.notificationManager.notifyGameState(this); + await this.notificationManager.notifyPlayersState(this.players); this.logger.debug('clients received boneyard :>> ' + this.board.boneyard); await wait(1000); if (this.autoDeal) { this.dealTiles(); - await this.notificationManager.notifyGameState(); - await this.notificationManager.notifyPlayersState(); + await this.notificationManager.notifyGameState(this); + await this.notificationManager.notifyPlayersState(this.players); } else { await this.tilesSelection(); } @@ -164,8 +179,8 @@ export class DominoesGame { printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`); while (!this.gameOver) { await this.playTurn(); - await this.notificationManager.notifyGameState(); - await this.notificationManager.notifyPlayersState(); + await this.notificationManager.notifyGameState(this); + await this.notificationManager.notifyPlayersState(this.players); this.gameBlocked = this.isBlocked(); this.gameOver = this.isGameOver(); } @@ -196,8 +211,8 @@ export class DominoesGame { while (this.board.boneyard.length > 0) { for (let player of this.players) { const choosen = await player.chooseTile(this.board); - await this.notificationManager.notifyGameState(); - await this.notificationManager.notifyPlayersState(); + await this.notificationManager.notifyGameState(this); + await this.notificationManager.notifyPlayersState(this.players); if (this.board.boneyard.length === 0) { break; } @@ -217,16 +232,8 @@ export class DominoesGame { gameTied: this.gameTied, gameId: this.id, boneyard: this.board.boneyard.map(tile => ({ id: tile.id})), - players: this.players.map(player => ({ - id: player.id, - name: player.name, - score: player.score, - hand: player.hand.map(tile => tile.id), - })), - currentPlayer: { - id: currentPlayer.id, - name: currentPlayer.name - }, + players: this.players.map(player => player.getState()), + currentPlayer: currentPlayer.getState(), board: this.board.tiles.map(tile => ({ id: tile.id, pips: tile.pips diff --git a/src/game/GameSession.ts b/src/game/GameSession.ts deleted file mode 100644 index b851c49..0000000 --- a/src/game/GameSession.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { DominoesGame } from "./DominoesGame"; -import { PlayerAI } from "./entities/player/PlayerAI"; -import { PlayerInterface } from "./entities/player/PlayerInterface"; -import { LoggingService } from "../common/LoggingService"; -import { getRandomSeed, uuid, wait } from "../common/utilities"; -import { GameSessionState } from "./dto/GameSessionState"; -import { PlayerNotificationManager } from './PlayerNotificationManager'; -import seedrandom, { PRNG } from "seedrandom"; - -export class GameSession { - private game: DominoesGame | null = null; - private minHumanPlayers: number = 1; - private waitingForPlayers: boolean = true; - private waitingSeconds: number = 0; - private logger: LoggingService = new LoggingService(); - private mode: string = 'classic'; - private pointsToWin: number = 100; - private playerNotificationManager: PlayerNotificationManager; - id: string; - players: PlayerInterface[] = []; - sessionInProgress: boolean = false; - maxPlayers: number = 4; - seed!: string - rng!: PRNG - - constructor(public creator: PlayerInterface, public name?: string) { - this.playerNotificationManager = new PlayerNotificationManager(this); - this.id = uuid(); - this.name = name || `Game ${this.id}`; - this.addPlayer(creator); - this.logger.info(`GameSession created by: ${creator.name}`); - this.creator = creator; - this.playerNotificationManager.notifySessionState(); - } - - get numPlayers() { - return this.players.length; - } - - private async startGame(seed: string) { - this.rng = seedrandom(seed); - const missingPlayers = this.maxPlayers - this.numPlayers; - for (let i = 0; i < missingPlayers; i++) { - this.addPlayer(this.createPlayerAI(i)); - } - this.game = new DominoesGame(this.players, this.rng); - this.sessionInProgress = true; - this.logger.info('Game started'); - this.playerNotificationManager.notifySessionState(); - await this.game.start(); - return this.endGame(); - } - - private endGame(): any { - if (this.game !== null) { - this.sessionInProgress = false; - const { gameBlocked, gameTied, winner } = this.game; - - gameBlocked ? console.log('Game blocked!') : gameTied ? console.log('Game tied!') : console.log('Game over!'); - console.log('Winner: ' + winner?.name + ' with ' + winner?.pipsCount() + ' points'); - this.getScore(this.game); - this.sessionInProgress = false; - this.logger.info('Game ended'); - this.game = null; - this.playerNotificationManager.notifySessionState(); - - return { - gameBlocked, - gameTied, - winner - }; - } - } - - private getScore(game: DominoesGame) { - const pips = game.players - .sort((a,b) => (b.pipsCount() - a.pipsCount())) - .map(player => { - return `${player.name}: ${player.pipsCount()}`; - }); - console.log(`Pips count: ${pips.join(', ')}`); - const totalPoints = game.players.reduce((acc, player) => acc + player.pipsCount(), 0); - if (game.winner !== null) { - game.winner.score += totalPoints; - } - const scores = game.players - .sort((a,b) => (b.score - a.score)) - .map(player => { - return `${player.name}: ${player.score}`; - }); - console.log(`Scores: ${scores.join(', ')}`); - } - - createPlayerAI(i: number) { - const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"]; - return new PlayerAI(AInames[i], this.rng); - } - - async start(seed?: string) { - this.seed = seed || getRandomSeed(); - console.log('seed :>> ', this.seed); - if (this.sessionInProgress) { - throw new Error("Game already in progress"); - } - this.waitingForPlayers = true; - this.logger.info('Waiting for players to join'); - while (this.numPlayers < this.maxPlayers) { - this.waitingSeconds += 1; - this.logger.info(`Waiting for players to join: ${this.waitingSeconds}`); - await wait(1000); - } - this.waitingForPlayers = false; - this.logger.info('All players joined'); - this.startGame(this.seed); - } - - addPlayer(player: PlayerInterface) { - if (this.numPlayers >= this.maxPlayers) { - throw new Error("GameSession is full"); - } - this.players.push(player); - this.logger.info(`${player.name} joined the game!`); - } - - toString() { - return `GameSession:(${this.id} ${this.name})`; - } - - getState(): GameSessionState { - return { - id: this.id, - name: this.name!, - creator: this.creator.id, - players: this.players.map(player =>( { - id: player.id, - name: player.name, - })), - sessionInProgress: this.sessionInProgress, - maxPlayers: this.maxPlayers, - numPlayers: this.numPlayers, - waitingForPlayers: this.waitingForPlayers, - waitingSeconds: this.waitingSeconds, - seed: this.seed, - mode: this.mode, - pointsToWin: this.pointsToWin, - status: this.sessionInProgress ? 'in progress' : 'waiting' - }; - } -} \ No newline at end of file diff --git a/src/game/MatchSession.ts b/src/game/MatchSession.ts new file mode 100644 index 0000000..f1b44fc --- /dev/null +++ b/src/game/MatchSession.ts @@ -0,0 +1,250 @@ +import { DominoesGame } from "./DominoesGame"; +import { PlayerAI } from "./entities/player/PlayerAI"; +import { PlayerInterface } from "./entities/player/PlayerInterface"; +import { LoggingService } from "../common/LoggingService"; +import { getRandomSeed, uuid, wait } from "../common/utilities"; +import { MatchSessionState } from "./dto/MatchSessionState"; +import { PlayerNotificationManager } from './PlayerNotificationManager'; +import seedrandom, { PRNG } from "seedrandom"; +import { NetworkPlayer } from "./entities/player/NetworkPlayer"; +import { PlayerHuman } from "./entities/player/PlayerHuman"; + + +export class MatchSession { + private currentGame: DominoesGame | null = null; + private minHumanPlayers: number = 1; + private waitingForPlayers: boolean = true; + private waitingSeconds: number = 0; + private logger: LoggingService = new LoggingService(); + private playerNotificationManager = new PlayerNotificationManager(); + + id: string; + matchInProgress: boolean = false; + matchWinner: PlayerInterface | null = null; + maxPlayers: number = 4; + mode: string = 'classic'; + players: PlayerInterface[] = []; + pointsToWin: number = 50; + rng!: PRNG + scoreboard: Map = new Map(); + seed!: string + sessionInProgress: boolean = false; + state: string = 'created' + + constructor(public creator: PlayerInterface, public name?: string) { + this.id = uuid(); + this.name = name || `Game ${this.id}`; + this.addPlayer(creator); + + this.creator = creator; + this.logger.info(`Match session created by: ${creator.name}`); + this.logger.info(`Match session ID: ${this.id}`); + this.logger.info(`Match session name: ${this.name}`); + this.logger.info(`Points to win: ${this.pointsToWin}`); + this.sessionInProgress = true; + this.matchInProgress = false; + this.playerNotificationManager.notifyMatchState(this); + this.playerNotificationManager.notifyPlayersState(this.players); + } + + get numPlayers() { + return this.players.length; + } + + get numPlayersReady() { + return this.players.filter(player => player.ready).length; + } + + get numHumanPlayers() { + return this.players.filter(player => player instanceof PlayerHuman).length; + } + + private async startMatch(seed: string) { + this.rng = seedrandom(seed); + const missingPlayers = this.maxPlayers - this.numPlayers; + for (let i = 0; i < missingPlayers; i++) { + this.addPlayer(this.createPlayerAI(i)); + } + this.state = 'ready' + this.resetScoreboard() + let gameNumber: number = 0; + this.matchInProgress = true + this.playerNotificationManager.notifyMatchState(this); + while (this.matchInProgress) { + + this.currentGame = new DominoesGame(this.players, this.rng); + gameNumber += 1; + this.state = 'started' + this.logger.info(`Game #${gameNumber} started`); + // this.game.reset() + await this.currentGame.start(); + this.setScores(); + this.checkMatchWinner(); + this.resetReadiness(); + this.state = 'waiting' + await this.playerNotificationManager.notifyMatchState(this); + this.playerNotificationManager.sendEventToPlayers('game-finished', this.players); + await this.checkHumanPlayersReady(); + } + this.state = 'end' + // await this.game.start(); + return this.endGame(); + } + + async checkHumanPlayersReady() { + this.logger.info('Waiting for human players to be ready'); + return new Promise((resolve) => { + const interval = setInterval(() => { + this.logger.debug(`Human players ready: ${this.numPlayersReady}/${this.numHumanPlayers}`) + if (this.numPlayersReady === this.numHumanPlayers) { + clearInterval(interval); + resolve(true); + } + }, 1000); + }); + } + + resetReadiness() { + this.players.forEach(player => { + player.ready = false + }); + } + + checkMatchWinner() { + const scores = Array.from(this.scoreboard.values()); + const maxScore = Math.max(...scores); + if (maxScore >= this.pointsToWin) { + this.matchWinner = this.players.find(player => this.scoreboard.get(player.id) === maxScore)!; + this.logger.info(`Match winner: ${this.matchWinner.name} with ${maxScore} points`); + this.matchInProgress = false; + } + } + resetScoreboard() { + this.scoreboard = new Map(); + this.players.forEach(player => { + this.scoreboard.set(player.id, 0); + }); + } + + setScores() { + const totalPips = this.currentGame?.players.reduce((acc, player) => acc + player.pipsCount(), 0); + if (this.currentGame && this.currentGame.winner !== null) { + const winner = this.currentGame.winner; + this.scoreboard.set(winner.id, this.scoreboard.get(winner.id)! + totalPips!); + if (winner.teamedWith !== null) { + this.scoreboard.set(winner.teamedWith.id, this.scoreboard.get(winner.teamedWith.id)! + totalPips!); + } + } + } + + private endGame(): any { + if (this.currentGame !== null) { + const { gameBlocked, gameTied, winner } = this.currentGame; + gameBlocked ? this.logger.info('Game blocked!') : gameTied ? this.logger.info('Game tied!') : this.logger.info('Game over!'); + this.logger.info('Winner: ' + winner?.name + ' with ' + winner?.pipsCount() + ' points'); + this.getScore(this.currentGame); + this.logger.info('Game ended'); + this.currentGame = null; + this.playerNotificationManager.notifyMatchState(this); + + return { + gameBlocked, + gameTied, + winner + }; + } + } + + private getScore(game: DominoesGame) { + const pips = game.players + .sort((a,b) => (b.pipsCount() - a.pipsCount())) + .map(player => { + return `${player.name}: ${player.pipsCount()}`; + }); + this.logger.info(`Pips count: ${pips.join(', ')}`); + const totalPoints = game.players.reduce((acc, player) => acc + player.pipsCount(), 0); + if (game.winner !== null) { + game.winner.score += totalPoints; + } + const scores = game.players + .sort((a,b) => (b.score - a.score)) + .map(player => { + return `${player.name}: ${player.score}`; + }); + this.logger.info(`Scores: ${scores.join(', ')}`); + } + + createPlayerAI(i: number) { + const AInames = ["Alice (AI)", "Bob (AI)", "Charlie (AI)", "David (AI)"]; + const player = new PlayerAI(AInames[i], this.rng); + player.ready = true; + return player; + } + + async start(seed?: string) { + this.seed = seed || getRandomSeed(); + console.log('seed :>> ', this.seed); + if (this.matchInProgress) { + throw new Error("Game already in progress"); + } + this.waitingForPlayers = true; + this.logger.info('Waiting for players to be ready'); + while (this.numPlayers < this.maxPlayers) { + this.waitingSeconds += 1; + this.logger.info(`Waiting for players to join: ${this.waitingSeconds}`); + await wait(1000); + } + this.waitingForPlayers = false; + this.logger.info('All players joined'); + await this.startMatch(this.seed); + } + + addPlayer(player: PlayerInterface) { + if (this.numPlayers >= this.maxPlayers) { + throw new Error("GameSession is full"); + } + this.players.push(player); + this.logger.info(`${player.name} joined the game!`); + } + + setPlayerReady(user: string) { + const player = this.players.find(player => player.name === user); + if (!player) { + throw new Error("Player not found"); + } + player.ready = true; + this.logger.info(`${player.name} is ready!`); + this.playerNotificationManager.notifyMatchState(this); + } + + + toString() { + return `GameSession:(${this.id} ${this.name})`; + } + + getState(): MatchSessionState { + return { + id: this.id, + name: this.name!, + creator: this.creator.id, + players: this.players.map(player =>( { + id: player.id, + name: player.name, + ready: player.ready, + })), + playersReady: this.numPlayersReady, + sessionInProgress: this.sessionInProgress, + maxPlayers: this.maxPlayers, + numPlayers: this.numPlayers, + waitingForPlayers: this.waitingForPlayers, + waitingSeconds: this.waitingSeconds, + seed: this.seed, + mode: this.mode, + pointsToWin: this.pointsToWin, + status: this.sessionInProgress ? 'in progress' : 'waiting', + scoreboard: this.scoreboard, + matchWinner: this.matchWinner?.getState() || null, + matchInProgress: this.matchInProgress + }; + } +} \ No newline at end of file diff --git a/src/game/NetworkClientNotifier.ts b/src/game/NetworkClientNotifier.ts index a973911..e327a28 100644 --- a/src/game/NetworkClientNotifier.ts +++ b/src/game/NetworkClientNotifier.ts @@ -17,7 +17,7 @@ export class NetworkClientNotifier { this.io = io; } - async notifyPlayer(player: NetworkPlayer, event: string, data: any = {}, timeoutSecs: number = 300): Promise { + async notifyPlayer(player: NetworkPlayer, event: string, data: any = {}, timeoutSecs: number = 900): Promise { try { const response = await this.io.to(player.socketId) .timeout(timeoutSecs * 1000) @@ -29,6 +29,10 @@ export class NetworkClientNotifier { } } + async sendEvent(player: NetworkPlayer, event: string, data?: any) { + this.io.to(player.socketId).emit(event, data); + } + async broadcast(event: string, data: any) { const responses = await this.io.emit(event, data); this.logger.debug('responses :>> ', responses); diff --git a/src/game/PlayerNotificationManager.ts b/src/game/PlayerNotificationManager.ts index c4f3ce4..928f7a4 100644 --- a/src/game/PlayerNotificationManager.ts +++ b/src/game/PlayerNotificationManager.ts @@ -1,39 +1,36 @@ import { DominoesGame } from "./DominoesGame"; -import { GameSession } from "./GameSession"; +import { MatchSession } from "./MatchSession"; import { GameState } from "./dto/GameState"; +import { PlayerInterface } from "./entities/player/PlayerInterface"; export class PlayerNotificationManager { - game!: DominoesGame; - session!: GameSession; - constructor(game: DominoesGame | GameSession) { - if (game instanceof GameSession) { - this.session = game; - } else { - this.game = game; - } - } - - async notifyGameState() { - if(!this.game) throw new Error('Game not initialized'); - const gameState: GameState = this.game.getGameState(); - const { players } = this.game; + async notifyGameState(game: DominoesGame) { + const gameState: GameState = game.getGameState(); + const { players } = game; let promises: Promise[] = players.map(player => player.notifyGameState(gameState)); return await Promise.all(promises); } - async notifyPlayersState() { - if(!this.game) throw new Error('Game not initialized'); - const { players } = this.game; + async notifyPlayersState(players: PlayerInterface[]) { let promises: Promise[] = players.map(player => player.notifyPlayerState(player.getState())); return await Promise.all(promises); } - async notifySessionState() { - if(!this.session) throw new Error('Session not initialized'); - const { players } = this.session; - let promises: Promise[] = players.map(player => player.notifySessionState(this.session.getState())); + async notifyMatchState(session: MatchSession) { + const { players } = session; + let promises: Promise[] = players.map(player => player.notifyMatchState(session.getState())); + return await Promise.all(promises); + } + + async waitForPlayersAction(actionId: string, data: any = {}, players: PlayerInterface[]) { + let promises: Promise[] = players.map(player => player.waitForAction(actionId, data)); + return await Promise.all(promises); + } + + async sendEventToPlayers(event: string, players: PlayerInterface[]) { + let promises: Promise[] = players.map(player => player.sendEvent(event)); return await Promise.all(promises); } } \ No newline at end of file diff --git a/src/game/dto/GameSessionState.ts b/src/game/dto/MatchSessionState.ts similarity index 67% rename from src/game/dto/GameSessionState.ts rename to src/game/dto/MatchSessionState.ts index 696d43f..a5be361 100644 --- a/src/game/dto/GameSessionState.ts +++ b/src/game/dto/MatchSessionState.ts @@ -1,6 +1,6 @@ import { PlayerDto } from "./PlayerDto"; -export interface GameSessionState { +export interface MatchSessionState { id: string; name: string; creator: string; @@ -14,4 +14,8 @@ export interface GameSessionState { maxPlayers: number; numPlayers: number; waitingSeconds: number; + scoreboard: Map; + matchWinner: PlayerDto | null; + matchInProgress: boolean; + playersReady: number } \ No newline at end of file diff --git a/src/game/dto/PlayerDto.ts b/src/game/dto/PlayerDto.ts index 61dce32..7ae0a80 100644 --- a/src/game/dto/PlayerDto.ts +++ b/src/game/dto/PlayerDto.ts @@ -2,5 +2,7 @@ export interface PlayerDto { id: string; name: string; score?: number; - hand?: string[]; + hand?: any[]; + teamedWith?: PlayerDto | null; + ready: boolean; } \ No newline at end of file diff --git a/src/game/dto/PlayerState.ts b/src/game/dto/PlayerState.ts deleted file mode 100644 index bb649bc..0000000 --- a/src/game/dto/PlayerState.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface PlayerState { - id: string; - name: string; - score: number; - hand: any[]; - teamedWith: string | undefined; -} \ No newline at end of file diff --git a/src/game/entities/Board.ts b/src/game/entities/Board.ts index 584e341..10562ad 100644 --- a/src/game/entities/Board.ts +++ b/src/game/entities/Board.ts @@ -38,6 +38,11 @@ export class Board { return this.rightEnd?.flippedPips[1]; } + reset() { + this.tiles = []; + this.boneyard = []; + } + getFreeEnds() { if(this.count === 0) { return []; diff --git a/src/game/entities/player/AbstractPlayer.ts b/src/game/entities/player/AbstractPlayer.ts index 1801beb..c571ff0 100644 --- a/src/game/entities/player/AbstractPlayer.ts +++ b/src/game/entities/player/AbstractPlayer.ts @@ -7,8 +7,8 @@ import { EventEmitter } from "stream"; import { PlayerInteractionInterface } from "../../PlayerInteractionInterface"; import { uuid } from "../../../common/utilities"; import { GameState } from "../../dto/GameState"; -import { PlayerState } from "../../dto/PlayerState"; -import { GameSessionState } from "../../dto/GameSessionState"; +import { MatchSessionState } from "../../dto/MatchSessionState"; +import { PlayerDto } from "../../dto/PlayerDto"; export abstract class AbstractPlayer extends EventEmitter implements PlayerInterface { hand: Tile[] = []; @@ -17,6 +17,7 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter teamedWith: PlayerInterface | null = null; playerInteraction: PlayerInteractionInterface = undefined as any; id: string = uuid(); + ready: boolean = false; constructor(public name: string) { super(); @@ -29,10 +30,17 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter async notifyGameState(state: GameState): Promise { } - async notifyPlayerState(state: PlayerState): Promise { + async notifyPlayerState(state: PlayerDto): Promise { } - async notifySessionState(state: GameSessionState): Promise { + async notifyMatchState(state: MatchSessionState): Promise { + } + + async waitForAction(actionId: string): Promise { + return true; + } + + async sendEvent(event: string): Promise { } pipsCount(): number { @@ -54,17 +62,24 @@ export abstract class AbstractPlayer extends EventEmitter implements PlayerInter return highestPair; } - getState(): PlayerState { + getState(showPips: boolean = false): PlayerDto { return { id: this.id, name: this.name, score: this.score, - hand: this.hand.map(tile => ({ - id: tile.id, - pips: tile.pips, - flipped: tile.revealed, - })), - teamedWith: this.teamedWith?.id, + hand: this.hand.map(tile => { + const d = { + id: tile.id, + pips: tile.pips, + flipped: tile.revealed, + }; + if (showPips) { + d.pips = tile.pips; + } + return d; + }), + teamedWith: this.teamedWith?.getState() ?? null, + ready: this.ready, }; } } diff --git a/src/game/entities/player/NetworkPlayer.ts b/src/game/entities/player/NetworkPlayer.ts index a98e966..55a5d20 100644 --- a/src/game/entities/player/NetworkPlayer.ts +++ b/src/game/entities/player/NetworkPlayer.ts @@ -5,8 +5,8 @@ import { NetworkClientNotifier } from "../../NetworkClientNotifier"; import { Tile } from "../Tile"; import { Board } from "../Board"; import { GameState } from "../../dto/GameState"; -import { PlayerState } from "../../dto/PlayerState"; -import { GameSessionState } from "../../dto/GameSessionState"; +import { PlayerDto } from "../../dto/PlayerDto"; +import { MatchSessionState } from "../../dto/MatchSessionState"; import { SocketDisconnectedError } from "../../../common/exceptions/SocketDisconnectedError"; export class NetworkPlayer extends PlayerHuman { @@ -27,7 +27,7 @@ export class NetworkPlayer extends PlayerHuman { } } - async notifyPlayerState(state: PlayerState): Promise { + async notifyPlayerState(state: PlayerDto): Promise { const response = await this.clientNotifier.notifyPlayer(this, 'playerState', state); console.log('player state notified :>> ', response); if (response === undefined || response.status !== 'ok' ) { @@ -35,14 +35,25 @@ export class NetworkPlayer extends PlayerHuman { } } - async notifySessionState(state: GameSessionState): Promise { - const response = await this.clientNotifier.notifyPlayer(this, 'sessionState', state); + async notifyMatchState(state: MatchSessionState): Promise { + const response = await this.clientNotifier.notifyPlayer(this, 'matchState', state); console.log('session state notified :>> ', response); if (response === undefined || response.status !== 'ok' ) { throw new SocketDisconnectedError(); } } - + async waitForAction(actionId: string): Promise { + const response = await this.clientNotifier.notifyPlayer(this, actionId); + if (response === undefined || response.status !== 'ok' ) { + throw new SocketDisconnectedError(); + } + const { actionResult } = response; + return actionResult; + } + + async sendEvent(event: string): Promise { + this.clientNotifier.sendEvent(this, event); + } async chooseTile(board: Board): Promise { return await this.playerInteraction.chooseTile(board); diff --git a/src/game/entities/player/PlayerInterface.ts b/src/game/entities/player/PlayerInterface.ts index fdc0db4..97f9bc9 100644 --- a/src/game/entities/player/PlayerInterface.ts +++ b/src/game/entities/player/PlayerInterface.ts @@ -2,9 +2,9 @@ import { PlayerInteractionInterface } from "../../PlayerInteractionInterface"; import { Board } from "../Board"; import { GameState } from "../../dto/GameState"; import { PlayerMove } from "../PlayerMove"; -import { PlayerState } from "../../dto/PlayerState"; import { Tile } from "../Tile"; -import { GameSessionState } from "../../dto/GameSessionState"; +import { MatchSessionState } from "../../dto/MatchSessionState"; +import { PlayerDto } from "../../dto/PlayerDto"; export interface PlayerInterface { id: string; @@ -13,12 +13,15 @@ export interface PlayerInterface { hand: Tile[]; teamedWith: PlayerInterface | null; playerInteraction: PlayerInteractionInterface; + ready: boolean; makeMove(gameState: Board): Promise; chooseTile(board: Board): Promise; pipsCount(): number; notifyGameState(state: GameState): Promise; - notifyPlayerState(state: PlayerState): Promise; - notifySessionState(state: GameSessionState): Promise; - getState(): PlayerState; + notifyPlayerState(state: PlayerDto): Promise; + notifyMatchState(state: MatchSessionState): Promise; + waitForAction(actionId: string, data: any): Promise; + sendEvent(event: string): Promise; + getState(): PlayerDto; } \ No newline at end of file diff --git a/src/server/controllers/ApiKeyController.ts b/src/server/controllers/ApiKeyController.ts new file mode 100644 index 0000000..848762f --- /dev/null +++ b/src/server/controllers/ApiKeyController.ts @@ -0,0 +1,90 @@ +import { Request, Response } from "express"; +import { ApiTokenMongoManager } from "../db/mongo/ApiTokenMongoManager"; +import { SecurityManager } from "../managers/SecurityManager"; +import { BaseController } from "./BaseController"; +import { Token } from "../db/interfaces"; +import toObjectId from "../db/mongo/common/mongoUtils"; + +export class ApiKeyController extends BaseController{ + apiTokenManager = new ApiTokenMongoManager(); + security = new SecurityManager(); + + async deleteApiKey(req: Request, res: Response) { + try { + const { id } = req.params; + await this.apiTokenManager.deleteToken(id); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async createApiKey(req: Request, res: Response) { + try { + const token: Token = this._createTokenObject(req); + await this.apiTokenManager.addToken(token); + res.status(201).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async listUserApiKeys(req: Request, res: Response) { + try { + const { user } = req; + const response = await this.apiTokenManager.getTokens(user._id); + res.json(response).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async listNamespaceApiKeys(req: Request, res: Response) { + try { + const { namespaceId } = req.user; + const response = await this.apiTokenManager.getTokensByNamespace(namespaceId); + res.json(response).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async deleteNamespaceApiKey(req: Request, res: Response) { + try { + const { tokenId: id } = req.params; + await this.apiTokenManager.deleteToken(id); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async createNamespaceApiKey(req: Request, res: Response) { + try { + const token = this._createTokenObject(req); + await this.apiTokenManager.addToken(token); + res.status(201).end(); + } catch (error) { + this.handleError(res, error); + } + } + + _createTokenObject(req: Request): Token { + const { user, body } = req; + const { roles = [ 'client' ], type = 'user', description = '' } = body; + const { _id: userId, namespaceId } = user; + const token = this.security.generateApiToken(); + const newToken: Token = { + token, + description, + roles, + type + }; + if (type === 'namespace') { + newToken.namespaceId = toObjectId(namespaceId); + } else if (type === 'user') { + newToken.userId = toObjectId(userId); + } + return newToken; + } +} \ No newline at end of file diff --git a/src/server/controllers/AuthController.ts b/src/server/controllers/AuthController.ts new file mode 100644 index 0000000..44dfdce --- /dev/null +++ b/src/server/controllers/AuthController.ts @@ -0,0 +1,213 @@ +import bcrypt from 'bcryptjs'; +import { UsersMongoManager } from "../db/mongo/UsersMongoManager"; +import { SecurityManager } from '../managers/SecurityManager'; +import { ApiTokenMongoManager } from '../db/mongo/ApiTokenMongoManager'; +import { TemporalTokenMongoManager } from '../db/mongo/TemporalTokenMongoManager'; +import { BaseController } from './BaseController'; +import { NextFunction, Request, Response } from 'express'; +import { AuthenticationOption, Token, User } from '../db/interfaces'; + +export class AuthController extends BaseController { + security = new SecurityManager(); + usersManager = new UsersMongoManager(); + temporalTokenManager = new TemporalTokenMongoManager(); + + async login(req: Request, res: Response): Promise { + const { log } = req; + try { + let token = null + const { username, password } = req.body; + this.logger.debug('login', username, password); + const { valid: isValidPassword, user } = await this._checkPassword(username, password); + this.logger.debug('isValidPassword', isValidPassword); + if (!isValidPassword) { + res.status(401).json({ error: 'Unauthorized' }).end(); + log.error('Unauthorized login attempt for user: ', username); + return; + } + this._jwtSignUser(user, res) + } catch (error) { + this.handleError(res, error); + } + } + + _jwtSignUser(user: User | null, res: Response) { + if (user === null) { + res.status(401).json({ error: 'Unauthorized' }).end(); + return; + } + delete user.hash; + const token = this.security.signJwt(user); + if (token === null) { + res.status(401).json({ error: 'Unauthorized' }).end(); + } else { + res.status(200).json({ token }).end(); + } + return; + } + + async twoFactorCodeAuthentication(req: Request, res: Response) { + const { code, username } = req.body; + const { valid: isValid, user } = await this._isValidTemporalCode(username, code); + if (!isValid) { + res.status(406).json({ error: 'Unauthorized' }).end(); + return; + } + res.status(200).end(); + } + + async _isValidTemporalCode(username: string, code: string) { + const user = await this.usersManager.getByUsername(username); + if (user === null || user._id === undefined) { + return { valid: false, user: null }; + } + const temporalToken = await this.temporalTokenManager.getByUserAndType(user._id.toString(), TemporalTokenMongoManager.Types.PASSWORD_RECOVERY); + if (temporalToken === null) { + return { valid: false, user: null }; + } + + const { token } = temporalToken; + const valid = bcrypt.compareSync(code, token); + return { valid, user: valid ? user : null}; + } + + async changePasswordWithCode(req: Request, res: Response) { + try { + const { username, newPassword, code } = req.body; + const { valid: isValid, user } = await this._isValidTemporalCode(username, code); + if (isValid) { + await this._setNewPassword(username, newPassword); + this._jwtSignUser(user, res); + } else { + res.status(400).json({ error: 'Code not valid.' }).end(); + } + } catch (error) { + this.handleError(res, error); + } + } + + async changePassword(req: Request, res: Response) { + try { + const { username, oldPassword, newPassword } = req.body; + const { valid: isValidPassword } = await this._checkPassword(username, oldPassword); + if (isValidPassword) { + await this._setNewPassword(username, newPassword); + res.status(200).end(); + } + res.status(400).json({ error: 'Password not valid.' }).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async _setNewPassword(username: string, newPassword: string) { + const hash = this.security.getHashedPassword(newPassword); + await this.usersManager.updatePassword(username, hash); + } + + async _checkPassword(username: string, password: string) { + let valid = false; + const user = await this.usersManager.getByUsername(username); + if (user && user.hash) { + const { hash } = user; + valid = bcrypt.compareSync(password, hash); + } + return { valid, user }; + } + + static async checkRolesToken(token: Token, rolesToCheck: string[]) { + if (rolesToCheck.length === 0) { + return true; + } + + if (!token._id) { + return false; + } + + const tokenFromDb = await new ApiTokenMongoManager().getById(token._id.toString()); + + if (!tokenFromDb) { + return false; + } + + const { roles } = tokenFromDb; + const validRoles = rolesToCheck.filter((r: string) => roles.includes(r)); + return validRoles.length === rolesToCheck.length; + } + + static async checkRoles(user: User, rolesToCheck: string[]) { + if (rolesToCheck.length === 0) { + return true; + } + + if (!user._id) { + return false; + } + + const usersManager = new UsersMongoManager(); + const userFromDb = await usersManager.getById(user._id.toString()); + + if (!userFromDb) { + return false; + } + + const { roles } = userFromDb; + const validRoles = rolesToCheck.filter((r: string) => roles.includes(r)); + return validRoles.length === rolesToCheck.length; + } + + static authenticate(options: AuthenticationOption = {}) { + return async function(req: Request, res: Response, next: NextFunction) { + const security = new SecurityManager(); + const token = req.headers.authorization; + const { roles = [] } = options; + if (!token) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const user: User = await security.verifyJwt(token); + const validRoles = await AuthController.checkRoles(user, roles); + if (!validRoles) { + return res.status(403).json({ error: 'Forbidden' }); + } + req.user = user; + next(); + } catch (error) { + return res.status(403).json({ error: 'Forbidden' }); + } + } + } + + static tokenAuthenticate(options: AuthenticationOption = {}) { + return async function(req: Request, res: Response, next: NextFunction) { + const { log } = req; + // log.info('tokenAuthenticate') + try { + const token: string = req.headers['x-api-key'] as string; + const dm = new ApiTokenMongoManager(); + const apiToken = await dm.getByToken(token); + const { roles = [] } = options; + const valid = !!apiToken && await AuthController.checkRolesToken(apiToken, roles); + if (!valid) { + return res.status(401).json({ error: 'Unauthorized' }); + } + req.token = apiToken; + next(); + } catch (error) { + return res.status(403).json({ error: 'Forbidden' }); + } + } + } + + static async withUser(req: Request, res: Response, next: NextFunction) { + try { + const token = req.token; + const dm = new UsersMongoManager(); + const user = await dm.getById(token.userId); + req.user = user; + next(); + } catch (error) { + return res.status(403).json({ error: 'Forbidden' }); + } + } +} \ No newline at end of file diff --git a/src/server/controllers/BaseController.ts b/src/server/controllers/BaseController.ts new file mode 100644 index 0000000..ffca99b --- /dev/null +++ b/src/server/controllers/BaseController.ts @@ -0,0 +1,11 @@ +import { Response } from "express"; +import { LoggingService } from "../../common/LoggingService"; + +export class BaseController { + logger = new LoggingService().logger; + + handleError(res: Response, error: any, data = {}) { + this.logger.error(error); + res.status(500).json({ error: error.message, ...data }).end(); + } +} \ No newline at end of file diff --git a/src/server/controllers/MatchSessionController.ts b/src/server/controllers/MatchSessionController.ts new file mode 100644 index 0000000..4f11789 --- /dev/null +++ b/src/server/controllers/MatchSessionController.ts @@ -0,0 +1,19 @@ +import { Request, Response } from "express"; +import { BaseController } from "./BaseController"; + +export class MatchSessionController extends BaseController { + // async createMatchSession(req: Request, res: Response): Promise { + // const response = await this.sessionManager.createSession(data, socketId); + // return response; + // } + + // async startMatchSession(data: any): Promise { + // const response = await this.sessionManager.startSession(data); + // return response; + // } + + // async joinMatchSession(data: any, socketId: string): Promise { + // const response = await this.sessionManager.joinSession(data, socketId); + // return response; + // } +} \ No newline at end of file diff --git a/src/server/controllers/NamespacesController.ts b/src/server/controllers/NamespacesController.ts new file mode 100644 index 0000000..00aecea --- /dev/null +++ b/src/server/controllers/NamespacesController.ts @@ -0,0 +1,62 @@ +import { NamespacesService } from "../services/NamespacesService"; +import { BaseController } from "./BaseController"; +import { Request, Response } from "express"; + +export class NamespacesController extends BaseController{ + private namespacesService: NamespacesService; + constructor() { + super(); + this.namespacesService = new NamespacesService(); + } + + async getNamespaces(req: Request, res: Response) { + try { + const namespaces = await this.namespacesService.getNamespaces(); + res.json(namespaces).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async getNamespace(req: Request, res: Response) { + try { + const { id } = req.params; + const namespace = await this.namespacesService.getNamespace(id); + res.json(namespace).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async createNamespace(req: Request, res: Response) { + try { + const namespace = req.body; + const user = req.user; + await this.namespacesService.createNamespace(namespace, user); + res.status(201).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async updateNamespace(req: Request, res: Response) { + try { + const { body: namespace, params} = req; + const { id } = params; + const result = await this.namespacesService.updateNamespace(id, namespace); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async deleteNamespace(req: Request, res: Response) { + try { + const { id } = req.params; + await this.namespacesService.deleteNamespace(id); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } +} \ No newline at end of file diff --git a/src/server/controllers/UserController.ts b/src/server/controllers/UserController.ts new file mode 100644 index 0000000..cbeabe4 --- /dev/null +++ b/src/server/controllers/UserController.ts @@ -0,0 +1,152 @@ +import { validationResult } from 'express-validator'; +import { SecurityManager } from '../managers/SecurityManager'; +import { CryptoService } from '../services/CryptoService'; +import { TemporalTokenMongoManager } from '../db/mongo/TemporalTokenMongoManager'; +import { BaseController } from './BaseController'; +import { MailerService } from '../services/mailer/MailerService'; +import { NamespacesService } from '../services/NamespacesService'; +import { UsersService } from '../services/UsersService'; +import { Request, Response } from 'express'; + +export class UserController extends BaseController { + security = new SecurityManager(); + temporalTokenManager = new TemporalTokenMongoManager(); + usersService = new UsersService(); + mailService = new MailerService(); + cryptoService = new CryptoService(); + namespacesService = new NamespacesService(); + + constructor() { + super(); + } + + async getNamespaces(req: Request, res: Response) { + return await this.namespacesService.getNamespaces(); + } + + async getUser(req: Request, res: Response) { + try { + const { id } = req.params; + const user = await this.usersService.getById(id); + res.json(user).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async createUser(req: Request, res: Response) { + const user = req.body; + try { + const validation = validationResult(req); + if (!validation.isEmpty()) { + res.status(400).json({ errors: validation.array() }); + return; + } + await this.usersService.createUser(user); + res.status(201).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async updateUserNamespace(req: Request, res: Response) { + try { + const { userId, namespaceId } = req.params; + return await this.usersService.updateUserNamespace(userId, namespaceId); + } catch (error) { + this.handleError(res, error); + } + } + + async resetUserNamespace(req: Request, res: Response) { + try { + const { userId } = req.params; + const defaultNS = await this.namespacesService.getDefaultNamespace(); + if (!defaultNS._id) { + throw new Error('Default namespace not found'); + } + return await this.usersService.updateUserNamespace(userId, defaultNS._id.toString()); + } catch (error) { + this.handleError(res, error); + } + } + + async updateUser(req: Request, res: Response) { + const user = req.body; + try { + const validation = validationResult(req); + if (!validation.isEmpty()) { + res.status(400).json({ errors: validation.array() }); + return; + } + + const { id } = req.params; + await this.usersService.updateUser(user, id); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async deleteUser(req: Request, res: Response) { + try { + const { id } = req.params; + await this.usersService.deleteUser(id); + res.status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async listUsers(req: Request, res: Response): Promise { + try { + const { available = false } = req.query; + let users = []; + if (available) { + const defaultNamespace = await this.namespacesService.getDefaultNamespace(); + users = await this.usersService.listUsers({ not: defaultNamespace._id }); + } else { + users = await this.usersService.listUsers(); + } + res.json(users).status(200).end(); + } catch (error) { + this.handleError(res, error); + } + } + + async passwordRecovery(req: Request, res: Response) { + try { + const { username } = req.body; + const user = await this.usersService.getByUsername(username); + if (user === null) { + res.status(404).json({ message: 'User not found', code: 'user-not-found'}).end(); + return; + } + if (!user.email) { + res.status(404).json({ message: 'Email not found', code: 'email-not-found'}).end(); + return; + } + const { email, firstname, lastname, _id: userId } = user; + const pin = this.cryptoService.generateRandomPin(8); + const token = this.security.getHashedPassword(pin); + const temporalToken = { + userId, + token, + createdAt: new Date().getTime(), + validUntil: new Date().getTime() + 1000 * 60 * 60 * 1, + type: TemporalTokenMongoManager.Types.PASSWORD_RECOVERY, + } + + if (!userId) { + throw new Error('User not found'); + } + + this.temporalTokenManager.deleteAllByUserAndType(userId.toString(), TemporalTokenMongoManager.Types.PASSWORD_RECOVERY); + this.temporalTokenManager.addToken(temporalToken); + await this.mailService.sendRecoveryPasswordEmail(firstname, lastname, email, pin); + res.status(200).end(); + } catch (error: any) { + this.handleError(res, error, { code: 'critical', message: error.message }); + } + } +} diff --git a/src/server/db/DbAdapter.ts b/src/server/db/DbAdapter.ts new file mode 100644 index 0000000..af0d2ab --- /dev/null +++ b/src/server/db/DbAdapter.ts @@ -0,0 +1,20 @@ +import { MatchSession } from "../../game/MatchSession"; +import { DbMatchSession } from "./interfaces"; + +export function matchSessionAdapter(session: MatchSession) : DbMatchSession { + return { + id: session.id, + name: session.name || '', + creator: session.creator.id, + players: session.players.map(player => player.id), + seed: session.seed, + mode: session.mode, + pointsToWin: session.pointsToWin, + maxPlayers: session.maxPlayers, + numPlayers: session.numPlayers, + scoreboard: Array.from(session.scoreboard.entries()).map(([player, score]) => ({ player, score })), + matchWinner: session.matchWinner ? session.matchWinner.id : null, + state: session.state + } + +} \ No newline at end of file diff --git a/src/server/db/interfaces.ts b/src/server/db/interfaces.ts new file mode 100644 index 0000000..8f76ca6 --- /dev/null +++ b/src/server/db/interfaces.ts @@ -0,0 +1,98 @@ +import { ObjectId } from "mongodb"; + +export interface Entity { + createdAt?: number | null; + modifiedAt?: number | null; + createdBy?: ObjectId | null; + modifiedBy?: ObjectId | null; +} + +export interface EntityMongo extends Entity { + _id?: ObjectId; +} + +export interface Score { + player: string; + score: number; +} + +export interface Namespace extends EntityMongo { + name?: string; + description?: string; + default: boolean; + type: string | null; + ownerId?: ObjectId; + users?: any[]; +} + +export interface User extends EntityMongo { + id: string, + username: string; + namespaceId: ObjectId; + hash?: string; + roles: string[]; + firstname?: string; + lastname?: string; + email?: string; + profileId?: string; + password?: string | null; + namespace?: Namespace; +} + +export interface DbMatchSession extends EntityMongo { + id: string; + name: string; + creator: string; + players: string[]; + seed: string; + mode: string; + pointsToWin: number; + maxPlayers: number; + numPlayers: number; + scoreboard: Score[]; + matchWinner: string | null; + state: string; +} + +export interface DbUser extends EntityMongo { + id: string, + username: string; + namespaceId: ObjectId; + hash?: string; + roles: string[]; + firstname?: string; + lastname?: string; + email?: string; + profileId?: string; + password?: string | null; + namespace?: DbNamespace; +} + +export interface DbNamespace extends EntityMongo { + name?: string; + description?: string; + default: boolean; + type: string | null; + ownerId?: ObjectId; +} + +export interface Token extends EntityMongo { + token: string; + userId?: ObjectId; + roles?: string[]; + expiresAt?: number | null; + type: string; + namespaceId?: ObjectId + description?: string; +} + +export interface AuthenticationOption { + roles?: string[]; +} + +export interface Role { + name: string; + description: string; + permissions: string[]; +} + diff --git a/src/server/db/mongo/ApiTokenMongoManager.ts b/src/server/db/mongo/ApiTokenMongoManager.ts new file mode 100644 index 0000000..ef261a9 --- /dev/null +++ b/src/server/db/mongo/ApiTokenMongoManager.ts @@ -0,0 +1,39 @@ +import { mongoExecute } from './common/mongoDBPool'; +import { BaseMongoManager } from './common/BaseMongoManager'; +import { Token } from '../interfaces'; + +export class ApiTokenMongoManager extends BaseMongoManager{ + collection = 'tokens'; + + async addToken(token: Token) { + return await mongoExecute(async ({ collection }) => { + await collection?.insertOne(token); + return token; + }, { colName: this.collection }); + } + + async getTokens(userId: string): Promise { + return await mongoExecute(async ({ collection }) => { + return await collection?.find({ userId: this.toObjectId(userId) }).toArray(); + }, { colName: this.collection }); + } + + async getTokensByNamespace(namespaceId: string): Promise { + return await mongoExecute(async ({ collection }) => { + return await collection?.find({ namespaceId: this.toObjectId(namespaceId) }).toArray(); + }, { colName: this.collection }); + } + + async getByToken(token: string): Promise{ + return await mongoExecute(async ({ collection }) => { + return await collection?.findOne({ token }); + }, { colName: this.collection }); + } + + async deleteToken(tokenId: string): Promise { + return await mongoExecute(async ({ collection }) => { + const res = await collection?.deleteOne({ _id: this.toObjectId(tokenId) }); + return res?.deletedCount || 0 + }, { colName: this.collection }); + } +} \ No newline at end of file diff --git a/src/server/db/mongo/MatchSessionMongoManager.ts b/src/server/db/mongo/MatchSessionMongoManager.ts new file mode 100644 index 0000000..320f7db --- /dev/null +++ b/src/server/db/mongo/MatchSessionMongoManager.ts @@ -0,0 +1,5 @@ +import { BaseMongoManager } from './common/BaseMongoManager'; + +export class MatchSessionMongoManager extends BaseMongoManager { + protected collection = "matchSessions"; +} \ No newline at end of file diff --git a/src/server/db/mongo/NamespacesMongoManager.ts b/src/server/db/mongo/NamespacesMongoManager.ts new file mode 100644 index 0000000..688958c --- /dev/null +++ b/src/server/db/mongo/NamespacesMongoManager.ts @@ -0,0 +1,77 @@ +import { PipelineLibrary } from './common/PipelineLibrary'; +import { mongoExecute } from './common/mongoDBPool'; +import { BaseMongoManager } from './common/BaseMongoManager'; +import { Namespace } from '../interfaces.js'; +import { Document, ObjectId } from 'mongodb'; + +export class NamespacesMongoManager extends BaseMongoManager{ + collection = 'namespaces'; + + constructor() { + super(); + } + + async createNamespace(namespace: Namespace): Promise { + return await mongoExecute(async ({collection}) => { + const now = new Date().getTime(); + delete namespace.users; + const result = await collection?.insertOne({ + ...namespace, + createdAt: now, + modifiedAt: now + }); + return result?.insertedId.toString() || ''; + }, {colName: this.collection}) + } + + async updateNamespace(id: string, namespace: Namespace): Promise { + return await mongoExecute(async ({collection}) => { + const now = new Date().getTime(); + const { name, description, type } = namespace; + const result = await collection?.updateOne({ + _id: this.toObjectId(id), + }, { + $set: { + name, + description, + type, + modifiedAt: now + } + }); + return result?.modifiedCount || 0; + }, {colName: this.collection}) + } + + async getNamespace(namespaceId: string): Promise { + const pipeline = PipelineLibrary.namespacesGetById(this.toObjectId((namespaceId))); + return await mongoExecute(async ({collection}) => { + const cursor: Document[] | undefined = await collection?.aggregate(pipeline).toArray(); + if (cursor === undefined ||cursor.length === 0) { + return null; + } + return cursor[0]; + }, {colName: this.collection}) + } + + async getDefaultNamespace(): Promise { + return await mongoExecute(async ({collection}) => { + return await collection?.findOne({ + default: true + }); + }, {colName: this.collection}); + } + + async getNamespaces(): Promise { + const pipeline = PipelineLibrary.namespacesGetNamespaces(); + return await mongoExecute(async ({collection}) => { + return await collection?.aggregate(pipeline).toArray(); + }, {colName: this.collection}) + } + + async deleteNamespace(_id: string): Promise { + return await mongoExecute(async ({collection}) => { + const result = await collection?.deleteOne({ _id: this.toObjectId(_id) }); + return result?.deletedCount || 0; + }, {colName: this.collection}) + } +} \ No newline at end of file diff --git a/src/server/db/mongo/TemporalTokenMongoManager.ts b/src/server/db/mongo/TemporalTokenMongoManager.ts new file mode 100644 index 0000000..4cb74cb --- /dev/null +++ b/src/server/db/mongo/TemporalTokenMongoManager.ts @@ -0,0 +1,35 @@ +import { DeleteResult } from "mongodb"; +import { ApiTokenMongoManager } from "./ApiTokenMongoManager"; +import { mongoExecute } from "./common/mongoDBPool"; +import { Token } from "../interfaces"; + +export class TemporalTokenMongoManager extends ApiTokenMongoManager{ + collection = 'temporalTokens'; + + static Types = { + PASSWORD_RECOVERY: 'password-recovery', + }; + + async getTokens(): Promise { + return this.getAllTokens(); + } + + async getAllTokens(): Promise { + return await mongoExecute(async ({ collection }) => { + return await collection?.find({ }).toArray(); + }, { colName: this.collection }); + } + + async getByUserAndType(userId: string, type: String): Promise{ + return await mongoExecute(async ({ collection }) => { + return await collection?.findOne({ userId: this.toObjectId(userId) , type }); + }, { colName: this.collection }); + } + + async deleteAllByUserAndType(userId: string, type: String): Promise{ + return await mongoExecute(async ({ collection }) => { + const res: DeleteResult | undefined = await collection?.deleteMany({ userId: this.toObjectId(userId), type }); + return res?.deletedCount || 0; + }, { colName: this.collection }); + } +} \ No newline at end of file diff --git a/src/server/db/mongo/UsersMongoManager.ts b/src/server/db/mongo/UsersMongoManager.ts new file mode 100644 index 0000000..d068b89 --- /dev/null +++ b/src/server/db/mongo/UsersMongoManager.ts @@ -0,0 +1,70 @@ +import { mongoExecute } from './common/mongoDBPool'; +import { PipelineLibrary } from './common/PipelineLibrary'; +import { BaseMongoManager } from './common/BaseMongoManager'; +import { User } from '../interfaces'; + +export class UsersMongoManager extends BaseMongoManager { + collection = 'users'; + + addUser(user: User) { + return this.create(user); + } + + updateUser(user: User) { + return this.update(user); + } + + async updateUserNamespace(userId: string, namespaceId: string) { + return await mongoExecute(async ({ collection }) => { + return await + collection?.updateOne({ _id: this.toObjectId(userId) }, { $set: { namespaceId: this.toObjectId(namespaceId) } }); + } + , { colName: this.collection }); + } + + async deleteUser(_id: string) { + return await mongoExecute(async ({ collection }) => { + await collection?.deleteOne({ _id: this.toObjectId(_id) }); + }, { colName: this.collection }); + } + + async getAvailableUsers(defaultId: string): Promise{ + return await mongoExecute(async ({ collection }) => { + const pipeline = PipelineLibrary.usersGetAvailableUsers(this.toObjectId(defaultId)); + return await collection?.aggregate(pipeline).toArray(); + }, { colName: this.collection }); + } + + async getUsers(): Promise { + return await mongoExecute(async ({ collection }) => { + const pipeline = PipelineLibrary.usersGetUsers(); + return await collection?.aggregate(pipeline).toArray(); + }, { colName: this.collection }); + } + + async getById(id: string): Promise{ + const pipeline = PipelineLibrary.usersGetById(this.toObjectId(id)); + return await mongoExecute(async ({ collection }) => { + const users = await collection?.aggregate(pipeline).toArray(); + if (users === undefined || users.length === 0) { + return null; + } + const user = users[0]; + delete user.hash; + return user; + } + , { colName: this.collection }); + } + + async getByUsername(username: string): Promise{ + return await mongoExecute(async ({ collection }) => { + return await collection?.findOne({ username }); + }, { colName: this.collection }); + } + + async updatePassword(username: string, hash: string) { + return await mongoExecute(async ({ collection }) => { + return await collection?.updateOne({ username }, { $set: { hash } }); + }, { colName: this.collection }); + } +} \ No newline at end of file diff --git a/src/server/db/mongo/common/BaseMongoManager.ts b/src/server/db/mongo/common/BaseMongoManager.ts new file mode 100644 index 0000000..171b36c --- /dev/null +++ b/src/server/db/mongo/common/BaseMongoManager.ts @@ -0,0 +1,123 @@ +import { ObjectId } from "mongodb"; +import { mongoExecute } from "./mongoDBPool"; +import { Entity } from "../../interfaces"; +import { LoggingService } from "../../../../common/LoggingService"; +import toObjectId from "./mongoUtils"; + + +export abstract class BaseMongoManager { + protected abstract collection?: string; + logger = new LoggingService().logger; + + create(data: Entity) { + return mongoExecute( + async ({ collection }) => { + await collection?.insertOne(data as any); + return data; + }, + { colName: this.collection } + ); + } + + delete(id: string) { + return mongoExecute( + async ({ collection }) => { + await collection?.deleteOne({ _id: this.toObjectId(id) }); + }, + { colName: this.collection } + ); + } + + deleteByFilter(filter: any) { + return mongoExecute( + async ({ collection }) => { + await collection?.deleteOne(filter); + }, + { colName: this.collection } + ); + } + + getById(id: string) { + return mongoExecute( + async ({ collection }) => { + return await collection?.findOne({ _id: this.toObjectId(id) }); + }, + { colName: this.collection } + ); + } + + getByFilter(filter: any) { + return mongoExecute( + async ({ collection }) => { + return await collection?.findOne(filter); + }, + { colName: this.collection } + ); + } + + list() { + return mongoExecute( + async ({ collection }) => { + return await collection?.find().toArray(); + }, + { colName: this.collection } + ); + } + + listByFilter(filter: any) { + return mongoExecute( + async ({ collection }) => { + return await collection?.find(filter).toArray(); + }, + { colName: this.collection } + ); + } + + update(object: Entity) { + const data: any = { ...object }; + const id = data._id; + delete data._id; + return mongoExecute(async ({ collection }) => { + return await collection?.updateOne( + { _id: this.toObjectId(id) }, + { $set: data } + ); + }, + { colName: this.collection }); + } + + updateMany(filter: any, data: Entity) { + return mongoExecute(async ({ collection }) => { + return await collection?.updateMany(filter, { $set: data as any }); + }, + { colName: this.collection }); + } + + replaceOne(filter: any, object: Entity) { + return mongoExecute(async ({collection}) => { + return await collection?.replaceOne(filter, object); + }, {colName: this.collection}); + } + + aggregation(pipeline: any) { + return mongoExecute( + async ({ collection }) => { + return await collection?.aggregate(pipeline).toArray(); + }, + { colName: this.collection } + ); + } + + aggregationOne(pipeline: any) { + return mongoExecute( + async ({ collection }) => { + return await collection?.aggregate(pipeline).next(); + }, + { colName: this.collection } + ); + } + + protected toObjectId = (oid: string) => { + return toObjectId(oid); + }; +} diff --git a/src/server/db/mongo/common/PipelineLibrary.ts b/src/server/db/mongo/common/PipelineLibrary.ts new file mode 100644 index 0000000..ba61d69 --- /dev/null +++ b/src/server/db/mongo/common/PipelineLibrary.ts @@ -0,0 +1,111 @@ +import { ObjectId } from "mongodb"; + +export class PipelineLibrary { + static usersGetById(id: ObjectId) { + return [ + { + '$match': { + '_id': id + } + }, { + '$lookup': { + 'from': 'namespaces', + 'localField': 'namespaceId', + 'foreignField': '_id', + 'as': 'namespace' + } + }, { + '$unwind': { + 'path': '$namespace', + 'preserveNullAndEmptyArrays': true + } + } + ]; + } + static namespacesGetNamespaces() { + return [ + { + '$lookup': { + 'from': 'users', + 'localField': '_id', + 'foreignField': 'namespaceId', + 'as': 'users' + } + }, { + '$project': { + '_id': 1, + 'name': 1, + 'description': 1, + 'ownerId': 1, + 'default': 1, + 'createdAt': 1, + 'modifiedAt': 1, + 'users': { + '_id': 1, + 'id': 1, + 'username': 1, + 'firstname': 1, + 'lastname': 1, + 'email': 1, + 'profileId': 1 + } + } + } + ]; + } + + static namespacesGetById(id: ObjectId) { + return [ + { + '$match': { + '_id': id + } + }, + ... PipelineLibrary.namespacesGetNamespaces() + ]; + } + + static usersGetAvailableUsers(defaultId: Object) { + return [ + { + '$match': { + 'namespaceId': defaultId + }, + }, + ...PipelineLibrary.usersGetUsers(), + ]; + } + + static usersGetUsers() { + return [ + { + '$lookup': { + 'from': 'namespaces', + 'localField': 'namespaceId', + 'foreignField': '_id', + 'as': 'namespace' + } + }, { + '$unwind': { + 'path': '$namespace', + 'preserveNullAndEmptyArrays': true + } + }, { + '$project': { + '_id': 1, + 'username': 1, + 'roles': 1, + 'firstname': 1, + 'lastname': 1, + 'email': 1, + 'modifiedAt': 1, + 'createdAt': 1, + 'namespace': { + '_id': 1, + 'name': 1 + } + } + } + ]; + } +} \ No newline at end of file diff --git a/src/server/db/mongo/common/mongoDBPool.ts b/src/server/db/mongo/common/mongoDBPool.ts new file mode 100644 index 0000000..b192ee2 --- /dev/null +++ b/src/server/db/mongo/common/mongoDBPool.ts @@ -0,0 +1,55 @@ +import { MongoClient, Collection, Db } from 'mongodb'; + +const { + MONGO_HOST, + MONGO_PORT, + MONGO_USER, + MONGO_PASS = '', + MONGO_DB, +} = process.env; + +const uri = `mongodb://${MONGO_USER}:${MONGO_PASS.replace(/[^A-Za-z0-9\-_.!~*'()%]/g, (c) => encodeURIComponent(c))}@${MONGO_HOST}:${MONGO_PORT}/?maxPoolSize=20`; + +interface MongoExecuteOptions { + dbName?: string; + colName?: string; +}; + +interface MongoExecuteParams { + collection?: Collection, + database?: Db, + connection?: MongoClient +} + +type MongoExecuteFunction = (options: MongoExecuteParams) => void | Promise | Promise | any; + +export const getMongoConnection = async() : Promise => { + const client = new MongoClient(uri); + return await client.connect(); +}; + +export const getMongoDatabase = (client: MongoClient, dbName: string): Db => { + const DB = dbName || MONGO_DB; + return client.db(DB); +}; + +export const mongoExecute = async function(fn: MongoExecuteFunction, opts: MongoExecuteOptions): Promise { + const { dbName, colName } = { dbName: MONGO_DB, ...opts }; + let connection: MongoClient | null = null; + try { + connection = await getMongoConnection(); + const database = connection.db(dbName); + if (colName) { + const collection: Collection = database.collection(colName); + return await fn({ collection, database, connection }); + } + return await fn({ database, connection }); + } catch (err: any) { + console.log('MOMGODB ERROR:', err.message); + throw err; + } finally { + if (connection !== null) { + await connection.close(); + } + } +}; diff --git a/src/server/db/mongo/common/mongoUtils.ts b/src/server/db/mongo/common/mongoUtils.ts new file mode 100644 index 0000000..9bb5333 --- /dev/null +++ b/src/server/db/mongo/common/mongoUtils.ts @@ -0,0 +1,5 @@ +import { ObjectId } from "mongodb"; + +export default function toObjectId(id: string) { + return ObjectId.createFromHexString(id); +} \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index e1360ef..810bf16 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,21 +5,30 @@ import { join } from 'path'; import { NetworkClientNotifier } from '../game/NetworkClientNotifier'; import { SocketIoService } from './services/SocketIoService'; +import { LoggingService } from '../common/LoggingService'; +import { useRouter } from './router'; const clientNotifier = new NetworkClientNotifier(); +const logger = new LoggingService(); const app = express(); const httpServer = http.createServer(app); const socketIoService = new SocketIoService(httpServer); clientNotifier.setSocket(socketIoService.getServer()); const PORT = process.env.PORT || 3000; -console.log('__dirname :>> ', __dirname); app.use(cors()); +app.use(logger.middleware()); +app.use(express.json({ limit: '50mb'})); +app.use(express.text()); +app.use(express.urlencoded({extended: true })); +app.use(useRouter()) + + app.get('/', (req, res) => { res.sendFile(join(__dirname, 'index.html')); }); httpServer.listen(PORT, () => { - console.log(`listening on *:${PORT}`); + logger.info(`listening on *:${PORT}`); }); diff --git a/src/server/controllers/ControllerBase.ts b/src/server/managers/ManagerBase.ts similarity index 78% rename from src/server/controllers/ControllerBase.ts rename to src/server/managers/ManagerBase.ts index 8780817..39e9c75 100644 --- a/src/server/controllers/ControllerBase.ts +++ b/src/server/managers/ManagerBase.ts @@ -1,5 +1,5 @@ import { LoggingService } from "../../common/LoggingService"; -export class ControllerBase { +export class ManagerBase { protected logger = new LoggingService(); } \ No newline at end of file diff --git a/src/server/managers/SecurityManager.ts b/src/server/managers/SecurityManager.ts new file mode 100644 index 0000000..b898e5c --- /dev/null +++ b/src/server/managers/SecurityManager.ts @@ -0,0 +1,39 @@ +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { User } from '../db/interfaces'; + +export class SecurityManager { + saltRounds = Number(process.env.SALT_ROUNDS); + jwtSecretKey = process.env.JWT_SECRET_KEY || ''; + + generateId() { + return crypto.randomBytes(16).toString('hex'); + } + + getHashedPassword(password: string) { + const salt = bcrypt.genSaltSync(this.saltRounds); + return bcrypt.hashSync(password, salt); + } + + generateApiToken() { + return crypto.randomBytes(32).toString('hex'); + } + + signJwt(data: any) { + return jwt.sign(data, this.jwtSecretKey, { expiresIn: '3h' }); + } + + // TODO: verificar esto + async verifyJwt(token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, this.jwtSecretKey, (err, decoded) => { + if (err) { + reject(err); + } else { + resolve(decoded as User); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/server/controllers/SessionController.ts b/src/server/managers/SessionManager.ts similarity index 63% rename from src/server/controllers/SessionController.ts rename to src/server/managers/SessionManager.ts index a241d1c..d606bb1 100644 --- a/src/server/controllers/SessionController.ts +++ b/src/server/managers/SessionManager.ts @@ -1,11 +1,12 @@ -import { LoggingService } from "../../common/LoggingService"; -import { GameSession } from "../../game/GameSession"; +import { MatchSession } from "../../game/MatchSession"; import { NetworkPlayer } from "../../game/entities/player/NetworkPlayer"; +import { SessionService } from "../services/SessionService"; -import { ControllerBase } from "./ControllerBase"; +import { ManagerBase } from "./ManagerBase"; -export class SessionController extends ControllerBase{ +export class SessionManager extends ManagerBase { private static sessions: any = {}; + private sessionService: SessionService = new SessionService(); constructor() { super(); @@ -15,8 +16,9 @@ export class SessionController extends ControllerBase{ createSession(data: any, socketId: string): any { const { user, sessionName } = data; const player = new NetworkPlayer(user, socketId); - const session = new GameSession(player, sessionName); - SessionController.sessions[session.id] = session; + const session = new MatchSession(player, sessionName); + SessionManager.sessions[session.id] = session; + this.sessionService.createSession(session); return { status: 'ok', @@ -29,9 +31,10 @@ export class SessionController extends ControllerBase{ this.logger.debug('joinSession data :>> ') this.logger.object(data); const { user, sessionId } = data; - const session = SessionController.sessions[sessionId]; + const session: MatchSession = SessionManager.sessions[sessionId]; const player = new NetworkPlayer(user, socketId); session.addPlayer(player); + this.sessionService.updateSession(session); return { status: 'ok', sessionId: session.id, @@ -39,10 +42,16 @@ export class SessionController extends ControllerBase{ }; } + setPlayerReady(data: any): any { + const { user, sessionId } = data; + const session: MatchSession = SessionManager.sessions[sessionId]; + session.setPlayerReady(user) + } + startSession(data: any): any { const sessionId: string = data.sessionId; const seed: string | undefined = data.seed; - const session = SessionController.sessions[sessionId]; + const session = SessionManager.sessions[sessionId]; if (!session) { return ({ @@ -68,10 +77,10 @@ export class SessionController extends ControllerBase{ getSession(id: string) { - return SessionController.sessions[id]; + return SessionManager.sessions[id]; } deleteSession(id: string) { - delete SessionController.sessions[id]; + delete SessionManager.sessions[id]; } } \ No newline at end of file diff --git a/src/server/router/adminRouter.ts b/src/server/router/adminRouter.ts new file mode 100644 index 0000000..7d79a6b --- /dev/null +++ b/src/server/router/adminRouter.ts @@ -0,0 +1,33 @@ +import { Request, Response, Router } from 'express'; +import { AuthController } from '../controllers/AuthController'; +import { NamespacesController } from '../controllers/NamespacesController'; +import { Validations } from './validations'; +import { UserController } from '../controllers/UserController'; +import { ApiKeyController } from '../controllers/ApiKeyController'; + +export default function(): Router { + const userController = new UserController(); + const namespacesController = new NamespacesController(); + const apiKeyController = new ApiKeyController(); + const router = Router(); + + const { authenticate } = AuthController; + + router.get('/users', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.listUsers(req, res)); + router.get('/user/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.getUser(req, res)); + router.delete('/user/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.deleteUser(req, res)); + router.post('/user', [ authenticate({ roles: ['admin']}), ...Validations.createUser ], (req: Request, res: Response) => userController.createUser(req, res)); + router.patch('/user/:id', [ authenticate({ roles: ['admin']}), ...Validations.updateUser ], (req: Request, res: Response) => userController.updateUser(req, res)); + router.patch('/user/:userId/namespace/:namespaceId/change', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.updateUserNamespace(req, res)); + router.patch('/user/:userId/namespace/reset', authenticate({ roles: ['admin']}), (req: Request, res: Response) => userController.resetUserNamespace(req, res)); + router.get('/namespaces', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.getNamespaces(req, res)); + router.post('/namespace', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.createNamespace(req, res)); + router.get('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.getNamespace(req, res)); + router.patch('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.updateNamespace(req, res)); + router.delete('/namespace/:id', authenticate({ roles: ['admin']}), (req: Request, res: Response) => namespacesController.deleteNamespace(req, res)); + router.get('/namespace/:id/tokens', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.listNamespaceApiKeys(req, res)); + router.delete('/namespace/:id/token/:tokenId', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.deleteNamespaceApiKey(req, res)); + router.post('/namespace/:id/token', authenticate({ roles: ['admin']}), (req: Request, res: Response) => apiKeyController.createNamespaceApiKey(req, res)); + + return router; +} diff --git a/src/server/router/apiRouter.ts b/src/server/router/apiRouter.ts new file mode 100644 index 0000000..f358c61 --- /dev/null +++ b/src/server/router/apiRouter.ts @@ -0,0 +1,22 @@ +import { Request, Response, Router } from 'express'; +import { AuthController } from '../controllers/AuthController'; + +import adminRouter from './adminRouter'; +import userRouter from './userRouter'; + +export default function(): Router { + const router = Router(); + const authController = new AuthController(); + + router.get('/version', async function(req: Request, res: Response){ + res.send('1.0.0').end(); + }); + + router.post('/auth/code', (req: Request, res: Response) => authController.twoFactorCodeAuthentication(req, res)); + router.post('/login', (req: Request, res: Response) => authController.login(req, res)); + + router .use('/admin', adminRouter()); + router .use('/user', userRouter()); + + return router; +} diff --git a/src/server/router/index.ts b/src/server/router/index.ts new file mode 100644 index 0000000..38b6c28 --- /dev/null +++ b/src/server/router/index.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { join } from 'path'; +import apiRouter from "./apiRouter"; + +export function useRouter(): Router { + const router = Router(); + + router.get('/', (req, res) => { + res.sendFile(join(__dirname, 'index.html')); + }); + + router.use('/api', apiRouter()); + + return router; +} \ No newline at end of file diff --git a/src/server/router/userRouter.ts b/src/server/router/userRouter.ts new file mode 100644 index 0000000..521660e --- /dev/null +++ b/src/server/router/userRouter.ts @@ -0,0 +1,23 @@ +import { Request, Response, Router } from 'express'; +import { UserController } from '../controllers/UserController'; +import { AuthController } from '../controllers/AuthController'; +import { ApiKeyController } from '../controllers/ApiKeyController'; + +export default function() : Router { + const userController = new UserController(); + const authController = new AuthController(); + const apiKeyController = new ApiKeyController(); + const router = Router(); + + const { authenticate } = AuthController; + + router.get('/tokens', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.listUserApiKeys(req, res)); + router.post('/token', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.createApiKey(req, res)); + router.delete('/token/:id', authenticate({ roles: ['user']}), (req: Request, res: Response) => apiKeyController.deleteApiKey(req, res)); + router.post('/password/change', authenticate({ roles: ['user']}), (req: Request, res: Response) => authController.changePassword(req, res)); + router.post('/password/recovery', (req: Request, res: Response) => userController.passwordRecovery(req, res)); + router.post('/password/recovery/change', (req: Request, res: Response) => authController.changePasswordWithCode(req, res)); + router.get('/namespaces', authenticate({ roles: ['user']}), (req: Request, res: Response) => userController.getNamespaces(req, res)); + + return router; +} \ No newline at end of file diff --git a/src/server/router/validations.ts b/src/server/router/validations.ts new file mode 100644 index 0000000..1c92bae --- /dev/null +++ b/src/server/router/validations.ts @@ -0,0 +1,22 @@ +import { body } from 'express-validator'; + +const username = body("username") +.trim() +.isLength({ min: 5 }) +.escape() +.withMessage("Username min 8 characters.") +.matches(/^[a-zA-Z0-9\-_]+$/).withMessage("First name has non-alphanumeric characters."); + +const password = body("password") +.notEmpty().withMessage('Password is required') +.matches(/^[a-zA-Z0-9!@#$%^&*()_+{}\[\]:;<>,.?~\-]+$/).withMessage('Password must contain at least one special character'); + +const email = body('email') +.optional({values: 'falsy'}) +.trim() +.isEmail(); + +export const Validations = { + createUser: [username, password, email], + updateUser: [username, email], +} diff --git a/src/server/services/CryptoService.ts b/src/server/services/CryptoService.ts new file mode 100644 index 0000000..568e3f1 --- /dev/null +++ b/src/server/services/CryptoService.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto'; + +export class CryptoService { + + generateEmailRecoveryToken(): string { + return this.generateToken(32); + } + + generateRandomPin(length: number): string { + const randomBytes = crypto.randomBytes(length); + let pin = ''; + + for (let i = 0; i < randomBytes.length; i++) { + // Convert each byte to a number between 0-9 + pin += (randomBytes[i] % 10).toString(); + } + + return pin; + } + + generateToken(length: number): string { + return crypto.randomBytes(length).toString('hex'); + } + +} \ No newline at end of file diff --git a/src/server/services/NamespacesService.ts b/src/server/services/NamespacesService.ts new file mode 100644 index 0000000..f0201d6 --- /dev/null +++ b/src/server/services/NamespacesService.ts @@ -0,0 +1,45 @@ +import { Namespace, User } from "../db/interfaces"; +import { NamespacesMongoManager } from "../db/mongo/NamespacesMongoManager"; +import { UsersService } from "./UsersService"; + +export class NamespacesService { + namespacesManager = new NamespacesMongoManager(); + usersService = new UsersService(); + + async createNamespace(namespace: Namespace, user: User) { + const insertedId = await this.namespacesManager.createNamespace({ ownerId: user._id, ...namespace, createdBy: user._id }); + await this._updateNamespaceUsers(namespace.users ?? [], insertedId); + return insertedId; + } + + async _updateNamespaceUsers(users: any[], id: string) { + const defaultNamespace: Namespace = await this.namespacesManager.getDefaultNamespace(); + for (const user of users) { + if (defaultNamespace._id === undefined) continue; + const namespaceId = user.removed ? defaultNamespace._id?.toString() : id; + await this.usersService.updateUserNamespace(user.id, namespaceId); + } + } + + async updateNamespace(id: string, namespace: Namespace) { + const result = await this.namespacesManager.updateNamespace(id, namespace); + await this._updateNamespaceUsers(namespace.users ?? [], id); + return result; + } + + async getNamespace(namespaceId: string) { + return await this.namespacesManager.getNamespace(namespaceId); + } + + async getNamespaces() { + return await this.namespacesManager.getNamespaces(); + } + + getDefaultNamespace(): Promise { + return this.namespacesManager.getDefaultNamespace(); + } + + async deleteNamespace(namespaceId: string) { + return await this.namespacesManager.deleteNamespace(namespaceId); + } +} \ No newline at end of file diff --git a/src/server/services/SessionService.ts b/src/server/services/SessionService.ts new file mode 100644 index 0000000..c0446f7 --- /dev/null +++ b/src/server/services/SessionService.ts @@ -0,0 +1,19 @@ +import { MatchSession } from "../../game/MatchSession"; +import { matchSessionAdapter } from "../db/DbAdapter"; +import { MatchSessionMongoManager } from "../db/mongo/MatchSessionMongoManager"; +import { ServiceBase } from "./ServiceBase"; + +export class SessionService extends ServiceBase{ + private dbManager: MatchSessionMongoManager = new MatchSessionMongoManager(); + constructor() { + super() + } + + public createSession(session: MatchSession): any { + this.dbManager.create(matchSessionAdapter(session)); + } + + public updateSession(session: MatchSession): any { + this.dbManager.replaceOne({id: session.id}, matchSessionAdapter(session)); + } +} \ No newline at end of file diff --git a/src/server/services/SocketIoService.ts b/src/server/services/SocketIoService.ts index bf591cf..3982d13 100644 --- a/src/server/services/SocketIoService.ts +++ b/src/server/services/SocketIoService.ts @@ -1,10 +1,12 @@ import { Server as HttpServer } from "http"; import { ServiceBase } from "./ServiceBase"; import { Server } from "socket.io"; -import { SessionController } from "../controllers/SessionController"; +import { SessionManager } from "../managers/SessionManager"; export class SocketIoService extends ServiceBase{ io: Server + clients: Map = new Map(); + constructor(private httpServer: HttpServer) { super() this.io = this.socketIo(httpServer); @@ -16,23 +18,30 @@ export class SocketIoService extends ServiceBase{ } private initListeners() { - const sessionController = new SessionController(); + const sessionController = new SessionManager(); this.io.on('connection', (socket) => { - console.log(`connect ${socket.id}`); + this.logger.debug(`connect ${socket.id}`); if (socket.recovered) { // recovery was successful: socket.id, socket.rooms and socket.data were restored - console.log("recovered!"); - console.log("socket.rooms:", socket.rooms); - console.log("socket.data:", socket.data); + this.logger.debug("recovered!"); + this.logger.debug("socket.rooms:", socket.rooms); + this.logger.debug("socket.data:", socket.data); } else { - console.log("new connection"); + this.logger.debug("new connection"); + this.clients.set(socket.id, { alive: true }); socket.join('room-general') socket.data.foo = "bar"; } + socket.on('pong', () => { + if (this.clients.has(socket.id)) { + this.clients.set(socket.id, { alive: true }); + } + }) socket.on('disconnect', () => { - console.log('user disconnected'); + this.logger.debug('user disconnected'); + this.clients.delete(socket.id); }); socket.on('createSession', (data, callback) => { @@ -49,16 +58,30 @@ export class SocketIoService extends ServiceBase{ const response = sessionController.joinSession(data, socket.id); callback(response); }); - - // socket.on('chat message', (msg, callback) => { - // io.emit('chat message', msg); - // callback({ - // status: 'ok', - // message: 'Message received', - // }) - // }); + + socket.on('playerReady', (data, callback) => { + const response = sessionController.setPlayerReady(data); + callback(response); + }); + + this.pingClients() }); } + + private pingClients() { + setInterval(() => { + for (let [id, client] of this.clients.entries()) { + if (!client.alive) { + this.logger.debug(`Client ${id} did not respond. Disconnecting.`); + this.io.to(id).disconnectSockets(true); // Disconnect client + this.clients.delete(id); + } else { + client.alive = false; // Reset alive status for the next ping + this.io.to(id).emit('ping'); // Send ping message + } + } + }, 30000); + } private socketIo(httpServer: HttpServer): Server { return new Server(httpServer, { diff --git a/src/server/services/UsersService.ts b/src/server/services/UsersService.ts new file mode 100644 index 0000000..1a4bd70 --- /dev/null +++ b/src/server/services/UsersService.ts @@ -0,0 +1,82 @@ +import { UsersMongoManager } from "../db/mongo/UsersMongoManager"; +import { SecurityManager } from "../managers/SecurityManager"; +import { ServiceBase } from "./ServiceBase"; +import { User } from "../db/interfaces"; +import toObjectId from "../db/mongo/common/mongoUtils"; + +export class UsersService extends ServiceBase { + usersManager = new UsersMongoManager(); + security = new SecurityManager(); + + listUsers(options?: any) { + if (options) { + return this.usersManager.getAvailableUsers(options.not); + } + return this.usersManager.getUsers(); + } + + getUser(id: string) { + return this.usersManager.getById(id); + } + + getByUsername(username: string) { + return this.usersManager.getByUsername(username); + } + + updateUser(user: any, id: string) { + const newUser = this._createUserObject(user, id); + const result = this.usersManager.updateUser(newUser); + return result; + } + + updateUserNamespace(userId: string, namespaceId: string) { + return this.usersManager.updateUserNamespace(userId, namespaceId); + } + + getById(id: string) { + return this.usersManager.getById(id); + } + + createUser(user: any) { + const newUser = this._createUserObject(user); + return this.usersManager.addUser(newUser); + } + + deleteUser(id: string) { + return this.usersManager.deleteUser(id); + } + + _createUserObject(body: any, _id?: string) { + const { username, password, firstname, lastname, email, isadmin, namespaceId, character, roles } = body; + const id = this.security.generateId(); + const createdAt = new Date().getTime(); + const modifiedAt = createdAt + // const roles = isadmin ? ['admin', 'user'] : ['user']; + + const user: User = { + id, + username, + password, + firstname, + lastname, + email, + roles, + createdAt, + modifiedAt, + namespaceId, + profileId: character, + }; + + this.logger.info(`${password === undefined}`); + if (_id !== undefined) { + user._id = toObjectId(_id); + } + + if (password !== undefined && typeof password === 'string' && password.length > 0) { + user.hash = this.security.getHashedPassword(password); + delete user.password; + } + + return user; + } +} \ No newline at end of file diff --git a/src/server/services/mailer/MailerService.ts b/src/server/services/mailer/MailerService.ts new file mode 100644 index 0000000..4ea163e --- /dev/null +++ b/src/server/services/mailer/MailerService.ts @@ -0,0 +1,75 @@ +import nodemailer, { SentMessageInfo, Transporter } from 'nodemailer'; +import hbs from 'nodemailer-express-handlebars'; +import Mail from 'nodemailer/lib/mailer'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; + +export class MailerService { + transporter: Transporter; + host: string | undefined; + sender: string | undefined; + port: number | undefined; + user: string | undefined; + pass: string | undefined; + + constructor() { + this.transporter = this._createTransporter(); + this.sender = process.env.EMAIL_SENDER; + this.host = process.env.SMTP_HOST; + this.port = Number(process.env.SMTP_PORT || 587); + this.user = process.env.SMTP_USER; + this.pass = process.env.SMTP_PASS; + this._configureTemplates(); + } + + _createTransporter(): Transporter { + + return nodemailer.createTransport({ + host: this.host, + port: this.port, + secure: false, + auth: { + user: this.user, + pass: this.pass, + }, + }); + } + + _configureTemplates() { + this.transporter.use('compile', hbs({ + viewEngine: { + extname: '.hbs', + partialsDir: 'app/server/views/partials', + layoutsDir: 'app/server/views/layouts', + defaultLayout: 'email.hbs', + }, + viewPath: 'app/server/views/emails', + extName: '.hbs', + })); + } + + async sendRecoveryPasswordEmail(firstname: string = '', lastname: string = '', email: string, pin: string) { + const to = firstname ? `${firstname}${lastname ? ' ' + lastname : ''}} <${email}>` : email; + const mailOptions = { + from: this.sender, + to, + subject: 'Password Recovery', + template: 'passwordRecovery', + context: { + pin, + }, + }; + return this.send(mailOptions); + } + + async send(mailOptions: any) { + return new Promise((resolve, reject) => { + this.transporter.sendMail(mailOptions, (error, info) => { + if (error) { + reject(error); + } else { + resolve(info); + } + }); + }); + } +} diff --git a/src/server/types/environment.d.ts b/src/server/types/environment.d.ts new file mode 100644 index 0000000..c05e6a6 --- /dev/null +++ b/src/server/types/environment.d.ts @@ -0,0 +1,11 @@ +export {}; + +declare global { + namespace NodeJS { + interface ProcessEnv { + DB_PORT: number; + DB_USER: string; + ENV: 'test' | 'dev' | 'prod'; + } + } +} \ No newline at end of file diff --git a/src/server/types/express/index.d.ts b/src/server/types/express/index.d.ts new file mode 100644 index 0000000..5d50eac --- /dev/null +++ b/src/server/types/express/index.d.ts @@ -0,0 +1,8 @@ +import 'express-serve-static-core'; + +declare module 'express-serve-static-core' { + interface Request { + user?: any; + token?: any; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b6fd952..1582ae3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,7 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": ["./src/server/types"], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */