diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..554fe3a --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +NODE_ENV=development +DB_PORT=5432 +DB_TYPE=postgres +DB_HOST=db +DB_USERNAME= +DB_PASSWORD= +DB_NAME=postgres +DB_LOGGING= +JWT_SECRET= +JWT_VERIFICATION_TOKEN_SECRET= +JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=3600 +EMAIL_CONFIRMATION_URL= +EMAIL_SERVICE= +EMAIL_USER= +EMAIL_PASS= +ENCRYPTION_KEY= +REDIRECT_BASE_URL = \ No newline at end of file diff --git a/package.json b/package.json index 725058a..797e398 100644 --- a/package.json +++ b/package.json @@ -27,20 +27,28 @@ "@nestjs-modules/mailer": "^2.0.2", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.0.1", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "err-code": "^3.0.1", + "fast-geoip": "^1.1.88", + "geoip-lite": "^1.4.10", "nodemailer": "^7.0.9", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.27" + "typeorm": "^0.3.27", + "ua-parser-js": "^2.0.6", + "useragent": "^2.3.0" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -50,10 +58,12 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", + "@types/geoip-lite": "^1.4.4", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/nodemailer": "^7.0.2", "@types/supertest": "^6.0.2", + "@types/useragent": "^2.3.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -86,5 +96,5 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" }, - "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" + "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0260525..2175794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@nestjs/core': specifier: ^11.0.1 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/jwt': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)) @@ -26,6 +29,12 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/schedule': + specifier: ^6.0.1 + version: 6.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/throttler': + specifier: ^6.4.0 + version: 6.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3))) @@ -38,6 +47,15 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 + err-code: + specifier: ^3.0.1 + version: 3.0.1 + fast-geoip: + specifier: ^1.1.88 + version: 1.1.88 + geoip-lite: + specifier: ^1.4.10 + version: 1.4.10 nodemailer: specifier: ^7.0.9 version: 7.0.9 @@ -59,6 +77,12 @@ importers: typeorm: specifier: ^0.3.27 version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)) + ua-parser-js: + specifier: ^2.0.6 + version: 2.0.6 + useragent: + specifier: ^2.3.0 + version: 2.3.0 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -81,6 +105,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.3 + '@types/geoip-lite': + specifier: ^1.4.4 + version: 1.4.4 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -93,6 +120,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/useragent': + specifier: ^2.3.4 + version: 2.3.4 eslint: specifier: ^9.18.0 version: 9.37.0 @@ -926,6 +956,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/jwt@11.0.0': resolution: {integrity: sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==} peerDependencies: @@ -943,6 +979,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.0.1': + resolution: {integrity: sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.8': resolution: {integrity: sha512-HKunkzfBYLpNyL/qP5wu0OBKVPrISJLnrB4r6S53fT99pEvopDcJAeIuznSAD1Dx1njUqpbTR/uGyD0xL1y0nw==} peerDependencies: @@ -961,6 +1003,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.4.0': + resolution: {integrity: sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@nestjs/typeorm@11.0.0': resolution: {integrity: sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==} peerDependencies: @@ -1256,6 +1305,9 @@ packages: '@types/express@5.0.3': resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} + '@types/geoip-lite@1.4.4': + resolution: {integrity: sha512-2uVfn+C6bX/H356H6mjxsWUA5u8LO8dJgSBIRO/NFlpMe4DESzacutD/rKYrTDKm1Ugv78b4Wz1KvpHrlv3jSw==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1277,6 +1329,9 @@ packages: '@types/jsonwebtoken@9.0.7': resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -1322,6 +1377,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/useragent@2.3.4': + resolution: {integrity: sha512-4pGiUe4NptP06CwsW+pY/3bgGCUdMf8+DrtyJ+jNR1UTPnSEA57TsskdBtE+oq+GpPCXIR+n51RvzKIsyy4rrw==} + '@types/validator@13.15.3': resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} @@ -1670,9 +1728,19 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-never@1.4.0: resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -1683,6 +1751,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1722,6 +1796,9 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -1765,6 +1842,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -1815,6 +1895,9 @@ packages: caniuse-lite@1.0.30001748: resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -1976,6 +2059,9 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1995,6 +2081,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.3.3: + resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==} + engines: {node: '>=18.x'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -2010,6 +2100,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} @@ -2056,6 +2150,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2119,6 +2216,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2171,6 +2271,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2304,6 +2407,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2331,12 +2437,22 @@ packages: extend-object@1.0.0: resolution: {integrity: sha512-0dHDIXC7y7LDmCh/lp1oYkmv73K25AMugQI07r8eFopkW6f7Ufn1q+ETMsJjnV9Am14SlElkqy3O92r6xEaxPw==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-geoip@1.1.88: + resolution: {integrity: sha512-rGBGyHTKwNtdXmlgX1IDaf0gskqzQRXR4EWFCuD6V4YSrAzhVZCS53kvKFECQkEylXd0I8rEPV7x4xhhRA4ymw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2363,6 +2479,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -2412,6 +2531,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -2419,6 +2541,10 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -2457,6 +2583,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geoip-lite@1.4.10: + resolution: {integrity: sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==} + engines: {node: '>=10.3.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2485,6 +2615,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2537,6 +2670,15 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2585,6 +2727,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2631,6 +2777,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-address@5.9.4: + resolution: {integrity: sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==} + engines: {node: '>= 0.10'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2692,6 +2842,9 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -2704,6 +2857,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2718,6 +2874,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2913,6 +3072,12 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2930,9 +3095,15 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2948,6 +3119,10 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + jstransformer@1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} @@ -2965,6 +3140,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lazy@1.0.11: + resolution: {integrity: sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==} + engines: {node: '>=0.2.0'} + leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} @@ -3059,9 +3238,16 @@ packages: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} + lru-cache@4.1.5: + resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -3362,6 +3548,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3393,6 +3582,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + p-event@4.2.0: resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} engines: {node: '>=8'} @@ -3508,6 +3701,12 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -3620,6 +3819,12 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pseudomap@1.0.2: + resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pug-attrs@3.0.0: resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} @@ -3671,6 +3876,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.5.3: + resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3711,6 +3920,11 @@ packages: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3744,6 +3958,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3778,6 +3997,10 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@5.5.1: + resolution: {integrity: sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==} + hasBin: true + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3885,10 +4108,18 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.2: + resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==} + sql-highlight@6.1.0: resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} engines: {node: '>=14'} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -4020,6 +4251,10 @@ packages: resolution: {integrity: sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==} hasBin: true + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -4042,6 +4277,10 @@ packages: resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} engines: {node: '>=14.16'} + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -4114,6 +4353,12 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4218,6 +4463,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.6: + resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -4260,6 +4512,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + useragent@2.3.0: + resolution: {integrity: sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4271,6 +4526,11 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -4294,6 +4554,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -4387,9 +4651,15 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@2.1.2: + resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yamlparser@0.0.2: + resolution: {integrity: sha512-Cou9FCGblEENtn1/8La5wkDM/ISMh2bzu5Wh7dYzCzA0o9jD4YGyLkUJxe84oPBGoB92f+Oy4ZjVhA8S0C2wlQ==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4398,6 +4668,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -5626,6 +5899,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + '@nestjs/jwt@11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5649,6 +5928,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.3.3 + '@nestjs/schematics@11.0.8(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -5679,6 +5964,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/throttler@6.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3)))': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -6095,6 +6386,8 @@ snapshots: '@types/express-serve-static-core': 5.0.7 '@types/serve-static': 1.15.9 + '@types/geoip-lite@1.4.4': {} + '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -6118,6 +6411,8 @@ snapshots: dependencies: '@types/node': 22.18.8 + '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -6177,6 +6472,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/useragent@2.3.4': {} + '@types/validator@13.15.3': {} '@types/yargs-parser@21.0.3': {} @@ -6524,9 +6821,19 @@ snapshots: asap@2.0.6: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assert-never@1.4.0: optional: true + assert-plus@1.0.0: {} + + async@2.6.4: + dependencies: + lodash: 4.17.21 + async@3.2.6: optional: true @@ -6536,6 +6843,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + babel-jest@30.2.0(@babel/core@7.28.4): dependencies: '@babel/core': 7.28.4 @@ -6599,6 +6910,10 @@ snapshots: baseline-browser-mapping@2.8.12: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bcrypt@6.0.0: dependencies: node-addon-api: 8.5.0 @@ -6661,6 +6976,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -6712,6 +7029,8 @@ snapshots: caniuse-lite@1.0.30001748: {} + caseless@0.12.0: {} + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -6883,6 +7202,8 @@ snapshots: cookiejar@2.1.4: {} + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.5: @@ -6901,6 +7222,11 @@ snapshots: create-require@1.1.1: {} + cron@4.3.3: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -6928,6 +7254,10 @@ snapshots: css-what@6.2.2: optional: true + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + dayjs@1.11.18: {} debug@4.4.3: @@ -6957,6 +7287,8 @@ snapshots: depd@2.0.0: {} + detect-europe-js@0.1.2: {} + detect-indent@6.1.0: optional: true @@ -7037,6 +7369,11 @@ snapshots: eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -7083,6 +7420,8 @@ snapshots: entities@6.0.1: optional: true + err-code@3.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7216,6 +7555,8 @@ snapshots: etag@1.8.1: {} + eventemitter2@6.4.9: {} + events@3.3.0: {} execa@0.10.0: @@ -7287,10 +7628,16 @@ snapshots: extend-object@1.0.0: optional: true + extend@3.0.2: {} + + extsprintf@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} + fast-geoip@1.1.88: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7319,6 +7666,10 @@ snapshots: dependencies: bser: 2.1.1 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -7390,6 +7741,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.8.3)(webpack@5.100.2): dependencies: '@babel/code-frame': 7.27.1 @@ -7407,6 +7760,12 @@ snapshots: typescript: 5.8.3 webpack: 5.100.2 + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -7442,6 +7801,16 @@ snapshots: gensync@1.0.0-beta.2: {} + geoip-lite@1.4.10: + dependencies: + async: 2.6.4 + chalk: 4.1.2 + iconv-lite: 0.6.3 + ip-address: 5.9.4 + lazy: 1.0.11 + rimraf: 2.7.1 + yauzl: 2.10.0 + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -7472,6 +7841,10 @@ snapshots: get-stream@6.0.1: {} + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7536,6 +7909,13 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.12.6 + har-schema: 2.0.0 + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -7609,6 +7989,12 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -7647,6 +8033,12 @@ snapshots: ini@1.3.8: optional: true + ip-address@5.9.4: + dependencies: + jsbn: 1.1.0 + lodash: 4.17.21 + sprintf-js: 1.1.2 + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -7699,6 +8091,8 @@ snapshots: hasown: 2.0.2 optional: true + is-standalone-pwa@0.1.1: {} + is-stream@1.1.0: optional: true @@ -7708,6 +8102,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: {} is-wsl@2.2.0: @@ -7719,6 +8115,8 @@ snapshots: isexe@2.0.0: {} + isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: @@ -8119,6 +8517,10 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@0.1.1: {} + + jsbn@1.1.0: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -8129,8 +8531,12 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -8154,6 +8560,13 @@ snapshots: ms: 2.1.3 semver: 7.7.2 + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jstransformer@1.0.0: dependencies: is-promise: 2.2.2 @@ -8186,6 +8599,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + lazy@1.0.11: {} + leac@0.6.0: optional: true @@ -8268,10 +8683,17 @@ snapshots: lru-cache@11.2.2: {} + lru-cache@4.1.5: + dependencies: + pseudomap: 1.0.2 + yallist: 2.1.2 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8783,6 +9205,8 @@ snapshots: boolbase: 1.0.0 optional: true + oauth-sign@0.9.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8826,6 +9250,8 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + os-tmpdir@1.0.2: {} + p-event@4.2.0: dependencies: p-timeout: 3.2.0 @@ -8943,6 +9369,10 @@ snapshots: peberminta@0.9.0: optional: true + pend@1.2.0: {} + + performance-now@2.1.0: {} + pg-cloudflare@1.2.7: optional: true @@ -9048,6 +9478,12 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pseudomap@1.0.2: {} + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pug-attrs@3.0.0: dependencies: constantinople: 4.0.1 @@ -9138,6 +9574,8 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.5.3: {} + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -9181,6 +9619,29 @@ snapshots: relateurl@0.2.7: optional: true + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.3 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -9207,6 +9668,10 @@ snapshots: reusify@1.1.0: {} + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -9256,6 +9721,8 @@ snapshots: parseley: 0.12.1 optional: true + semver@5.5.1: {} + semver@5.7.2: optional: true @@ -9380,8 +9847,22 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.2: {} + sql-highlight@6.1.0: {} + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -9506,6 +9987,10 @@ snapshots: tlds@1.260.0: optional: true + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + tmpl@1.0.5: {} to-buffer@1.2.2: @@ -9529,6 +10014,11 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + tr46@0.0.3: optional: true @@ -9601,6 +10091,12 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -9669,6 +10165,14 @@ snapshots: typescript@5.9.3: {} + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.6: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + uc.micro@2.1.0: optional: true @@ -9724,12 +10228,22 @@ snapshots: dependencies: punycode: 2.3.1 + useragent@2.3.0: + dependencies: + lru-cache: 4.1.5 + request: 2.88.2 + semver: 5.5.1 + tmp: 0.0.33 + yamlparser: 0.0.2 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} uuid@11.1.0: {} + uuid@3.4.0: {} + uuid@9.0.1: optional: true @@ -9748,6 +10262,12 @@ snapshots: vary@1.1.2: {} + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + void-elements@3.1.0: optional: true @@ -9881,8 +10401,12 @@ snapshots: y18n@5.0.8: {} + yallist@2.1.2: {} + yallist@3.1.1: {} + yamlparser@0.0.2: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -9895,6 +10419,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..977f4df --- /dev/null +++ b/src/analytics/analytics.controller.ts @@ -0,0 +1,30 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data'; +import { AnalyticsService } from './analytics.service'; +import type { RequestWithUser } from 'src/types/RequestWithUser'; +import { GuardService } from 'src/guard/guard.service'; + +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @UseGuards(GuardService) + @HttpCode(HttpStatus.OK) + @Get() + async filterUrlAnalytics( + @Query() query: FilterAnalyticsRequestData, + @Req() request: RequestWithUser, + ) { + const userId = request.decodedData.sub; + return this.analyticsService.getAnalytics(query, userId); + } +} diff --git a/src/analytics/analytics.entity.ts b/src/analytics/analytics.entity.ts new file mode 100644 index 0000000..a0f4538 --- /dev/null +++ b/src/analytics/analytics.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { Url } from 'src/url/url.entity'; + +@Index('IDX_url_analytics_url_id', ['urlId']) +@Index('IDX_url_analytics_redirected_at', ['redirectedAt']) +@Index('IDX_url_analytics_url_id_redirected_at', ['urlId', 'redirectedAt']) +@Entity({ name: 'url_analytics' }) +export class UrlAnalytics { + @PrimaryGeneratedColumn('uuid') + readonly id: string; + + @Column({ type: 'uuid', name: 'url_id' }) + readonly urlId: string; + + @Column({ type: 'varchar', length: 40, nullable: true, name: 'country' }) + readonly country?: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true, name: 'device' }) + readonly device?: string | null; + + @Column({ type: 'varchar', length: 40, nullable: true, name: 'os' }) + readonly os?: string | null; + + @Column({ type: 'varchar', length: 40, nullable: true, name: 'browser' }) + readonly browser?: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'ip_address' }) + readonly ipAddress?: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'user_agent' }) + readonly userAgent?: string | null; + + @CreateDateColumn({ type: 'timestamp with time zone', name: 'redirected_at' }) + readonly redirectedAt: Date; + + @ManyToOne(() => Url, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'url_id' }) + readonly url: Url; +} diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts new file mode 100644 index 0000000..2730ad0 --- /dev/null +++ b/src/analytics/analytics.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { UrlAnalytics } from './analytics.entity'; +import { AnalyticsController } from './analytics.controller'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([UrlAnalytics]), AuthModule], + providers: [AnalyticsService], + exports: [], + controllers: [AnalyticsController], +}) +export class AnalyticsModule {} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts new file mode 100644 index 0000000..b8b6088 --- /dev/null +++ b/src/analytics/analytics.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import geoip from 'geoip-lite'; +import { UrlAnalytics } from './analytics.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import useragent from 'useragent'; +import { ParsedUserAgent } from './types'; +import { OnEvent } from '@nestjs/event-emitter'; +import { UrlRedirectedEvent } from 'src/event/Url-redirected.events'; +import { FilterAnalyticsRequestData } from './dto/filter-analytics-request-data'; +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(UrlAnalytics) + private readonly analyticsRepo: Repository, + ) {} + + @OnEvent('url.redirected') + async recordClick(event: UrlRedirectedEvent): Promise { + const urlId = event.urlId; + const req = event.req; + const ipAddress = (req.headers['x-forwarded-for'] as string) + ?.split(',')[0] + ?.trim(); + + const userAgent = req.headers['user-agent'] || ''; + const parsed = ( + useragent as unknown as { + parse: (ua?: string, jsAgent?: string) => ParsedUserAgent; + } + ).parse(userAgent); + + // Match the first section inside parentheses of the User-Agent string (up to the first semicolon). + // This typically represents the device or platform, e.g. "Windows NT 10.0" or "iPhone". + const deviceMatch = parsed.source.match(/\(([^;]+);/); + const device = deviceMatch ? deviceMatch[1] : 'Unknown Device'; + + // Look for known browser names followed by a version number, + // e.g. "Chrome/120.0", "Firefox/118.0". + const browserMatch = parsed.source.match( + /(Chrome|Firefox|Safari|Edge|Opera)\/[\d.]+/, + ); + const browser = browserMatch ? browserMatch[0] : 'Unknown Browser'; + + // Match the substring inside parentheses that follows the first semicolon. + // For example, from "(Windows NT 10.0; Win64; x64)" → captures "Win64; x64". + const osMatch = parsed.source.match(/\((?:[^;]+);\s*([^)]+)\)/); + + const os = osMatch ? osMatch[1] : 'Unknown OS'; + + const geo = geoip.lookup(ipAddress); + const country = geo?.country || 'Unknown'; + + const analytics = this.analyticsRepo.create({ + urlId, + os, + ipAddress, + browser: browser, + userAgent, + device: device, + country, + }); + + await this.analyticsRepo.save(analytics); + } + + async getAnalytics(requestData: FilterAnalyticsRequestData, userId: string) { + const qb = this.analyticsRepo + .createQueryBuilder('a') + .innerJoin('a.url', 'url') + .take(10) + .skip(10); + + qb.andWhere('url.userId=:userId', { userId }); + + const start = requestData.startDate || new Date(0); + const end = requestData.endDate || new Date(); + + qb.andWhere('a.redirectedAt BETWEEN :start AND :end', { + start, + end, + }); + + if (requestData.browser) { + qb.andWhere('a.browser = :browser', { browser: requestData.browser }); + } + if (requestData.country) { + qb.andWhere('a.country = :country', { country: requestData.country }); + } + + if (requestData.device) { + qb.andWhere('a.device = :device', { device: requestData.device }); + } + + if (requestData.os) { + qb.andWhere('a.os = :os', { os: requestData.os }); + } + + const groupColumns: string[] = []; + + if (requestData.groupByUrl) groupColumns.push('a.url_id'); + if (requestData.groupByDevice) groupColumns.push('a.device'); + if (requestData.groupByOs) groupColumns.push('a.os'); + if (requestData.groupByBrowser) groupColumns.push('a.browser'); + if (requestData.groupByCountry) groupColumns.push('a.country'); + if (requestData.groupByIpAddress) groupColumns.push('a.ip_address'); + + if (groupColumns.length > 0) { + qb.select(groupColumns.join(', ')) + .addSelect('COUNT(*)', 'hits') + .groupBy(groupColumns.join(', ')) + .orderBy('hits', 'DESC'); + + return qb.getRawMany(); + } + + return await qb.getMany(); + } +} diff --git a/src/analytics/dto/filter-analytics-request-data.ts b/src/analytics/dto/filter-analytics-request-data.ts new file mode 100644 index 0000000..af20799 --- /dev/null +++ b/src/analytics/dto/filter-analytics-request-data.ts @@ -0,0 +1,54 @@ +import { IsDateString, IsOptional } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class FilterAnalyticsRequestData { + @IsOptional() + browser?: string | null; + + @IsOptional() + device?: string | null; + + @IsOptional() + groupByUrl?: boolean | null; + + @IsOptional() + groupByDevice?: boolean | null; + + @IsOptional() + groupByIpAddress?: boolean | null; + + @IsOptional() + groupByOs?: boolean | null; + + @IsOptional() + groupByCountry?: boolean | null; + + @IsOptional() + groupByBrowser?: boolean | null; + + @IsOptional() + urlId?: string | null; + + @IsOptional() + os?: string | null; + + @IsOptional() + country?: string | null; + + @IsOptional() + ip?: string | null; + + @IsOptional() + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, + }) + startDate?: Date | null; + + @IsOptional() + @IsDateString() + @Transform(({ value }) => new Date(value).toUTCString(), { + toPlainOnly: true, + }) + endDate?: Date | null; +} diff --git a/src/analytics/types.ts b/src/analytics/types.ts new file mode 100644 index 0000000..abf428c --- /dev/null +++ b/src/analytics/types.ts @@ -0,0 +1,7 @@ +export type ParsedUserAgent = { + family: string; + major: string; + minor: string; + patch: string; + source: string; +}; diff --git a/src/app.module.ts b/src/app.module.ts index a52745f..4bdb2c6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,16 +4,29 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; import { EmailModule } from './email/email.module'; +import { UrlModule } from './url/url.module'; import dataSource from './data-source'; +import { ScheduleModule } from '@nestjs/schedule'; +import { CronModule } from './cron/cron.module'; +import { AnalyticsModule } from './analytics/analytics.module'; +import { GuardService } from './guard/guard.service'; +import { GuardModule } from './guard/guard.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ + ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), TypeOrmModule.forRoot(dataSource.options), UserModule, AuthModule, EmailModule, + UrlModule, + CronModule, + AnalyticsModule, + GuardModule, ], controllers: [AppController], - providers: [], + providers: [GuardService], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index ea26a05..d92bf93 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -17,7 +17,7 @@ import { LoginRequestData } from './dto/login-user-dto'; export class AuthController { constructor(private readonly authService: AuthService) {} - @HttpCode(HttpStatus.OK) + @HttpCode(HttpStatus.CREATED) @Post('sign-up') async signup(@Body() signupRequestData: SignupRequestData) { return this.authService.signUp(signupRequestData); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 8de4000..e3cb563 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,14 +4,16 @@ import { AuthController } from './auth.controller'; import { JwtModule } from '@nestjs/jwt'; import { CryptoService } from './crypto.service'; import { EmailModule } from 'src/email/email.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { EmailVerification } from './email-verification.entity'; import { User } from 'src/user/user.entity'; -import { EmailVerification } from 'src/auth/email-verification.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserModule } from 'src/user/user.module'; @Module({ imports: [ TypeOrmModule.forFeature([User, EmailVerification]), EmailModule, + UserModule, JwtModule.register({ global: true, secret: process.env.JWT_SECRET, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 99398df..dcbeb86 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { SignupRequestData } from './dto/signup-user-dto'; import { JwtService } from '@nestjs/jwt'; import { EmailService } from 'src/email/email.service'; @@ -11,6 +15,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { EmailVerification } from './email-verification.entity'; import { EmailVerificationPayload } from './interface'; import { EmailMessages } from './messages'; +import { JwtPayload } from 'src/types/JwtPayload'; +import { UserService } from '../user/user.service'; @Injectable() export class AuthService { @@ -22,52 +28,24 @@ export class AuthService { @InjectRepository(EmailVerification) private readonly emailVerificationRepo: Repository, private readonly cryptoService: CryptoService, + private readonly userService: UserService, ) {} - async signUp( - signUpUserDto: SignupRequestData, - ): Promise<{ access_token: string }> { - const userByUsername = await this.userRepository.findOne({ - where: { username: signUpUserDto.username }, - }); - - if (userByUsername) { - throw new BadRequestException('Username already taken'); - } - - const userByEmail = await this.userRepository.findOne({ - where: { email: signUpUserDto.email }, - }); - - if (userByEmail) { - throw new BadRequestException('Email already taken'); - } - + async signUp(signUpUserDto: SignupRequestData): Promise { const hashedPassword = await this.cryptoService.hashPassword( signUpUserDto.password, ); - const { email, fullName, username } = signUpUserDto; - - const user = this.userRepository.create({ + const createUserRequestData: SignupRequestData = { + ...signUpUserDto, password: hashedPassword, - email, - fullName, - username, - }); - - await this.userRepository.save(user); + }; - try { - await this.sendVerificationLink(email); - console.log('Verification email sent to:', email); - } catch (error) { - console.error('Failed to send verification email:', error); - } + const user = await this.userService.create(createUserRequestData); - const payload = { sub: user.id, username: user.username }; + await this.sendVerificationLink(user.email); - return { access_token: await this.jwtService.signAsync(payload) }; + return user; } async sendVerificationLink(email: string) { @@ -80,6 +58,10 @@ export class AuthService { throw new BadRequestException('User is already verified'); } + await this.emailVerificationRepo.delete({ + userId: user.id, + }); + const payload: EmailVerificationPayload = { email, }; @@ -102,85 +84,83 @@ export class AuthService { const text = `Welcome to the application. To confirm the email address, click here: ${url}`; - try { - await this.emailService.sendMail({ - to: email, - subject: 'Email confirmation', - text, - }); - return { - message: EmailMessages.emailSendSuccess, - }; - } catch (error) { - console.error('Failed to send verification email', error); - throw new Error(EmailMessages.emailSendFailed); - } + await this.emailService.sendMail({ + to: email, + subject: 'Email confirmation', + text, + }); + return { + message: EmailMessages.emailSendSuccess, + }; } async verify(token: string) { - try { - const payload = this.jwtService.verify(token, { - secret: process.env.JWT_VERIFICATION_TOKEN_SECRET, - }); - - const record = await this.emailVerificationRepo.findOneByOrFail({ - token, - }); - - if (record.expiresAt < new Date()) { - throw new BadRequestException('Token has expired'); - } - - await this.emailVerificationRepo.save(record); - - const user = await this.userRepository.findOne({ - where: { - email: payload.email, - }, - }); - if (!user) throw new Error('User not found'); - - await this.userRepository.update(user.id, { verifiedAt: new Date() }); - - await this.emailVerificationRepo.delete({ token }); - - return { message: EmailMessages.emailVerifySuccess }; - } catch (error) { - throw new BadRequestException({ - message: 'Something went wrong during signup', - error: (error as Error)?.message, - }); + const payload = this.jwtService.verify(token, { + secret: process.env.JWT_VERIFICATION_TOKEN_SECRET, + }); + + const record = await this.emailVerificationRepo.findOne({ + where: { token }, + }); + + if (!record) { + throw new NotFoundException('Token not found or has expired'); + } + + if (record.expiresAt < new Date()) { + throw new BadRequestException('Token has expired'); } + + await this.emailVerificationRepo.save(record); + + const user = await this.userRepository.findOne({ + where: { + email: payload.email, + }, + }); + if (!user) throw new Error('User not found'); + + await this.userRepository.update(user.id, { verifiedAt: new Date() }); + + await this.emailVerificationRepo.delete({ token }); + + return { message: EmailMessages.emailVerifySuccess }; } async login( loginRequestData: LoginRequestData, ): Promise<{ accessToken: string }> { - try { - const user = await this.userRepository.findOne({ - where: { - email: loginRequestData.email, - }, - }); - if (!user) { - throw new BadRequestException('User not found'); - } - - const match = await bcrypt.compare( - loginRequestData.password, - user.password, + const user = await this.userRepository.findOne({ + where: { + email: loginRequestData.email, + }, + }); + if (!user) { + throw new BadRequestException('User not found'); + } + + if (user.verifiedAt === null) { + throw new BadRequestException( + 'User not verified. Please verify before login', ); - const payload = { sub: user.id, username: user.username }; - - if (match) { - return { accessToken: await this.jwtService.signAsync(payload) }; - } else { - throw new BadRequestException('Invalid email or password'); - } - } catch (error) { - throw new BadRequestException({ - message: 'Something went wrong during login', - error: (error as Error)?.message, - }); } + + const match = await bcrypt.compare( + loginRequestData.password, + user.password, + ); + const payload = { sub: user.id, username: user.username }; + + if (match) { + return { accessToken: await this.jwtService.signAsync(payload) }; + } else { + throw new BadRequestException('Invalid email or password'); + } + } + + async validateToken(token: string): Promise { + const decoded = await this.jwtService.verifyAsync(token, { + secret: process.env.JWT_SECRET, + }); + return decoded; } } diff --git a/src/auth/email-verification.entity.ts b/src/auth/email-verification.entity.ts index 848d7f1..131351a 100644 --- a/src/auth/email-verification.entity.ts +++ b/src/auth/email-verification.entity.ts @@ -6,10 +6,12 @@ import { CreateDateColumn, JoinColumn, OneToOne, + Index, } from 'typeorm'; @Entity('email_verifications') export class EmailVerification { + @Index() @PrimaryGeneratedColumn('uuid') readonly id: string; @@ -22,6 +24,7 @@ export class EmailVerification { @JoinColumn({ name: 'user_id' }) readonly user: User; + @Index('IDX_email_verifications_token') @Column({ length: 255 }) readonly token: string; diff --git a/src/cron/check-url-expiry.service.ts b/src/cron/check-url-expiry.service.ts new file mode 100644 index 0000000..0d83758 --- /dev/null +++ b/src/cron/check-url-expiry.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { EmailService } from '../email/email.service'; +import { UserService } from '../user/user.service'; +import { UrlService } from '../url/url.service'; + +@Injectable() +export class CheckUrlExpiry { + constructor( + private readonly emailService: EmailService, + private readonly urlService: UrlService, + private readonly userService: UserService, + ) {} + + @Cron(CronExpression.EVERY_30_SECONDS) + async checkUrls() { + const expiredUrls = await this.urlService.checkExpiredUrl(); + for (const url of expiredUrls) { + const user = await this.userService.findOneByField('id', url.userId); + await this.emailService.sendMail({ + to: user.email, + subject: `Your ${url.title} URL has expired`, + text: `Hi ${user.fullName} your ${url.title} url has expired!`, + }); + await this.urlService.update(user.id, url.id, { + ...url, + expiryAlertedAt: new Date(Date.now()), + }); + } + } +} diff --git a/src/cron/cron.module.ts b/src/cron/cron.module.ts new file mode 100644 index 0000000..0d93148 --- /dev/null +++ b/src/cron/cron.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Url } from 'src/url/url.entity'; +import { User } from 'src/user/user.entity'; +import { EmailModule } from 'src/email/email.module'; +import { CheckUrlExpiry } from './check-url-expiry.service'; +import { UserModule } from 'src/user/user.module'; +import { UrlModule } from 'src/url/url.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Url, User]), + EmailModule, + UserModule, + UrlModule, + ], + controllers: [], + providers: [CheckUrlExpiry], + exports: [], +}) +export class CronModule {} diff --git a/src/data-source.ts b/src/data-source.ts index d22221b..f0c36f9 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -4,7 +4,7 @@ const dataSource = new DataSource({ type: 'postgres', host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 5432, - username: process.env.DB_USERNAME, + username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, logging: true, diff --git a/src/email/dto/send-verification-request-data.ts b/src/email/dto/send-verification-request-data.ts new file mode 100644 index 0000000..a616c26 --- /dev/null +++ b/src/email/dto/send-verification-request-data.ts @@ -0,0 +1,7 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class SendVerificationRequestData { + @IsEmail() + @IsNotEmpty({ message: 'Email is required' }) + email: string; +} diff --git a/src/email/dto/verification-token-request-data.ts b/src/email/dto/verification-token-request-data.ts new file mode 100644 index 0000000..2a997dd --- /dev/null +++ b/src/email/dto/verification-token-request-data.ts @@ -0,0 +1,7 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class VerificationTokenRequestData { + @IsEmail() + @IsNotEmpty({ message: 'Email is required' }) + email: string; +} diff --git a/src/email/email.service.ts b/src/email/email.service.ts index b7c788b..bf73f41 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -1,7 +1,6 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { createTransport } from 'nodemailer'; import Mail from 'nodemailer/lib/mailer'; - @Injectable() export class EmailService { private nodemailerTransport: Mail; @@ -19,9 +18,11 @@ export class EmailService { async sendMail(options: Mail.Options) { try { await this.nodemailerTransport.sendMail(options); - } catch (error) { - console.error('Failed to send email: ', error); - throw new InternalServerErrorException('Failed to send email'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new InternalServerErrorException( + 'Failed to send email: ' + message, + ); } } } diff --git a/src/event/Url-redirected.events.ts b/src/event/Url-redirected.events.ts new file mode 100644 index 0000000..3733726 --- /dev/null +++ b/src/event/Url-redirected.events.ts @@ -0,0 +1,8 @@ +import { RequestWithUser } from 'src/types/RequestWithUser'; + +export class UrlRedirectedEvent { + constructor( + public readonly urlId: string, + public readonly req: RequestWithUser, + ) {} +} diff --git a/src/guard/guard.module.ts b/src/guard/guard.module.ts new file mode 100644 index 0000000..6115d27 --- /dev/null +++ b/src/guard/guard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from 'src/auth/auth.module'; +import { GuardService } from './guard.service'; + +@Module({ + imports: [AuthModule], + providers: [GuardService], + controllers: [], + exports: [GuardService], +}) +export class GuardModule {} diff --git a/src/user/user.guards.ts b/src/guard/guard.service.ts similarity index 55% rename from src/user/user.guards.ts rename to src/guard/guard.service.ts index 3a1fb52..537d367 100644 --- a/src/user/user.guards.ts +++ b/src/guard/guard.service.ts @@ -5,12 +5,12 @@ import { UnauthorizedException, } from '@nestjs/common'; -import { UserService } from './user.service'; -import { RequestWithUser } from './types/RequestWithUser'; +import { AuthService } from 'src/auth/auth.service'; +import { RequestWithUser } from '../types/RequestWithUser'; @Injectable() -export class UserGuard implements CanActivate { - constructor(private readonly userService: UserService) {} +export class GuardService implements CanActivate { + constructor(private readonly authService: AuthService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -22,13 +22,14 @@ export class UserGuard implements CanActivate { const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - try { - const decoded = await this.userService.validateToken(token); - request.decodedData = decoded; - return true; - } catch (err) { - console.error('Token validation error:', err); - throw new UnauthorizedException('Invalid or expired token'); + const decoded = await this.authService.validateToken(token); + request.decodedData = decoded; + const userData = request.decodedData; + + if (!userData) { + throw new UnauthorizedException('Invalid or missing token'); } + + return true; } } diff --git a/src/main.ts b/src/main.ts index 12c5564..41b59f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,11 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; +import { RateLimitMiddleware } from './middleware/rate-limit-midddleware'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.use(new RateLimitMiddleware().use); app.useGlobalPipes( new ValidationPipe({ transform: true, @@ -16,6 +18,6 @@ bootstrap() .then(() => { console.log('API started'); }) - .catch((err) => { - console.error(err); + .catch((error) => { + if (error instanceof Error) throw error; }); diff --git a/src/middleware/rate-limit-midddleware.ts b/src/middleware/rate-limit-midddleware.ts new file mode 100644 index 0000000..a0966fc --- /dev/null +++ b/src/middleware/rate-limit-midddleware.ts @@ -0,0 +1,35 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +interface RateLimitInfo { + count: number; + timer: NodeJS.Timeout; +} + +const ipMap = new Map(); + +@Injectable() +export class RateLimitMiddleware implements NestMiddleware { + use = (req: Request, res: Response, next: NextFunction) => { + const ip: string = req.ip || 'unknown'; + const limit = 5; + const ttl = 1000; + + if (!ipMap.has(ip)) { + const timer = setTimeout(() => ipMap.delete(ip), ttl); + ipMap.set(ip, { count: 1, timer }); + return next(); + } + + const data = ipMap.get(ip)!; + if (data.count >= limit) { + res.setHeader('Retry-After', ttl / 1000); + return res.status(429).json({ + message: 'Too many requests from this IP, please try again later.', + }); + } + + data.count += 1; + next(); + }; +} diff --git a/src/migrations/1760416146118-createTable.ts b/src/migrations/1760500000000-createUserTable.ts similarity index 59% rename from src/migrations/1760416146118-createTable.ts rename to src/migrations/1760500000000-createUserTable.ts index bb0b50f..89e5420 100644 --- a/src/migrations/1760416146118-createTable.ts +++ b/src/migrations/1760500000000-createUserTable.ts @@ -11,12 +11,20 @@ export class CreateUsersTable1760500000000 implements MigrationInterface { "password" varchar NOT NULL, "verified_at" timestamp with time zone DEFAULT NULL, "created_at" timestamp with time zone DEFAULT now(), - "last_login_at" timestamp with time zone DEFAULT NULL + "last_login_at" timestamp with time zone DEFAULT NULL, + "deleted_at" timestamp with time zone DEFAULT NULL ); + + CREATE INDEX "IDX_users_full_name" ON "users" ("full_name"); + CREATE INDEX "IDX_users_created_at" ON "users" ("created_at"); `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "users";`); + await queryRunner.dropIndex('users', 'IDX_users_created_at'); + await queryRunner.query(` + DROP INDEX IF EXIST "IDX_users_full_name"; + DROP INDEX IF EXISTS "IDX_users_created_at"; + DROP TABLE "users";`); } } diff --git a/src/migrations/1760522072024-createUrlTable.ts b/src/migrations/1760522072024-createUrlTable.ts new file mode 100644 index 0000000..1eb312f --- /dev/null +++ b/src/migrations/1760522072024-createUrlTable.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUrlTable1760522072024 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "urls" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "encrypted_url" varchar (2048) NOT NULL, + "title" varchar (64) NOT NULL, + "short_code" varchar (255) NOT NULL UNIQUE, + "created_at" timestamp with time zone DEFAULT now(), + "deleted_at" timestamp with time zone DEFAULT NULL, + "original_url" VARCHAR(64) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone DEFAULT NULL, + "expiry_alerted_at" timestamp with time zone DEFAULT NULL, + CONSTRAINT "fk_user_urls" + FOREIGN KEY ("user_id") + REFERENCES "users" ("id") ON DELETE CASCADE + ); + + CREATE INDEX "IDX_urls_user_id" ON "urls" ("user_id"); + + CREATE INDEX "IDX_urls_expires_at" ON "urls" ("expires_at"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "IDX_urls_expires_at"; + DROP INDEX IF EXISTS "IDX_urls_user_id"; + DROP TABLE "urls";`); + } +} diff --git a/src/migrations/1760441676754-createEmailVerificationTable.ts b/src/migrations/1760600000000-createEmailVerificationTable.ts similarity index 72% rename from src/migrations/1760441676754-createEmailVerificationTable.ts rename to src/migrations/1760600000000-createEmailVerificationTable.ts index 21ddce7..7a9cde4 100644 --- a/src/migrations/1760441676754-createEmailVerificationTable.ts +++ b/src/migrations/1760600000000-createEmailVerificationTable.ts @@ -7,17 +7,22 @@ export class CreateEmailVerificationsTable1760600000000 await queryRunner.query(` CREATE TABLE "email_verifications" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), - "user_id" uuid NOT NULL, + "user_id" uuid NOT NULL UNIQUE, "token" varchar(255) NOT NULL, "created_at" timestamp with time zone DEFAULT now(), "expires_at" timestamp with time zone NOT NULL, CONSTRAINT "fk_user_email_verifications" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ); + + CREATE INDEX "IDX_email_verifications_token" + ON "email_verifications" ("token"); `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "email_verifications";`); + await queryRunner.query(` + DROP INDEX IF EXISTS "IDX_email_verifications_token"; + DROP TABLE "email_verifications";`); } } diff --git a/src/migrations/1761545874535-createAnalyticsTable.ts b/src/migrations/1761545874535-createAnalyticsTable.ts new file mode 100644 index 0000000..35d03f7 --- /dev/null +++ b/src/migrations/1761545874535-createAnalyticsTable.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAnalyticsTable1761545874535 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "url_analytics" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "url_id" uuid NOT NULL, + "redirected_at" timestamp with time zone DEFAULT NOW(), + "ip" VARCHAR(100) , + "country" VARCHAR(40) , + "os" VARCHAR(40) , + "device" VARCHAR(50), + "browser" VARCHAR(40) , + "user_agent" VARCHAR(200), + CONSTRAINT "fk_url_analytics" + FOREIGN KEY ("url_id") REFERENCES "urls" ("id") ON DELETE CASCADE + ); + + + CREATE INDEX "IDX_url_analytics_url_id" ON "url_analytics" ("url_id"); + + CREATE INDEX "IDX_url_analytics_redirected_at" ON "url_analytics" ("redirected_at"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "IDX_url_analytics_redirected_at"; + DROP INDEX IF EXISTS "IDX_url_analytics_url_id"; + DROP TABLE "url_analytics"`); + } +} diff --git a/src/migrations/1762158558309-renameIpColumn.ts b/src/migrations/1762158558309-renameIpColumn.ts new file mode 100644 index 0000000..caca28b --- /dev/null +++ b/src/migrations/1762158558309-renameIpColumn.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameIpColumn1762158558309 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + RENAME COLUMN "ip" TO "ip_address"; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + RENAME COLUMN "ip_address" TO "ip"; + `); + } +} diff --git a/src/migrations/1762159175018-relengthUserAgent.ts b/src/migrations/1762159175018-relengthUserAgent.ts new file mode 100644 index 0000000..7a4fcf8 --- /dev/null +++ b/src/migrations/1762159175018-relengthUserAgent.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RelengthUserAgent1762159175018 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + ALTER COLUMN "user_agent" TYPE TEXT;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "url_analytics" + ALTER COLUMN "user_agent" TYPE varchar(200);`); + } +} diff --git a/src/user/types/JwtPayload.ts b/src/types/JwtPayload.ts similarity index 100% rename from src/user/types/JwtPayload.ts rename to src/types/JwtPayload.ts diff --git a/src/user/types/RequestWithUser.ts b/src/types/RequestWithUser.ts similarity index 73% rename from src/user/types/RequestWithUser.ts rename to src/types/RequestWithUser.ts index 668d586..ce57a16 100644 --- a/src/user/types/RequestWithUser.ts +++ b/src/types/RequestWithUser.ts @@ -1,6 +1,5 @@ import { JwtPayload } from './JwtPayload'; import { Request } from 'express'; export interface RequestWithUser extends Request { - decodedData?: JwtPayload; - username: string; + decodedData: JwtPayload; } diff --git a/src/url/dto/create-url-request-data.ts b/src/url/dto/create-url-request-data.ts new file mode 100644 index 0000000..ffd36c7 --- /dev/null +++ b/src/url/dto/create-url-request-data.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; + +export class CreateUrlRequestData { + @IsNotEmpty({ message: 'Expiry date cannot be empty' }) + expiresAt: Date; + + @IsNotEmpty({ message: 'Original url cannot be empty' }) + originalUrl: string; + + @IsNotEmpty({ message: 'URL title cannot be empty' }) + title: string; +} diff --git a/src/url/dto/redirect-url-reuest-data.ts b/src/url/dto/redirect-url-reuest-data.ts new file mode 100644 index 0000000..9f1d0f1 --- /dev/null +++ b/src/url/dto/redirect-url-reuest-data.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class RedirectUrlRequestData { + @IsNotEmpty() + shortCode: string; +} diff --git a/src/url/dto/update-url-request-data.ts b/src/url/dto/update-url-request-data.ts new file mode 100644 index 0000000..3289da3 --- /dev/null +++ b/src/url/dto/update-url-request-data.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty } from 'class-validator'; + +export class UpdateUrlRequestData { + @IsNotEmpty() + title: string; + + @IsNotEmpty() + expiresAt: Date; +} diff --git a/src/url/url.controller.ts b/src/url/url.controller.ts new file mode 100644 index 0000000..20fb587 --- /dev/null +++ b/src/url/url.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Redirect, + Req, + UseGuards, +} from '@nestjs/common'; +import { UrlService } from './url.service'; +import { CreateUrlRequestData } from './dto/create-url-request-data'; +import { GuardService } from 'src/guard/guard.service'; +import type { RequestWithUser } from 'src/types/RequestWithUser'; +import { UpdateUrlRequestData } from './dto/update-url-request-data'; + +@Controller('urls') +export class UrlController { + constructor(private readonly urlService: UrlService) {} + + @UseGuards(GuardService) + @HttpCode(HttpStatus.CREATED) + @Post() + async createUrl( + @Body() body: CreateUrlRequestData, + @Req() request: RequestWithUser, + ) { + const userId = request.decodedData.sub; + return await this.urlService.create(userId, body); + } + + @UseGuards(GuardService) + @HttpCode(HttpStatus.OK) + @Get() + async getUrls(@Req() request: RequestWithUser) { + const userId = request.decodedData.sub; + return await this.urlService.getAll(userId); + } + + // @UseGuards(AuthGuard) + @Get(':shortCode') + @HttpCode(302) + @Redirect() + async redirect( + @Param('shortCode') shortCode: string, + @Req() req: RequestWithUser, + ) { + const { longCode } = await this.urlService.getLongUrl(shortCode, req); + return { url: longCode }; + } + + @UseGuards(GuardService) + @HttpCode(HttpStatus.OK) + @Patch(':id') + async updateUrl( + @Req() request: RequestWithUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() + body: Partial, + ) { + const userId = request.decodedData.sub; + return await this.urlService.update(userId, id, body); + } + + @UseGuards(GuardService) + @HttpCode(HttpStatus.NO_CONTENT) + @Delete(':id') + async deleteUrl( + @Req() request: RequestWithUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + const userId = request.decodedData.sub; + await this.urlService.delete(userId, id); + return { message: 'URL deleted successfully' }; + } +} diff --git a/src/url/url.entity.ts b/src/url/url.entity.ts new file mode 100644 index 0000000..1175cb0 --- /dev/null +++ b/src/url/url.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; +import { User } from 'src/user/user.entity'; + +@Index('IDX_urls_user_id', ['userId']) +@Index('IDX_urls_expires_at', ['expiresAt']) +@Entity({ name: 'urls' }) +export class Url { + @PrimaryGeneratedColumn('uuid') + readonly id: string; + + @Column({ type: 'uuid', name: 'user_id' }) + readonly userId: string; + + @Column({ type: 'varchar', length: 2048, name: 'encrypted_url' }) + readonly encryptedUrl: string; + + @Column({ type: 'varchar', length: 64, name: 'title' }) + readonly title: string; + + @Column({ type: 'varchar', length: 255, unique: true, name: 'short_code' }) + readonly shortCode: string; + + @Column({ type: 'varchar', length: 64, unique: true, name: 'original_url' }) + readonly originalUrl: string; + + @Column({ + type: 'timestamp with time zone', + name: 'expiry_alerted_at', + nullable: true, + default: null, + }) + readonly expiryAlertedAt: Date | null; + + @DeleteDateColumn({ + type: 'timestamp with time zone', + nullable: true, + name: 'deleted_at', + }) + readonly deletedAt?: Date | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + readonly user: User; + + @Column({ + type: 'timestamp with time zone', + name: 'expires_at', + }) + readonly expiresAt: Date; + + @CreateDateColumn({ type: 'timestamp with time zone', name: 'created_at' }) + readonly createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' }) + readonly updatedAt: Date; +} diff --git a/src/url/url.module.ts b/src/url/url.module.ts new file mode 100644 index 0000000..351cfe9 --- /dev/null +++ b/src/url/url.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { UrlController } from './url.controller'; +import { UrlService } from './url.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Url } from './url.entity'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Url]), AuthModule], + controllers: [UrlController], + providers: [UrlService], + exports: [UrlService], +}) +export class UrlModule {} diff --git a/src/url/url.service.ts b/src/url/url.service.ts new file mode 100644 index 0000000..a56aa1e --- /dev/null +++ b/src/url/url.service.ts @@ -0,0 +1,138 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Url } from './url.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, LessThan, MoreThan, Repository } from 'typeorm'; +import { CreateUrlRequestData } from './dto/create-url-request-data'; +import { + CodeGenerator, + decrypt, + encrypt, + hashString, +} from './utils/crypto-helper'; +import { RequestWithUser } from 'src/types/RequestWithUser'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UrlRedirectedEvent } from 'src/event/Url-redirected.events'; +@Injectable() +export class UrlService { + constructor( + @InjectRepository(Url) + private readonly urlRepository: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + async create( + userId: string, + createUrlRequestData: CreateUrlRequestData, + ): Promise { + const hashUrl = hashString(createUrlRequestData.originalUrl); + + const existingUrl = await this.urlRepository.findOne({ + where: { originalUrl: hashUrl }, + }); + + if (existingUrl) { + throw new BadRequestException('short url for this URL already exists'); + } + const shortCode = CodeGenerator(); + const encryptedUrl = encrypt(createUrlRequestData.originalUrl); + const url = this.urlRepository.create({ + title: createUrlRequestData.title, + userId: userId, + shortCode: shortCode, + encryptedUrl: encryptedUrl, + expiresAt: createUrlRequestData.expiresAt, + originalUrl: hashUrl, + }); + return await this.urlRepository.save(url); + } + + async checkExpiredUrl(): Promise { + const expiredUrl = await this.urlRepository.find({ + where: { expiresAt: LessThan(new Date()), expiryAlertedAt: IsNull() }, + }); + return expiredUrl; + } + + async getLongUrl( + shortCode: string, + req: RequestWithUser, + ): Promise<{ longCode: string }> { + if (!shortCode) { + throw new BadRequestException('Short code is required'); + } + const url = await this.urlRepository.findOne({ + where: { + shortCode, + expiresAt: MoreThan(new Date()), + }, + }); + + if (!url) { + throw new NotFoundException('URL not found'); + } + + if (url.expiresAt && new Date(url.expiresAt) < new Date()) { + throw new NotFoundException('This URL has expired'); + } + + const decryptedUrl = decrypt(url.encryptedUrl); + + const event = new UrlRedirectedEvent(url.id, req); + this.eventEmitter.emit('url.redirected', event); + + return { longCode: decryptedUrl }; + } + + async getAll( + userId: string, + ): Promise< + (Pick & { shortCode: string })[] + > { + const urls: Pick[] = + await this.urlRepository.find({ + where: { userId }, + select: ['id', 'title', 'shortCode', 'expiresAt'], + }); + + return urls.map((item) => ({ + ...item, + shortCode: `${process.env.REDIRECT_BASE_URL}${item.shortCode}`, + })); + } + + async update( + userId: string, + urlId: string, + updateData: Partial, + ): Promise<{ message: string }> { + const existingUrl = await this.urlRepository.findOneBy({ + id: urlId, + userId: userId, + }); + + if (!existingUrl) { + throw new NotFoundException(`Url with ID ${urlId} not found`); + } + + await this.urlRepository.update(urlId, updateData); + return { message: 'URL updated succesfully' }; + } + + async delete(userId: string, urlId: string): Promise<{ message: string }> { + const existingUrl = await this.urlRepository.findOneBy({ + id: urlId, + userId: userId, + }); + + if (!existingUrl) { + throw new NotFoundException(`Url with ID ${urlId} not found`); + } + + await this.urlRepository.softDelete({ id: urlId }); + return { message: 'URL deleted succesfully' }; + } +} diff --git a/src/url/utils/crypto-helper.ts b/src/url/utils/crypto-helper.ts new file mode 100644 index 0000000..e210df7 --- /dev/null +++ b/src/url/utils/crypto-helper.ts @@ -0,0 +1,34 @@ +import * as crypto from 'crypto'; +const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); +export const encrypt = (text: string) => { + const algorithm = 'aes-256-cbc'; + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; +}; + +export const decrypt = (encrypted: string): string => { + const [ivHex, ecnryptedData] = encrypted.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(ecnryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +}; + +export const CodeGenerator = (): string => { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; +}; + +export function hashString(text: string): string { + return crypto.createHash('sha256').update(text).digest('hex'); +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 94cf0e1..4c2f290 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -8,11 +8,9 @@ import { ParseUUIDPipe, Patch, Post, - UseGuards, } from '@nestjs/common'; import { UserService } from './user.service'; import { SignupRequestData } from 'src/auth/dto/signup-user-dto'; -import { UserGuard } from './user.guards'; import { UpdateUserRequestData } from './dto/update-user-request-data'; @Controller('users') @@ -39,7 +37,6 @@ export class UserController { @HttpCode(HttpStatus.OK) @Patch(':id') - @UseGuards(UserGuard) async updateUser( @Param('id', ParseUUIDPipe) id: string, @Body() diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index 3d0b357..cc87ecd 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -3,8 +3,12 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + DeleteDateColumn, + Index, } from 'typeorm'; +@Index('IDX_users_full_name', ['fullName']) +@Index('IDX_users_created_at', ['createdAt']) @Entity({ name: 'users' }) export class User { @PrimaryGeneratedColumn('uuid') @@ -36,6 +40,13 @@ export class User { }) readonly createdAt: Date; + @DeleteDateColumn({ + type: 'timestamp with time zone', + nullable: true, + name: 'deleted_at', + }) + readonly deletedAt?: Date | null; + @Column({ type: 'timestamp with time zone', nullable: true, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 56687a6..a43f1fe 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -2,96 +2,72 @@ import { BadRequestException, Injectable, NotFoundException, - UnauthorizedException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { SignupRequestData } from 'src/auth/dto/signup-user-dto'; -import { JwtService } from '@nestjs/jwt'; -import { JwtPayload } from './types/JwtPayload'; @Injectable() export class UserService { constructor( @InjectRepository(User) private readonly userRepository: Repository, - private readonly jwtService: JwtService, ) {} - async validateToken(token: string): Promise { - try { - const decoded = await this.jwtService.verifyAsync(token, { - secret: process.env.JWT_SECRET, - }); - return decoded; - } catch (error) { - console.error('Token validation error:', error); - throw new UnauthorizedException('Invalid or expired token'); - } - } - async findAll(): Promise { - try { - return await this.userRepository.find(); - } catch (error) { - console.error('Error fetching users:', error); - throw new BadRequestException('Failed to fetch users'); - } + return await this.userRepository.find(); } async findOneByField( field: K, value: User[K], - ): Promise { - try { - if (value === undefined || value === null) { - throw new BadRequestException(`Invalid value for field: ${field}`); - } - - const user = await this.userRepository.findOneBy({ [field]: value }); + ): Promise { + if (!value) { + throw new BadRequestException(`Invalid value for field: ${field}`); + } - return user || null; - } catch (error) { - console.error(`Error fetching user by ${String(field)}:`, error); - throw new BadRequestException('Failed to fetch user'); + const user = await this.userRepository.findOne({ + where: { [field]: value }, + }); + if (!user) { + throw new NotFoundException('User not found'); } + return user; } - async create(userDto: SignupRequestData): Promise { - try { - if (!userDto.email || !userDto.username || !userDto.password) { - throw new BadRequestException('Missing required fields'); + async create(signUpUserDto: SignupRequestData): Promise { + const existingUser = await this.userRepository.findOne({ + where: [ + { username: signUpUserDto.username }, + { email: signUpUserDto.email }, + ], + }); + + if (existingUser) { + if (existingUser.username === signUpUserDto.username) { + throw new BadRequestException('Username already taken'); } - const user = this.userRepository.create(userDto); - return await this.userRepository.save(user); - } catch (error) { - console.error('Error creating user:', error); - throw new BadRequestException('Failed to create user'); + if (existingUser.email === signUpUserDto.email) { + throw new BadRequestException('Email already taken'); + } } + const user = this.userRepository.create(signUpUserDto); + return await this.userRepository.save(user); } - async update(userId: string, updateData: Partial): Promise { - if (!userId) { - throw new BadRequestException('User ID is required'); + async update( + userId: string, + updateData: Partial, + ): Promise<{ message: string }> { + const existingUser = await this.userRepository.findOneBy({ id: userId }); + if (!existingUser) { + throw new NotFoundException(`User with ID ${userId} not found`); } - try { - const existingUser = await this.userRepository.findOneBy({ id: userId }); - if (!existingUser) { - throw new NotFoundException(`User with ID ${userId} not found`); - } - - await this.userRepository.update(userId, updateData); + await this.userRepository.update(userId, updateData); - return await this.userRepository.findOneByOrFail({ id: userId }); - } catch (error) { - console.error(`Error updating user ${userId}:`, error); - - if (error instanceof NotFoundException) throw error; - - throw new BadRequestException('Failed to update user'); - } + return { message: 'User updated succesfully' }; } }