feat: 增加NapCat WebUI Gateway会话服务
This commit is contained in:
parent
2b1c8e9b3e
commit
6f23db64be
@ -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",
|
||||
|
||||
206
pnpm-lock.yaml
206
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: {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
20
src/apps/napcat-webui-gateway/main.ts
Normal file
20
src/apps/napcat-webui-gateway/main.ts
Normal 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();
|
||||
59
src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts
Normal file
59
src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts
Normal 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 {}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
404
test/apps/napcat-webui-gateway/session-store.spec.ts
Normal file
404
test/apps/napcat-webui-gateway/session-store.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user