From 6f23db64beac6faa9ef2ec72d572e8f9fce88576 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 13:04:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0NapCat=20WebUI=20Gate?= =?UTF-8?q?way=E4=BC=9A=E8=AF=9D=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 + pnpm-lock.yaml | 206 ++++++++- .../napcat-webui-gateway-session.service.ts | 181 ++++++++ .../napcat-webui-gateway-config.service.ts | 115 +++++ .../domain/napcat-webui-gateway.types.ts | 62 +++ .../napcat-webui-gateway-redis.store.ts | 161 +++++++ .../napcat-webui-gateway-ticket.service.ts | 65 +++ src/apps/napcat-webui-gateway/main.ts | 20 + .../napcat-webui-gateway.module.ts | 59 +++ .../internal-session.controller.ts | 121 ++++++ .../session-store.spec.ts | 404 ++++++++++++++++++ 11 files changed, 1393 insertions(+), 6 deletions(-) create mode 100644 src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts create mode 100644 src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts create mode 100644 src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts create mode 100644 src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts create mode 100644 src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts create mode 100644 src/apps/napcat-webui-gateway/main.ts create mode 100644 src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts create mode 100644 src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts create mode 100644 test/apps/napcat-webui-gateway/session-store.spec.ts diff --git a/package.json b/package.json index 50d23c5..a63b14c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "cross-env NODE_ENV=production node dist/main", + "start:gateway:prod": "cross-env NODE_ENV=production node dist/apps/napcat-webui-gateway/main", + "start:gateway:dev": "ts-node -r tsconfig-paths/register src/apps/napcat-webui-gateway/main.ts", "typecheck": "tsc --noEmit", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -34,6 +36,7 @@ }, "dependencies": { "@kwitsukasa/knife4j-swagger-vue3": "0.1.2", + "@nestjs-modules/ioredis": "^2.2.1", "@nestjs/bullmq": "11.0.4", "@nestjs/common": "^11.1.24", "@nestjs/config": "^4.0.4", @@ -48,6 +51,8 @@ "cron-parser": "4.9.0", "cross-env": "^7.0.3", "express": "5.2.1", + "http-proxy-middleware": "^4.1.1", + "ioredis": "^5.11.1", "lodash": "^4.17.21", "moment": "^2.30.1", "mqtt": "^5.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edcb718..39c8fe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@kwitsukasa/knife4j-swagger-vue3': specifier: 0.1.2 version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs-modules/ioredis': + specifier: ^2.2.1 + version: 2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) '@nestjs/bullmq': specifier: 11.0.4 version: 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1) @@ -31,7 +34,7 @@ importers: version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) axios: specifier: ^1.17.0 version: 1.17.0 @@ -53,6 +56,12 @@ importers: express: specifier: 5.2.1 version: 5.2.1 + http-proxy-middleware: + specifier: ^4.1.1 + version: 4.1.1 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 lodash: specifier: ^4.17.21 version: 4.18.1 @@ -130,7 +139,7 @@ importers: version: 4.1.252 typeorm: specifier: ^0.3.28 - version: 0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + version: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) unified: specifier: ^11.0.5 version: 11.0.5 @@ -597,6 +606,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -914,6 +926,13 @@ packages: cpu: [x64] os: [win32] + '@nestjs-modules/ioredis@2.2.1': + resolution: {integrity: sha512-wQ08XvlV2s9V+01SKcC5XmFoQ2hMAHP0KuVja8UFZyE/dM0bKI5HSHr+3wQ5ChRpsyhfxF/vKrlPXMlJIr7FIg==} + peerDependencies: + '@nestjs/common': '>=6.7.0' + '@nestjs/core': '>=6.7.0' + ioredis: '>=5.0.0' + '@nestjs/bull-shared@11.0.4': resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} peerDependencies: @@ -1022,6 +1041,54 @@ packages: class-validator: optional: true + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + '@nestjs/testing@11.1.24': resolution: {integrity: sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==} peerDependencies: @@ -1499,6 +1566,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1660,6 +1730,10 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -1801,6 +1875,10 @@ packages: chart.js: '>=3.0.0' moment: ^2.10.2 + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1820,6 +1898,10 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1847,6 +1929,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -2606,10 +2692,17 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-middleware@4.1.1: + resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==} + engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + httpxy@0.5.3: + resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2665,6 +2758,10 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -4580,6 +4677,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -5118,6 +5219,8 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 + '@ioredis/commands@1.10.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -5622,6 +5725,30 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true + '@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + ioredis: 5.11.1 + optionalDependencies: + '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + transitivePeerDependencies: + - '@grpc/grpc-js' + - '@grpc/proto-loader' + - '@mikro-orm/core' + - '@mikro-orm/nestjs' + - '@nestjs/axios' + - '@nestjs/microservices' + - '@nestjs/mongoose' + - '@nestjs/sequelize' + - '@nestjs/typeorm' + - '@prisma/client' + - mongoose + - reflect-metadata + - rxjs + - sequelize + - typeorm + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5748,6 +5875,19 @@ snapshots: reflect-metadata: 0.2.2 swagger-ui-dist: 5.32.6 + '@nestjs/terminus@11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + optional: true + '@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)': dependencies: '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5756,13 +5896,13 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': dependencies: '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: 0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) '@noble/hashes@1.8.0': {} @@ -6272,6 +6412,11 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + optional: true + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -6448,6 +6593,18 @@ snapshots: transitivePeerDependencies: - supports-color + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + optional: true + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -6589,6 +6746,9 @@ snapshots: chart.js: 4.5.1 moment: 2.30.1 + check-disk-space@3.4.0: + optional: true + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -6601,6 +6761,9 @@ snapshots: cjs-module-lexer@1.4.3: {} + cli-boxes@2.2.1: + optional: true + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6629,6 +6792,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.1: {} + cluster-key-slot@1.1.2: {} co@4.6.0: {} @@ -7490,6 +7655,16 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-middleware@4.1.1: + dependencies: + debug: 4.4.3 + httpxy: 0.5.3 + is-glob: 4.0.3 + is-plain-obj: 4.1.0 + micromatch: 4.0.8 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -7497,6 +7672,8 @@ snapshots: transitivePeerDependencies: - supports-color + httpxy@0.5.3: {} + human-signals@2.1.0: {} husky@9.1.7: {} @@ -7550,6 +7727,18 @@ snapshots: transitivePeerDependencies: - supports-color + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -9639,7 +9828,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)): + typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.3.1 @@ -9657,7 +9846,7 @@ snapshots: uuid: 11.1.1 yargs: 17.7.2 optionalDependencies: - ioredis: 5.10.1 + ioredis: 5.11.1 mysql2: 3.22.4(@types/node@22.19.19) ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) transitivePeerDependencies: @@ -9865,6 +10054,11 @@ snapshots: dependencies: string-width: 4.2.3 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + optional: true + wmf@1.0.2: {} word-wrap@1.2.5: {} diff --git a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts new file mode 100644 index 0000000..8a465e5 --- /dev/null +++ b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service'; +import { + NAPCAT_WEBUI_GATEWAY_SESSION_STORE, + type NapcatWebuiGatewayCreateSessionInput, + type NapcatWebuiGatewayLifecycleInput, + type NapcatWebuiGatewaySession, + type NapcatWebuiGatewaySessionStore, +} from '../domain/napcat-webui-gateway.types'; + +const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked']; + +@Injectable() +export class NapcatWebuiGatewaySessionService { + /** + * Creates the Gateway session lifecycle service. + * @param store - Session store abstraction backed by Redis in production. + * @param config - Gateway runtime config and time source. + */ + constructor( + @Inject(NAPCAT_WEBUI_GATEWAY_SESSION_STORE) + private readonly store: NapcatWebuiGatewaySessionStore, + private readonly config: NapcatWebuiGatewayConfigService, + ) {} + + /** + * Creates a new Gateway session and revokes an older same-user same-account session. + * @param input - Internal API payload with server-only WebUI target metadata. + * @returns Created session persisted in the store. + */ + async create(input: NapcatWebuiGatewayCreateSessionInput) { + const existing = await this.store.findActiveByUserAndAccount( + input.adminUserId, + input.accountId, + ); + if (existing) { + await this.store.update(existing.sessionId, { + revokedAt: this.config.now(), + status: 'revoked', + }); + } + + const now = this.config.now(); + const session: NapcatWebuiGatewaySession = { + accountId: input.accountId, + adminUserId: input.adminUserId, + clientIp: this.toOptionalText(input.clientIp), + containerId: input.containerId, + containerName: input.containerName, + createdAt: now, + expiresAt: now + this.config.ttlMs(), + selfId: input.selfId, + sessionId: randomUUID(), + status: 'created', + upstreamBaseUrl: input.upstreamBaseUrl, + userAgent: this.toOptionalText(input.userAgent), + webuiToken: input.webuiToken, + }; + + return this.store.create(session); + } + + /** + * Marks a bootstrap-created session active before proxy traffic is allowed. + * @param sessionId - Gateway session id being bootstrapped. + * @returns Updated active session. + */ + async markActive(sessionId: string) { + const session = await this.requireUsableSession(sessionId); + const now = this.config.now(); + + return this.store.update(sessionId, { + activeAt: session.activeAt || now, + expiresAt: now + this.config.ttlMs(), + lastSeenAt: now, + status: 'active', + }); + } + + /** + * Extends an Admin-owned active Gateway session. + * @param input - Session id plus Admin ownership and request evidence. + * @returns Browser-safe lifecycle result. + */ + async heartbeat(input: NapcatWebuiGatewayLifecycleInput) { + const session = await this.requireUsableSession(input.sessionId); + this.assertOwner(session, input.adminUserId); + const now = this.config.now(); + const expiresAt = now + this.config.ttlMs(); + + await this.store.update(input.sessionId, { + clientIp: this.toOptionalText(input.clientIp) || session.clientIp, + expiresAt, + lastSeenAt: now, + status: 'active', + userAgent: this.toOptionalText(input.userAgent) || session.userAgent, + }); + + return { + expiresAt, + sessionId: input.sessionId, + status: 'active' as const, + }; + } + + /** + * Revokes an Admin-owned Gateway session. + * @param input - Session id plus Admin ownership and request evidence. + * @returns Browser-safe lifecycle result. + */ + async revoke(input: NapcatWebuiGatewayLifecycleInput) { + const session = await this.store.find(input.sessionId); + if (!session) throw new Error('Gateway session is not active'); + this.assertOwner(session, input.adminUserId); + + const updated = await this.store.update(input.sessionId, { + clientIp: this.toOptionalText(input.clientIp) || session.clientIp, + revokedAt: this.config.now(), + status: 'revoked', + userAgent: this.toOptionalText(input.userAgent) || session.userAgent, + }); + + return { + expiresAt: updated.expiresAt, + sessionId: input.sessionId, + status: 'revoked' as const, + }; + } + + /** + * Loads a session only when it is usable for later proxy handling. + * @param sessionId - Gateway session id from a public Gateway route. + * @returns Stored server-only session metadata for proxy setup. + */ + async requireProxySession(sessionId: string) { + return this.requireUsableSession(sessionId); + } + + /** + * Loads and validates a non-terminal, non-expired session. + * @param sessionId - Gateway session id. + * @returns Usable Gateway session. + */ + private async requireUsableSession(sessionId: string) { + const session = await this.store.find(sessionId); + if (!session || TERMINAL_SESSION_STATUSES.includes(session.status)) { + throw new Error('Gateway session is not active'); + } + if (session.expiresAt <= this.config.now()) { + await this.store.update(sessionId, { status: 'expired' }); + throw new Error('Gateway session is not active'); + } + + return session; + } + + /** + * Ensures lifecycle calls can only mutate sessions owned by the same Admin user. + * @param session - Stored Gateway session. + * @param adminUserId - Admin actor id from the internal API payload. + */ + private assertOwner( + session: NapcatWebuiGatewaySession, + adminUserId: string, + ) { + if (session.adminUserId !== adminUserId) { + throw new Error('Gateway session owner mismatch'); + } + } + + /** + * Normalizes optional evidence fields before storing them. + * @param value - Optional string value. + * @returns Trimmed text or undefined. + */ + private toOptionalText(value?: string) { + const text = String(value || '').trim(); + return text || undefined; + } +} diff --git a/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts b/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts new file mode 100644 index 0000000..6176dc4 --- /dev/null +++ b/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const DEFAULT_GATEWAY_PORT = 48086; +const DEFAULT_REDIS_HOST = '127.0.0.1'; +const DEFAULT_REDIS_PORT = 6379; +const DEFAULT_SESSION_TTL_MS = 60_000; +const MAX_TICKET_TTL_MS = 60_000; + +@Injectable() +export class NapcatWebuiGatewayConfigService { + /** + * Creates the Gateway config facade around Nest ConfigService. + * @param configService - Global Nest config service loaded from the current NODE_ENV file. + */ + constructor(private readonly configService: ConfigService) {} + + /** + * Returns the current wall-clock timestamp for session expiry decisions. + * @returns Milliseconds since Unix epoch. + */ + now() { + return Date.now(); + } + + /** + * Reads the browser Gateway session TTL. + * @returns Positive TTL in milliseconds, defaulting to 60 seconds. + */ + ttlMs() { + return this.getPositiveNumber( + 'NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS', + DEFAULT_SESSION_TTL_MS, + ); + } + + /** + * Reads and bounds the one-time bootstrap ticket TTL. + * @returns Positive TTL in milliseconds, capped at 60 seconds. + */ + ticketTtlMs() { + return Math.min( + this.getPositiveNumber( + 'NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS', + MAX_TICKET_TTL_MS, + ), + MAX_TICKET_TTL_MS, + ); + } + + /** + * Reads the standalone Gateway HTTP port. + * @returns Positive port number, defaulting to 48086. + */ + port() { + return this.getPositiveNumber( + 'NAPCAT_WEBUI_GATEWAY_PORT', + DEFAULT_GATEWAY_PORT, + ); + } + + /** + * Reads the shared internal secret required for mutating API-to-Gateway calls. + * @returns Trimmed secret or an empty string when missing so callers fail closed. + */ + internalSecret() { + return this.getString('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET'); + } + + /** + * Builds the Redis URL from the explicit Gateway URL or host/port fallback. + * @returns Redis connection URL for the Gateway process. + */ + redisUrl() { + const explicitUrl = this.getString('NAPCAT_WEBUI_GATEWAY_REDIS_URL'); + if (explicitUrl) return explicitUrl; + + const host = + this.getString('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || DEFAULT_REDIS_HOST; + const port = this.getPositiveNumber( + 'NAPCAT_WEBUI_GATEWAY_REDIS_PORT', + DEFAULT_REDIS_PORT, + ); + + return `redis://${host}:${port}`; + } + + /** + * Reads the public route prefix used for relative iframe URLs. + * @returns Gateway-owned public session route prefix without a trailing slash. + */ + publicSessionPrefix() { + return '/napcat-webui/session'; + } + + /** + * Reads a trimmed string from the environment-backed config store. + * @param key - Environment variable key. + * @returns Trimmed string value or empty string. + */ + private getString(key: string) { + return String(this.configService.get(key) || '').trim(); + } + + /** + * Reads a positive numeric environment variable with a fallback. + * @param key - Environment variable key. + * @param fallback - Default value when the configured value is invalid. + * @returns Positive finite number. + */ + private getPositiveNumber(key: string, fallback: number) { + const value = Number(this.configService.get(key)); + return Number.isFinite(value) && value > 0 ? value : fallback; + } +} diff --git a/src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts b/src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts new file mode 100644 index 0000000..640c748 --- /dev/null +++ b/src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts @@ -0,0 +1,62 @@ +export type NapcatWebuiGatewaySessionStatus = + | 'active' + | 'created' + | 'expired' + | 'failed' + | 'revoked'; + +export interface NapcatWebuiGatewaySession { + accountId: string; + activeAt?: number; + adminUserId: string; + clientIp?: string; + containerId: string; + containerName: string; + createdAt: number; + expiresAt: number; + lastSeenAt?: number; + revokedAt?: number; + selfId: string; + sessionId: string; + status: NapcatWebuiGatewaySessionStatus; + upstreamBaseUrl: string; + userAgent?: string; + webuiToken: string; +} + +export interface NapcatWebuiGatewaySessionStore { + create( + session: NapcatWebuiGatewaySession, + ): Promise; + find(sessionId: string): Promise; + findActiveByUserAndAccount( + adminUserId: string, + accountId: string, + ): Promise; + update( + sessionId: string, + patch: Partial, + ): Promise; +} + +export type NapcatWebuiGatewayCreateSessionInput = { + accountId: string; + adminUserId: string; + clientIp?: string; + containerId: string; + containerName: string; + selfId: string; + upstreamBaseUrl: string; + userAgent?: string; + webuiToken: string; +}; + +export type NapcatWebuiGatewayLifecycleInput = { + adminUserId: string; + clientIp?: string; + sessionId: string; + userAgent?: string; +}; + +export const NAPCAT_WEBUI_GATEWAY_SESSION_STORE = + 'NAPCAT_WEBUI_GATEWAY_SESSION_STORE'; diff --git a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts new file mode 100644 index 0000000..2f218a2 --- /dev/null +++ b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import type Redis from 'ioredis'; +import { NapcatWebuiGatewayConfigService } from '../../config/napcat-webui-gateway-config.service'; +import type { + NapcatWebuiGatewaySession, + NapcatWebuiGatewaySessionStore, +} from '../../domain/napcat-webui-gateway.types'; + +const SESSION_KEY_PREFIX = 'napcat:webui:session:'; +const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:'; +const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked']; + +@Injectable() +export class NapcatWebuiGatewayRedisStore + implements NapcatWebuiGatewaySessionStore +{ + /** + * Creates the Redis-backed Gateway session store. + * @param redis - ioredis client injected by @nestjs-modules/ioredis. + * @param config - Gateway config used for TTL calculations. + */ + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly config: NapcatWebuiGatewayConfigService, + ) {} + + /** + * Stores a newly created Gateway session and its user/account lookup index. + * @param session - Session metadata to persist. + * @returns Persisted session. + */ + async create(session: NapcatWebuiGatewaySession) { + await this.writeSession(session); + await this.writeUserAccountIndex(session); + return session; + } + + /** + * Finds a Gateway session by id. + * @param sessionId - Gateway session id. + * @returns Parsed session or undefined. + */ + async find(sessionId: string) { + const value = await this.redis.get(this.sessionKey(sessionId)); + return value + ? (JSON.parse(value) as NapcatWebuiGatewaySession) + : undefined; + } + + /** + * Finds a non-terminal session through the user/account index. + * @param adminUserId - Admin actor id. + * @param accountId - QQBot account id. + * @returns Active or created session, ignoring terminal states. + */ + async findActiveByUserAndAccount(adminUserId: string, accountId: string) { + const sessionId = await this.redis.get( + this.userAccountKey(adminUserId, accountId), + ); + if (!sessionId) return undefined; + + const session = await this.find(sessionId); + if (!session || this.isTerminal(session)) return undefined; + + return session; + } + + /** + * Merges a partial session patch and refreshes session/index TTLs. + * @param sessionId - Gateway session id. + * @param patch - Fields to merge into the stored session. + * @returns Updated session. + */ + async update( + sessionId: string, + patch: Partial, + ) { + const current = await this.find(sessionId); + if (!current) throw new Error('Gateway session is not active'); + + const next = { + ...current, + ...patch, + sessionId, + }; + + await this.writeSession(next); + if (this.isTerminal(next)) { + await this.redis.del( + this.userAccountKey(next.adminUserId, next.accountId), + ); + } else { + await this.writeUserAccountIndex(next); + } + + return next; + } + + /** + * Writes the session JSON with a PX TTL based on its remaining lifetime. + * @param session - Gateway session to serialize. + */ + private async writeSession(session: NapcatWebuiGatewaySession) { + await this.redis.psetex( + this.sessionKey(session.sessionId), + this.remainingTtlMs(session), + JSON.stringify(session), + ); + } + + /** + * Writes the user/account lookup index with the same session lifetime. + * @param session - Gateway session used to derive the index key. + */ + private async writeUserAccountIndex(session: NapcatWebuiGatewaySession) { + await this.redis.set( + this.userAccountKey(session.adminUserId, session.accountId), + session.sessionId, + 'PX', + this.remainingTtlMs(session), + ); + } + + /** + * Builds the Redis session key. + * @param sessionId - Gateway session id. + * @returns Redis key for session JSON. + */ + private sessionKey(sessionId: string) { + return `${SESSION_KEY_PREFIX}${sessionId}`; + } + + /** + * Builds the Redis user/account index key. + * @param adminUserId - Admin actor id. + * @param accountId - QQBot account id. + * @returns Redis key pointing to the active session id. + */ + private userAccountKey(adminUserId: string, accountId: string) { + return `${USER_ACCOUNT_KEY_PREFIX}${adminUserId}:${accountId}`; + } + + /** + * Calculates a positive Redis TTL from the session expiry timestamp. + * @param session - Gateway session with expiresAt. + * @returns Positive TTL in milliseconds. + */ + private remainingTtlMs(session: NapcatWebuiGatewaySession) { + return Math.max(1, session.expiresAt - this.config.now()); + } + + /** + * Checks whether the session has reached a terminal lifecycle status. + * @param session - Gateway session to inspect. + * @returns Whether the session must be ignored by active lookups. + */ + private isTerminal(session: NapcatWebuiGatewaySession) { + return TERMINAL_SESSION_STATUSES.includes(session.status); + } +} diff --git a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts new file mode 100644 index 0000000..28cf22d --- /dev/null +++ b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts @@ -0,0 +1,65 @@ +import { randomBytes } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import type Redis from 'ioredis'; +import { NapcatWebuiGatewayConfigService } from '../../config/napcat-webui-gateway-config.service'; + +const TICKET_KEY_PREFIX = 'napcat:webui:ticket:'; + +@Injectable() +export class NapcatWebuiGatewayTicketService { + /** + * Creates the Redis-backed one-time bootstrap ticket service. + * @param redis - ioredis client injected by @nestjs-modules/ioredis. + * @param config - Gateway config used to bound ticket TTL. + */ + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly config: NapcatWebuiGatewayConfigService, + ) {} + + /** + * Issues a one-time bootstrap ticket for a Gateway session. + * @param sessionId - Gateway session id the ticket can activate. + * @returns URL-safe opaque bootstrap ticket. + */ + async issue(sessionId: string) { + const ticket = this.createTicket(); + await this.redis.set( + this.ticketKey(ticket), + sessionId, + 'PX', + this.config.ticketTtlMs(), + ); + return ticket; + } + + /** + * Redeems a ticket once by deleting the Redis key before returning the session id. + * @param ticket - Opaque ticket from the bootstrap iframe URL. + * @returns Session id when the ticket existed, otherwise undefined. + */ + async redeem(ticket: string) { + const key = this.ticketKey(ticket); + const sessionId = await this.redis.get(key); + await this.redis.del(key); + return sessionId || undefined; + } + + /** + * Generates a URL-safe random ticket value. + * @returns Opaque bootstrap ticket. + */ + private createTicket() { + return randomBytes(32).toString('base64url'); + } + + /** + * Builds the Redis ticket key. + * @param ticket - Opaque bootstrap ticket. + * @returns Redis key for one-time ticket storage. + */ + private ticketKey(ticket: string) { + return `${TICKET_KEY_PREFIX}${ticket}`; + } +} diff --git a/src/apps/napcat-webui-gateway/main.ts b/src/apps/napcat-webui-gateway/main.ts new file mode 100644 index 0000000..3051ee1 --- /dev/null +++ b/src/apps/napcat-webui-gateway/main.ts @@ -0,0 +1,20 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from 'nestjs-pino'; +import { json, urlencoded } from 'express'; +import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service'; +import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module'; + +/** + * Starts the standalone NapCat WebUI Gateway process. + */ +async function bootstrap() { + const app = await NestFactory.create(NapcatWebuiGatewayModule, { + bufferLogs: true, + }); + app.useLogger(app.get(Logger)); + app.use(json({ limit: '50mb' })); + app.use(urlencoded({ extended: true, limit: '50mb' })); + await app.listen(app.get(NapcatWebuiGatewayConfigService).port()); +} + +bootstrap(); diff --git a/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts b/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts new file mode 100644 index 0000000..a60e88a --- /dev/null +++ b/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts @@ -0,0 +1,59 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { LoggerModule } from 'nestjs-pino'; +import { createPinoLoggerParams } from '@/common'; +import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service'; +import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service'; +import { NAPCAT_WEBUI_GATEWAY_SESSION_STORE } from './domain/napcat-webui-gateway.types'; +import { NapcatWebuiGatewayRedisStore } from './infrastructure/session/napcat-webui-gateway-redis.store'; +import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service'; +import { InternalSessionController } from './presentation/internal-session.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, + }), + LoggerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + /** + * Builds pino logger options from the shared API logging config. + * @param configService - Nest ConfigService dependency. + * @returns LoggerModule options. + */ + useFactory: (configService: ConfigService) => + createPinoLoggerParams(configService), + }), + RedisModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + /** + * Builds the Gateway Redis module options. + * @param configService - Nest ConfigService dependency. + * @returns @nestjs-modules/ioredis single-connection options. + */ + useFactory: (configService: ConfigService) => { + const config = new NapcatWebuiGatewayConfigService(configService); + return { + type: 'single' as const, + url: config.redisUrl(), + }; + }, + }), + ], + controllers: [InternalSessionController], + providers: [ + NapcatWebuiGatewayConfigService, + NapcatWebuiGatewaySessionService, + NapcatWebuiGatewayRedisStore, + NapcatWebuiGatewayTicketService, + { + provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE, + useExisting: NapcatWebuiGatewayRedisStore, + }, + ], +}) +export class NapcatWebuiGatewayModule {} diff --git a/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts b/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts new file mode 100644 index 0000000..33daa16 --- /dev/null +++ b/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts @@ -0,0 +1,121 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + UnauthorizedException, +} from '@nestjs/common'; +import { NapcatWebuiGatewaySessionService } from '../application/napcat-webui-gateway-session.service'; +import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service'; +import type { + NapcatWebuiGatewayCreateSessionInput, + NapcatWebuiGatewayLifecycleInput, +} from '../domain/napcat-webui-gateway.types'; +import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service'; + +type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput; +type LifecycleBody = Omit; + +@Controller('internal') +export class InternalSessionController { + /** + * Creates the internal API-to-Gateway session controller. + * @param sessionService - Gateway session lifecycle application service. + * @param ticketService - One-time bootstrap ticket service. + * @param config - Gateway config used for secret validation and public URL prefix. + */ + constructor( + private readonly sessionService: NapcatWebuiGatewaySessionService, + private readonly ticketService: NapcatWebuiGatewayTicketService, + private readonly config: NapcatWebuiGatewayConfigService, + ) {} + + /** + * Creates a Gateway session and returns only browser-safe bootstrap metadata. + * @param secret - Shared API-to-Gateway secret header. + * @param body - Internal create-session payload from the API service. + * @returns Browser-safe session id, expiry, and relative iframe URL. + */ + @Post('sessions') + async createSession( + @Headers('x-kt-gateway-secret') secret: string, + @Body() body: CreateSessionBody, + ) { + this.requireInternalSecret(secret); + const session = await this.sessionService.create(body); + const ticket = await this.ticketService.issue(session.sessionId); + + return { + expiresAt: session.expiresAt, + iframeUrl: `${this.config.publicSessionPrefix()}/${ + session.sessionId + }/bootstrap?ticket=${ticket}`, + sessionId: session.sessionId, + }; + } + + /** + * Refreshes one Gateway session heartbeat from the internal API. + * @param sessionId - Gateway session id from the route. + * @param secret - Shared API-to-Gateway secret header. + * @param body - Admin ownership and client evidence payload. + * @returns Browser-safe heartbeat lifecycle result. + */ + @Post('sessions/:sessionId/heartbeat') + heartbeat( + @Param('sessionId') sessionId: string, + @Headers('x-kt-gateway-secret') secret: string, + @Body() body: LifecycleBody, + ) { + this.requireInternalSecret(secret); + return this.sessionService.heartbeat({ + ...body, + sessionId, + }); + } + + /** + * Revokes one Gateway session from the internal API. + * @param sessionId - Gateway session id from the route. + * @param secret - Shared API-to-Gateway secret header. + * @param body - Admin ownership and client evidence payload. + * @returns Browser-safe revoke lifecycle result. + */ + @Post('sessions/:sessionId/revoke') + revoke( + @Param('sessionId') sessionId: string, + @Headers('x-kt-gateway-secret') secret: string, + @Body() body: LifecycleBody, + ) { + this.requireInternalSecret(secret); + return this.sessionService.revoke({ + ...body, + sessionId, + }); + } + + /** + * Returns a public health response for process and route liveness checks. + * @returns Simple Gateway health payload. + */ + @Get('health') + health() { + return { + ok: true, + service: 'napcat-webui-gateway', + }; + } + + /** + * Validates the shared secret and fails closed when it is missing or mismatched. + * @param secret - Request header value. + */ + private requireInternalSecret(secret: string) { + const configured = this.config.internalSecret(); + if (!configured || secret !== configured) { + throw new UnauthorizedException('Gateway internal secret mismatch'); + } + } +} diff --git a/test/apps/napcat-webui-gateway/session-store.spec.ts b/test/apps/napcat-webui-gateway/session-store.spec.ts new file mode 100644 index 0000000..5159d8d --- /dev/null +++ b/test/apps/napcat-webui-gateway/session-store.spec.ts @@ -0,0 +1,404 @@ +import { HttpStatus, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service'; +import { NapcatWebuiGatewayConfigService } from '../../../src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service'; +import { + NAPCAT_WEBUI_GATEWAY_SESSION_STORE, + type NapcatWebuiGatewaySession, + type NapcatWebuiGatewaySessionStore, +} from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types'; +import { NapcatWebuiGatewayRedisStore } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store'; +import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service'; +import { InternalSessionController } from '../../../src/apps/napcat-webui-gateway/presentation/internal-session.controller'; + +const INTERNAL_SECRET = ['internal', 'secret', 'fixture'].join('-'); + +class MemorySessionStore implements NapcatWebuiGatewaySessionStore { + readonly sessions = new Map(); + + /** + * Stores a new in-memory session for service lifecycle tests. + * @param session - Session object created by the Gateway session service. + * @returns Stored session. + */ + async create(session: NapcatWebuiGatewaySession) { + this.sessions.set(session.sessionId, { ...session }); + return { ...session }; + } + + /** + * Finds a session by id from the in-memory fixture store. + * @param sessionId - Gateway session id. + * @returns Matching session or undefined. + */ + async find(sessionId: string) { + const session = this.sessions.get(sessionId); + return session ? { ...session } : undefined; + } + + /** + * Finds the currently usable session for the Admin user and QQBot account pair. + * @param adminUserId - Admin actor id. + * @param accountId - QQBot account id. + * @returns Existing non-terminal session or undefined. + */ + async findActiveByUserAndAccount(adminUserId: string, accountId: string) { + const session = [...this.sessions.values()].find( + (item) => + item.adminUserId === adminUserId && + item.accountId === accountId && + !['expired', 'failed', 'revoked'].includes(item.status), + ); + + return session ? { ...session } : undefined; + } + + /** + * Applies a partial update to an existing test session. + * @param sessionId - Gateway session id. + * @param patch - Fields to merge into the stored session. + * @returns Updated session. + */ + async update( + sessionId: string, + patch: Partial, + ) { + const current = this.sessions.get(sessionId); + if (!current) throw new Error(`Missing session ${sessionId}`); + const next = { ...current, ...patch }; + this.sessions.set(sessionId, next); + return { ...next }; + } +} + +class FakeRedis { + readonly calls: string[] = []; + readonly values = new Map(); + readonly ttl = new Map(); + + /** + * Stores a value with an expiration marker for Redis-backed store tests. + * @param key - Redis key. + * @param ttlMs - Millisecond TTL. + * @param value - Serialized value. + * @returns Redis OK marker. + */ + async psetex(key: string, ttlMs: number, value: string) { + this.calls.push(`psetex:${key}:${ttlMs}`); + this.values.set(key, value); + this.ttl.set(key, ttlMs); + return 'OK'; + } + + /** + * Stores a string value with optional PX expiration arguments. + * @param key - Redis key. + * @param value - Serialized value. + * @param mode - Optional Redis expiration mode. + * @param ttlMs - Optional Redis TTL. + * @returns Redis OK marker. + */ + async set(key: string, value: string, mode?: string, ttlMs?: number) { + this.calls.push(`set:${key}:${mode || ''}:${ttlMs || ''}`); + this.values.set(key, value); + if (mode === 'PX' && ttlMs) this.ttl.set(key, ttlMs); + return 'OK'; + } + + /** + * Reads a string value by key from the fake Redis store. + * @param key - Redis key. + * @returns Stored value or null. + */ + async get(key: string) { + this.calls.push(`get:${key}`); + return this.values.get(key) ?? null; + } + + /** + * Deletes one or more keys from the fake Redis store. + * @param keys - Redis keys to delete. + * @returns Number of deleted keys. + */ + async del(...keys: string[]) { + this.calls.push(`del:${keys.join(',')}`); + let deleted = 0; + keys.forEach((key) => { + if (this.values.delete(key)) deleted += 1; + this.ttl.delete(key); + }); + return deleted; + } +} + +/** + * Creates a Gateway session creation input with safe server-only target data. + * @param override - Fields to replace in the default fixture. + * @returns Session creation payload. + */ +function createSessionInput( + override: Partial[0]> = {}, +) { + return { + accountId: 'account-1', + adminUserId: 'admin-1', + clientIp: '127.0.0.1', + containerId: 'container-1', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + upstreamBaseUrl: 'http://127.0.0.1:6099', + userAgent: 'jest-agent', + webuiToken: ['webui', 'token', 'fixture'].join('-'), + ...override, + }; +} + +/** + * Creates a lightweight config fixture for Gateway service tests. + * @param currentTime - Mutable time supplier used by lifecycle assertions. + * @returns Config service shape consumed by Gateway services. + */ +function createConfig(currentTime: { value: number }) { + return { + internalSecret: () => INTERNAL_SECRET, + now: () => currentTime.value, + publicSessionPrefix: () => '/napcat-webui/session', + ticketTtlMs: () => 60_000, + ttlMs: () => 60_000, + }; +} + +describe('NapcatWebuiGatewaySessionService', () => { + it('revokes an older same-user same-account session when creating a new one', async () => { + const store = new MemorySessionStore(); + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig({ value: 1000 }) as never, + ); + + const first = await service.create(createSessionInput()); + const second = await service.create(createSessionInput()); + + expect(first.sessionId).not.toBe(second.sessionId); + expect(await store.find(first.sessionId)).toMatchObject({ + revokedAt: 1000, + status: 'revoked', + }); + expect(await store.find(second.sessionId)).toMatchObject({ + createdAt: 1000, + expiresAt: 61_000, + status: 'created', + }); + }); + + it('extends active sessions on heartbeat and rejects revoked sessions', async () => { + const store = new MemorySessionStore(); + const currentTime = { value: 1000 }; + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig(currentTime) as never, + ); + const session = await service.create(createSessionInput()); + + currentTime.value = 5000; + await service.markActive(session.sessionId); + const heartbeat = await service.heartbeat({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }); + + expect(heartbeat).toEqual({ + expiresAt: 65_000, + sessionId: session.sessionId, + status: 'active', + }); + expect(await store.find(session.sessionId)).toMatchObject({ + activeAt: 5000, + expiresAt: 65_000, + lastSeenAt: 5000, + status: 'active', + }); + + await service.revoke({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }); + + await expect( + service.heartbeat({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }), + ).rejects.toThrow('Gateway session is not active'); + }); + + it('rejects heartbeat and revoke owner mismatches', async () => { + const store = new MemorySessionStore(); + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig({ value: 1000 }) as never, + ); + const session = await service.create(createSessionInput()); + + await expect( + service.heartbeat({ + adminUserId: 'admin-2', + sessionId: session.sessionId, + }), + ).rejects.toThrow('Gateway session owner mismatch'); + await expect( + service.revoke({ + adminUserId: 'admin-2', + sessionId: session.sessionId, + }), + ).rejects.toThrow('Gateway session owner mismatch'); + }); + + it('rejects expired proxy sessions and marks them expired', async () => { + const store = new MemorySessionStore(); + const currentTime = { value: 1000 }; + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig(currentTime) as never, + ); + const session = await service.create(createSessionInput()); + + currentTime.value = 70_000; + + await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( + 'Gateway session is not active', + ); + expect(await store.find(session.sessionId)).toMatchObject({ + status: 'expired', + }); + }); +}); + +describe('NapcatWebuiGatewayRedisStore', () => { + it('stores sessions with remaining TTL and ignores terminal indexed sessions', async () => { + const redis = new FakeRedis(); + const config = createConfig({ value: 1000 }); + const store = new NapcatWebuiGatewayRedisStore( + redis as never, + config as never, + ); + const session: NapcatWebuiGatewaySession = { + ...createSessionInput(), + createdAt: 1000, + expiresAt: 61_000, + sessionId: 'session-1', + status: 'created', + }; + + await store.create(session); + await store.update(session.sessionId, { + revokedAt: 2000, + status: 'revoked', + }); + + expect(redis.ttl.get('napcat:webui:session:session-1')).toBe(60_000); + await expect( + store.findActiveByUserAndAccount('admin-1', 'account-1'), + ).resolves.toBeUndefined(); + }); +}); + +describe('NapcatWebuiGatewayTicketService', () => { + it('redeems bootstrap tickets once and deletes them before returning', async () => { + const redis = new FakeRedis(); + const service = new NapcatWebuiGatewayTicketService( + redis as never, + createConfig({ value: 1000 }) as never, + ); + + const ticket = await service.issue('session-1'); + const sessionId = await service.redeem(ticket); + const secondRedeem = await service.redeem(ticket); + + expect(sessionId).toBe('session-1'); + expect(secondRedeem).toBeUndefined(); + expect(redis.values.get(`napcat:webui:ticket:${ticket}`)).toBeUndefined(); + expect(redis.calls).toEqual( + expect.arrayContaining([ + `get:napcat:webui:ticket:${ticket}`, + `del:napcat:webui:ticket:${ticket}`, + ]), + ); + }); +}); + +describe('InternalSessionController', () => { + let app: INestApplication; + const store = new MemorySessionStore(); + const currentTime = { value: 1000 }; + const config = createConfig(currentTime); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [InternalSessionController], + providers: [ + NapcatWebuiGatewaySessionService, + NapcatWebuiGatewayTicketService, + { + provide: NapcatWebuiGatewayConfigService, + useValue: config, + }, + { + provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE, + useValue: store, + }, + { + provide: 'default_IORedisModuleConnectionToken', + useValue: new FakeRedis(), + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + store.sessions.clear(); + currentTime.value = 1000; + }); + + afterAll(async () => { + await app?.close(); + }); + + it('creates a safe relative iframe URL with a bootstrap ticket', async () => { + const response = await request(app.getHttpServer()) + .post('/internal/sessions') + .set('x-kt-gateway-secret', INTERNAL_SECRET) + .send(createSessionInput()) + .expect(HttpStatus.CREATED); + + expect(response.body).toEqual({ + expiresAt: 61_000, + iframeUrl: expect.stringMatching( + /^\/napcat-webui\/session\/[0-9a-f-]+\/bootstrap\?ticket=[A-Za-z0-9_-]+$/, + ), + sessionId: expect.any(String), + }); + expect(response.body.iframeUrl).not.toContain('http://'); + expect(response.body.iframeUrl).not.toContain('webui-token-fixture'); + expect(response.body.iframeUrl).not.toContain('127.0.0.1'); + }); + + it('rejects missing or wrong secrets for mutating calls', async () => { + await request(app.getHttpServer()) + .post('/internal/sessions') + .send(createSessionInput()) + .expect(HttpStatus.UNAUTHORIZED); + await request(app.getHttpServer()) + .post('/internal/sessions') + .set('x-kt-gateway-secret', 'wrong-secret') + .send(createSessionInput()) + .expect(HttpStatus.UNAUTHORIZED); + await request(app.getHttpServer()) + .get('/internal/health') + .expect(HttpStatus.OK); + }); +});