feat: 增加NapCat WebUI Gateway会话服务

This commit is contained in:
sunlei 2026-06-24 13:04:35 +08:00
parent 2b1c8e9b3e
commit 6f23db64be
11 changed files with 1393 additions and 6 deletions

View File

@ -19,6 +19,8 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main", "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", "typecheck": "tsc --noEmit",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@ -34,6 +36,7 @@
}, },
"dependencies": { "dependencies": {
"@kwitsukasa/knife4j-swagger-vue3": "0.1.2", "@kwitsukasa/knife4j-swagger-vue3": "0.1.2",
"@nestjs-modules/ioredis": "^2.2.1",
"@nestjs/bullmq": "11.0.4", "@nestjs/bullmq": "11.0.4",
"@nestjs/common": "^11.1.24", "@nestjs/common": "^11.1.24",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
@ -48,6 +51,8 @@
"cron-parser": "4.9.0", "cron-parser": "4.9.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"express": "5.2.1", "express": "5.2.1",
"http-proxy-middleware": "^4.1.1",
"ioredis": "^5.11.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",

View File

@ -11,6 +11,9 @@ importers:
'@kwitsukasa/knife4j-swagger-vue3': '@kwitsukasa/knife4j-swagger-vue3':
specifier: 0.1.2 specifier: 0.1.2
version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.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': '@nestjs/bullmq':
specifier: 11.0.4 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) 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) 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': '@nestjs/typeorm':
specifier: ^11.0.1 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: axios:
specifier: ^1.17.0 specifier: ^1.17.0
version: 1.17.0 version: 1.17.0
@ -53,6 +56,12 @@ importers:
express: express:
specifier: 5.2.1 specifier: 5.2.1
version: 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: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.18.1 version: 4.18.1
@ -130,7 +139,7 @@ importers:
version: 4.1.252 version: 4.1.252
typeorm: typeorm:
specifier: ^0.3.28 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: unified:
specifier: ^11.0.5 specifier: ^11.0.5
version: 11.0.5 version: 11.0.5
@ -597,6 +606,9 @@ packages:
'@types/node': '@types/node':
optional: true optional: true
'@ioredis/commands@1.10.0':
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
'@ioredis/commands@1.5.1': '@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@ -914,6 +926,13 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@nestjs/bull-shared@11.0.4':
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
peerDependencies: peerDependencies:
@ -1022,6 +1041,54 @@ packages:
class-validator: class-validator:
optional: true 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': '@nestjs/testing@11.1.24':
resolution: {integrity: sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==} resolution: {integrity: sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==}
peerDependencies: peerDependencies:
@ -1499,6 +1566,9 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} 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: ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1660,6 +1730,10 @@ packages:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
boxen@5.1.2:
resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==}
engines: {node: '>=10'}
brace-expansion@1.1.15: brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
@ -1801,6 +1875,10 @@ packages:
chart.js: '>=3.0.0' chart.js: '>=3.0.0'
moment: ^2.10.2 moment: ^2.10.2
check-disk-space@3.4.0:
resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==}
engines: {node: '>=16'}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@ -1820,6 +1898,10 @@ packages:
cjs-module-lexer@1.4.3: cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} 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: cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1847,6 +1929,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} 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: cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2606,10 +2692,17 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} 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: https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@2.1.0: human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'} engines: {node: '>=10.17.0'}
@ -2665,6 +2758,10 @@ packages:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'} 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: ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@ -4580,6 +4677,10 @@ packages:
wide-align@1.1.5: wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} 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: wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -5118,6 +5219,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 22.19.19 '@types/node': 22.19.19
'@ioredis/commands@1.10.0': {}
'@ioredis/commands@1.5.1': {} '@ioredis/commands@1.5.1': {}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
@ -5622,6 +5725,30 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
optional: true 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)': '@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: dependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -5748,6 +5875,19 @@ snapshots:
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6 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)': '@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: dependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -5756,13 +5896,13 @@ snapshots:
optionalDependencies: 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/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: dependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) '@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) '@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 reflect-metadata: 0.2.2
rxjs: 7.8.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': {} '@noble/hashes@1.8.0': {}
@ -6272,6 +6412,11 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 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-colors@4.1.3: {}
ansi-escapes@4.3.2: ansi-escapes@4.3.2:
@ -6448,6 +6593,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: brace-expansion@1.1.15:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@ -6589,6 +6746,9 @@ snapshots:
chart.js: 4.5.1 chart.js: 4.5.1
moment: 2.30.1 moment: 2.30.1
check-disk-space@3.4.0:
optional: true
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@ -6601,6 +6761,9 @@ snapshots:
cjs-module-lexer@1.4.3: {} cjs-module-lexer@1.4.3: {}
cli-boxes@2.2.1:
optional: true
cli-cursor@3.1.0: cli-cursor@3.1.0:
dependencies: dependencies:
restore-cursor: 3.1.0 restore-cursor: 3.1.0
@ -6629,6 +6792,8 @@ snapshots:
clone@1.0.4: {} clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
co@4.6.0: {} co@4.6.0: {}
@ -7490,6 +7655,16 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 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: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
@ -7497,6 +7672,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
httpxy@0.5.3: {}
human-signals@2.1.0: {} human-signals@2.1.0: {}
husky@9.1.7: {} husky@9.1.7: {}
@ -7550,6 +7727,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: {} ip-address@10.2.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@ -9639,7 +9828,7 @@ snapshots:
typedarray@0.0.6: {} 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: dependencies:
'@sqltools/formatter': 1.2.5 '@sqltools/formatter': 1.2.5
ansis: 4.3.1 ansis: 4.3.1
@ -9657,7 +9846,7 @@ snapshots:
uuid: 11.1.1 uuid: 11.1.1
yargs: 17.7.2 yargs: 17.7.2
optionalDependencies: optionalDependencies:
ioredis: 5.10.1 ioredis: 5.11.1
mysql2: 3.22.4(@types/node@22.19.19) mysql2: 3.22.4(@types/node@22.19.19)
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
@ -9865,6 +10054,11 @@ snapshots:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
widest-line@3.1.0:
dependencies:
string-width: 4.2.3
optional: true
wmf@1.0.2: {} wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}

View File

@ -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;
}
}

View File

@ -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<string>(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<string>(key));
return Number.isFinite(value) && value > 0 ? value : fallback;
}
}

View File

@ -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<NapcatWebuiGatewaySession>;
find(sessionId: string): Promise<NapcatWebuiGatewaySession | undefined>;
findActiveByUserAndAccount(
adminUserId: string,
accountId: string,
): Promise<NapcatWebuiGatewaySession | undefined>;
update(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
): Promise<NapcatWebuiGatewaySession>;
}
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';

View File

@ -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<NapcatWebuiGatewaySession>,
) {
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);
}
}

View File

@ -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}`;
}
}

View File

@ -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();

View File

@ -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 {}

View File

@ -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<NapcatWebuiGatewayLifecycleInput, 'sessionId'>;
@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');
}
}
}

View File

@ -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<string, NapcatWebuiGatewaySession>();
/**
* 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<NapcatWebuiGatewaySession>,
) {
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<string, string>();
readonly ttl = new Map<string, number>();
/**
* 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<Parameters<NapcatWebuiGatewaySessionService['create']>[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);
});
});