feat: 增加NapCat WebUI Gateway会话服务
This commit is contained in:
parent
2b1c8e9b3e
commit
6f23db64be
@ -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",
|
||||||
|
|||||||
206
pnpm-lock.yaml
206
pnpm-lock.yaml
@ -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: {}
|
||||||
|
|||||||
@ -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