Compare commits
21 Commits
98c8568184
...
a8a1c3503f
| Author | SHA1 | Date | |
|---|---|---|---|
| a8a1c3503f | |||
| eb162ddba6 | |||
| d0fbc13763 | |||
| 52d96294ed | |||
| f16dd0050c | |||
| 0375fd4661 | |||
| c9cc0d5e3a | |||
| 43b2c4c32e | |||
| d174d9c9c2 | |||
| 87b0368b9a | |||
| 8ee7866afe | |||
| 6f23db64be | |||
| 2b1c8e9b3e | |||
| 99ecb4a35e | |||
| 156ba8147e | |||
| 06eac5a01d | |||
| b9ef48e5dd | |||
| 71b37e6ffb | |||
| 788761698f | |||
| 571db8ecea | |||
| cdded5b754 |
8
API.md
8
API.md
@ -379,6 +379,14 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。
|
||||
|
||||
### NapCat WebUI Gateway
|
||||
|
||||
NapCat WebUI Gateway 是独立部署的内部代理服务,生产镜像由 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js`,K8s 服务名为 `kt-napcat-webui-gateway`,端口 `48086`。API 侧只通过内部路由 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL` 创建、续期、撤销会话和交换一次性 ticket;浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL` 下的代理页面、静态资源和 WebSocket 转发,不能直连 NapCat 容器 WebUI。
|
||||
|
||||
必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret,不写入 Git 或 manifest 字面量。
|
||||
|
||||
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
|
||||
`napcat_login_event` 实体和表仅作为历史 schema 兼容保留;watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
|
||||
|
||||
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
||||
|
||||
30
Jenkinsfile
vendored
30
Jenkinsfile
vendored
@ -38,6 +38,7 @@ def requiredRuntimeEnvKeys() {
|
||||
'FFLOGS_CLIENT_SECRET',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_HOST',
|
||||
'QQBOT_PLUGIN_QUEUE_REDIS_PORT',
|
||||
'NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET',
|
||||
]
|
||||
}
|
||||
|
||||
@ -85,6 +86,7 @@ pipeline {
|
||||
string(name: 'PUBLISH_BRANCH_PATTERN', defaultValue: '^(main|master|release/.+)$', description: '允许推送镜像的分支正则')
|
||||
string(name: 'DOCKER_REGISTRY', defaultValue: 'k3d-kt-registry.localhost:5000', description: '镜像仓库地址;K8s 发布默认使用 fnOS NAS 上的 k3d 本地 registry')
|
||||
string(name: 'IMAGE_NAME', defaultValue: 'kt-template-online-api', description: 'Docker 镜像名称')
|
||||
string(name: 'GATEWAY_IMAGE_NAME', defaultValue: 'kt-napcat-webui-gateway', description: 'NapCat WebUI Gateway Docker 镜像名称')
|
||||
string(name: 'IMAGE_TAG', defaultValue: '', description: '镜像标签,为空时使用 分支名-BUILD_NUMBER;PR 使用源分支名')
|
||||
string(name: 'CONTAINER_NAME', defaultValue: 'kt-template-online-api', description: '业务容器名称')
|
||||
string(name: 'CONTAINER_PORT', defaultValue: '48085', description: '宿主机映射端口,容器内固定使用 48085')
|
||||
@ -133,6 +135,8 @@ pipeline {
|
||||
env.DOCKER_REGISTRY_EFFECTIVE = registry ?: ''
|
||||
env.DOCKER_IMAGE = registry ? "${registry}/${params.IMAGE_NAME}:${env.IMAGE_TAG_FINAL}" : "${params.IMAGE_NAME}:${env.IMAGE_TAG_FINAL}"
|
||||
env.DOCKER_IMAGE_LATEST = registry ? "${registry}/${params.IMAGE_NAME}:latest" : "${params.IMAGE_NAME}:latest"
|
||||
env.GATEWAY_DOCKER_IMAGE = registry ? "${registry}/${params.GATEWAY_IMAGE_NAME}:${env.IMAGE_TAG_FINAL}" : "${params.GATEWAY_IMAGE_NAME}:${env.IMAGE_TAG_FINAL}"
|
||||
env.GATEWAY_DOCKER_IMAGE_LATEST = registry ? "${registry}/${params.GATEWAY_IMAGE_NAME}:latest" : "${params.GATEWAY_IMAGE_NAME}:latest"
|
||||
|
||||
// Agent 由 NAS 侧预先创建;这里仅确认 CI 所需的 Node/pnpm 环境可用。
|
||||
if (isUnix()) {
|
||||
@ -183,6 +187,8 @@ pipeline {
|
||||
Docker registry: ${env.DOCKER_REGISTRY_EFFECTIVE ?: '-'}
|
||||
Docker image: ${env.DOCKER_IMAGE}
|
||||
Docker latest: ${env.DOCKER_IMAGE_LATEST}
|
||||
Gateway image: ${env.GATEWAY_DOCKER_IMAGE}
|
||||
Gateway latest: ${env.GATEWAY_DOCKER_IMAGE_LATEST}
|
||||
Deploy target: ${params.DEPLOY_TARGET}
|
||||
Publish branch: ${env.IS_PUBLISH_BRANCH}
|
||||
Run container: ${params.RUN_DOCKER_CONTAINER}
|
||||
@ -237,16 +243,24 @@ pipeline {
|
||||
if (isUnix()) {
|
||||
runCmd("""
|
||||
test -f dist/main.js
|
||||
test -f dist/apps/napcat-webui-gateway/main.js
|
||||
docker build -f dockerfile -t ${env.DOCKER_IMAGE} .
|
||||
docker build -f dockerfile.gateway -t ${env.GATEWAY_DOCKER_IMAGE} .
|
||||
if [ '${env.DOCKER_IMAGE}' != '${env.DOCKER_IMAGE_LATEST}' ]; then
|
||||
docker tag ${env.DOCKER_IMAGE} ${env.DOCKER_IMAGE_LATEST}
|
||||
fi
|
||||
if [ '${env.GATEWAY_DOCKER_IMAGE}' != '${env.GATEWAY_DOCKER_IMAGE_LATEST}' ]; then
|
||||
docker tag ${env.GATEWAY_DOCKER_IMAGE} ${env.GATEWAY_DOCKER_IMAGE_LATEST}
|
||||
fi
|
||||
""".stripIndent())
|
||||
} else {
|
||||
runCmd('', """
|
||||
if not exist dist\\main.js exit /b 1
|
||||
if not exist dist\\apps\\napcat-webui-gateway\\main.js exit /b 1
|
||||
docker build -f dockerfile -t ${env.DOCKER_IMAGE} .
|
||||
docker build -f dockerfile.gateway -t ${env.GATEWAY_DOCKER_IMAGE} .
|
||||
if not "${env.DOCKER_IMAGE}"=="${env.DOCKER_IMAGE_LATEST}" docker tag ${env.DOCKER_IMAGE} ${env.DOCKER_IMAGE_LATEST}
|
||||
if not "${env.GATEWAY_DOCKER_IMAGE}"=="${env.GATEWAY_DOCKER_IMAGE_LATEST}" docker tag ${env.GATEWAY_DOCKER_IMAGE} ${env.GATEWAY_DOCKER_IMAGE_LATEST}
|
||||
""".stripIndent())
|
||||
}
|
||||
}
|
||||
@ -267,9 +281,14 @@ pipeline {
|
||||
runCmd("""
|
||||
docker push ${env.DOCKER_IMAGE}
|
||||
docker push ${env.DOCKER_IMAGE_LATEST}
|
||||
docker push ${env.GATEWAY_DOCKER_IMAGE}
|
||||
docker push ${env.GATEWAY_DOCKER_IMAGE_LATEST}
|
||||
""".stripIndent())
|
||||
} else {
|
||||
runCmd("docker push ${env.DOCKER_IMAGE}")
|
||||
runCmd("""
|
||||
docker push ${env.DOCKER_IMAGE}
|
||||
docker push ${env.GATEWAY_DOCKER_IMAGE}
|
||||
""".stripIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -295,6 +314,7 @@ pipeline {
|
||||
def namespace = params.K8S_NAMESPACE?.trim() ?: 'kt-prod'
|
||||
def deploymentName = params.K8S_DEPLOYMENT?.trim() ?: 'kt-template-online-api'
|
||||
def containerName = params.K8S_CONTAINER?.trim() ?: 'api'
|
||||
def gatewayDeploymentName = 'kt-napcat-webui-gateway'
|
||||
def envSecret = params.K8S_ENV_SECRET?.trim() ?: 'kt-template-online-api-env'
|
||||
def rolloutTimeout = params.K8S_ROLLOUT_TIMEOUT?.trim() ?: '180s'
|
||||
def containerEnvFile = params.CONTAINER_ENV_FILE?.trim()
|
||||
@ -338,10 +358,14 @@ pipeline {
|
||||
|
||||
kubectl ${kubeConfigArg} apply -f ${shellQuote(manifestFile)}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} set image ${shellQuote("deployment/${deploymentName}")} ${shellQuote("${containerName}=${env.DOCKER_IMAGE}")}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} set image ${shellQuote('deployment/kt-napcat-webui-gateway')} ${shellQuote("gateway=${env.GATEWAY_DOCKER_IMAGE}")}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} annotate ${shellQuote("deployment/${deploymentName}")} \\
|
||||
${shellQuote("kubernetes.io/change-cause=${changeCause}")} --overwrite
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} annotate ${shellQuote('deployment/kt-napcat-webui-gateway')} \\
|
||||
${shellQuote("kubernetes.io/change-cause=${changeCause}")} --overwrite
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} rollout status ${shellQuote("deployment/${deploymentName}")} --timeout=${shellQuote(rolloutTimeout)}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} get pod,svc -l app=${shellQuote(deploymentName)}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} rollout status ${shellQuote('deployment/kt-napcat-webui-gateway')} --timeout=${shellQuote(rolloutTimeout)}
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} get pod,svc -l ${shellQuote("app in (${deploymentName},${gatewayDeploymentName})")}
|
||||
""".stripIndent())
|
||||
}
|
||||
}
|
||||
@ -407,7 +431,7 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
archiveArtifacts artifacts: 'dist/**,package.json,pnpm-lock.yaml,dockerfile,k8s/**,ci/fnos-k8s/**', fingerprint: true, allowEmptyArchive: true
|
||||
archiveArtifacts artifacts: 'dist/**,package.json,pnpm-lock.yaml,dockerfile,dockerfile.gateway,k8s/**,ci/fnos-k8s/**', fingerprint: true, allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +79,8 @@ Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。
|
||||
|
||||
NapCat WebUI Gateway 是独立运行的 NestJS 入口,生产镜像使用 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js` 并监听 `48086`。API 通过内部地址 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL=http://kt-napcat-webui-gateway:48086` 创建/续期/撤销 WebUI 会话,Admin 浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL=/napcat-webui`。Gateway 运行时需要 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`;生产 secret 由 Jenkins 从私有 `.env.production` 重建到 `kt-template-online-api-env`,不得写入 Git。验收命令:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全边界:浏览器不得收到 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
|
||||
46
dockerfile.gateway
Normal file
46
dockerfile.gateway
Normal file
@ -0,0 +1,46 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV APP_PORT=48086
|
||||
ENV LOG_LEVEL=info
|
||||
ENV LOG_APP_NAME=kt-napcat-webui-gateway
|
||||
ENV LOG_PRETTY=false
|
||||
ENV LOKI_ENV=production
|
||||
ENV LOKI_PUSH_ENDPOINT=/loki/api/v1/push
|
||||
ENV LOKI_QUERY_ENDPOINT=/loki/api/v1/query_range
|
||||
ENV LOKI_PUSH_TIMEOUT_MS=30000
|
||||
ENV LOKI_QUERY_TIMEOUT_MS=10000
|
||||
ENV LOKI_BATCH_INTERVAL_SECONDS=5
|
||||
ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000
|
||||
ENV LOKI_QUERY_MAX_LIMIT=1000
|
||||
ENV LOKI_SILENCE_ERRORS=true
|
||||
ENV FFLOGS_BASE_URL=https://cn.fflogs.com
|
||||
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
|
||||
ENV FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
|
||||
ENV FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
|
||||
ENV FFLOGS_DEFAULT_SERVER_REGION=CN
|
||||
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-noto-cjk openssh-client \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 生产镜像只安装运行依赖,dist 由 Jenkins Build stage 提前产出。
|
||||
# 跳过安装阶段脚本,避免 NODE_ENV=production 时 devDependency 中的 husky 不存在导致 prepare 失败。
|
||||
RUN npm install -g pnpm@9.15.9 --registry=https://registry.npmmirror.com \
|
||||
&& pnpm config set registry https://registry.npmmirror.com \
|
||||
&& pnpm install --prod --frozen-lockfile --ignore-scripts \
|
||||
&& pnpm rebuild skia-canvas
|
||||
|
||||
# dist 由 Jenkins 的 Build stage 生成,这里只打包运行产物。
|
||||
COPY dist ./dist
|
||||
|
||||
EXPOSE 48086
|
||||
|
||||
CMD ["node", "dist/apps/napcat-webui-gateway/main"]
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,881 @@
|
||||
# QQBot NapCat WebUI Gateway 实施计划
|
||||
|
||||
> **给 agent worker:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 按任务执行本计划。步骤使用 checkbox(`- [ ]`)跟踪。
|
||||
|
||||
**目标:** 实现独立 NapCat WebUI Gateway 微服务,并在 Admin 二级页面中打开指定 QQBot 账号的原版 NapCat WebUI,支持完整操作和页面生命周期清理。
|
||||
|
||||
**架构:** API 继续作为 Admin 鉴权和 QQBot 账号绑定解析的权威,只负责创建短期 Gateway session。新增 `kt-napcat-webui-gateway` 进程负责 session 存储、一次性 bootstrap ticket、NapCat WebUI Credential 交换、HTTP/静态资源/API/WebSocket 代理和审计。Admin 打开 `/qqbot/account/:accountId/napcat-webui`,页面 mounted 创建 session,mounted 期间 heartbeat,路由离开时 revoke。
|
||||
|
||||
**技术栈:** NestJS 11、Express adapter、TypeORM/MySQL、Redis via `@nestjs-modules/ioredis` + `ioredis`、`http-proxy-middleware` 代理 Express/WebSocket、Vue 3 TSX、VueUse `useIntervalFn`、Vben Admin、antdv-next、K8s、Jenkins、Caddy/Admin 域路由。
|
||||
|
||||
---
|
||||
|
||||
## 来源参考
|
||||
|
||||
- 设计文档:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.md`
|
||||
- 中文设计:`docs/superpowers/specs/2026-06-24-qqbot-napcat-webui-gateway-design.zh-CN.md`
|
||||
- `http-proxy-middleware` 支持 Express proxy middleware 和 WebSocket upgrade:<https://github.com/chimurai/http-proxy-middleware>
|
||||
- `http-proxy-middleware` WebSocket recipe 说明了 `ws: true`、手动 `server.on('upgrade', proxy.upgrade)`、多 target 和 path rewrite:<https://github.com/chimurai/http-proxy-middleware/blob/master/recipes/websocket.md>
|
||||
- `@nestjs-modules/ioredis` 提供 Nest `RedisModule.forRoot` 和 `@InjectRedis()`,底层使用 `ioredis`:<https://github.com/nest-modules/ioredis>
|
||||
- 明确不使用 `connect-redis`,因为它是 Express session store,而 Gateway 需要 API 预创建 session、一次性 ticket、target 元数据、同账号并发撤销、Credential 缓存和审计事件:<https://github.com/tj/connect-redis>
|
||||
- VueUse `useIntervalFn` 是带 pause/resume 控制的 `setInterval` wrapper,Admin 已有该依赖:<https://vueuse.org/shared/useintervalfn/>
|
||||
|
||||
## 范围检查
|
||||
|
||||
这是一条完整可交付链路,虽然跨 API、Gateway、Admin 和部署,但不能拆成互不依赖的计划。Admin 页面没有 API session 不能加载,API session 没有 Gateway 不能 smoke,Gateway 没有 Admin 生命周期和部署路由也不能安全上线。
|
||||
|
||||
## 文件结构
|
||||
|
||||
### API 仓库:`D:\MyFiles\KT\Node\kt-template-online-api`
|
||||
|
||||
- 修改 `package.json` 和 `pnpm-lock.yaml`:加入 `@nestjs-modules/ioredis`、`ioredis`、`http-proxy-middleware` 和 Gateway 启动脚本。
|
||||
- 新增 `src/apps/napcat-webui-gateway/main.ts`:Gateway 独立 Nest bootstrap,端口 `48086`。
|
||||
- 新增 `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`:Gateway module,导入配置、日志、TypeORM、`RedisModule` 和 Gateway provider/controller。
|
||||
- 新增 `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`:读取 Gateway env 和安全默认值。
|
||||
- 新增 `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`:session、audit、target、proxy 类型。
|
||||
- 新增 `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`:Redis session/ticket store。
|
||||
- 新增 `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts`:一次性 bootstrap ticket。
|
||||
- 新增 `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`:服务端 WebUI token 换 Credential。
|
||||
- 新增 `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`:HTTP、header、redirect、cookie、WebSocket 代理。
|
||||
- 新增 `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts`:create、active、heartbeat、revoke、expire、同账号并发策略。
|
||||
- 新增 `src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts`:Gateway 内部服务接口。
|
||||
- 新增 `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`:bootstrap 和公开 iframe/proxy 入口。
|
||||
- 新增 `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts`:Admin-facing DTO。
|
||||
- 新增 `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts`:`/qqbot/napcat/webui` Admin 接口。
|
||||
- 新增 `src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts`:账号鉴权、容器解析、Gateway client 编排。
|
||||
- 新增 `src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts`:Gateway 内部 HTTP client。
|
||||
- 新增 `src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts`:MySQL audit entity。
|
||||
- 修改 `src/modules/qqbot/napcat/qqbot-napcat.module.ts`:注册 controller、service、client、audit entity。
|
||||
- 修改 `sql/qqbot-init.sql` 和 `sql/refactor-v3/01-seed-core.sql`:加入 `QqBot:Account:WebUI` hidden route/menu 和按钮权限。
|
||||
- 修改 `sql/refactor-v3/99-verify.sql`:校验新权限和审计表。
|
||||
- 新增 `dockerfile.gateway`:生产镜像入口 `dist/apps/napcat-webui-gateway/main`。
|
||||
- 修改 `Jenkinsfile`:构建、推送和部署 API 镜像与 Gateway 镜像。
|
||||
- 修改 `k8s/prod/api.yaml`:新增 Gateway Deployment/Service,给 API 增加 Gateway base URL/public base URL/internal secret。
|
||||
- 修改 `README.md` 和 `API.md`:记录 Gateway env、路由和验证命令。
|
||||
|
||||
### Admin 仓库:`D:\MyFiles\KT\Vue\kt-template-admin`
|
||||
|
||||
- 修改 `apps/web-antdv-next/src/router/routes/modules/qqbot.ts`:新增隐藏二级 WebUI 路由。
|
||||
- 修改 `apps/web-antdv-next/src/api/qqbot/napcat.ts`:新增 WebUI session 类型和 caller。
|
||||
- 修改 `apps/web-antdv-next/src/views/qqbot/account/list.tsx`:新增 WebUI 行操作。
|
||||
- 新增 `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx`:远程控制台页面。
|
||||
- 新增 `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss`:主题化布局。
|
||||
- 新增 `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts`:页面生命周期 session。
|
||||
- 修改 `apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts`:保证 account list 不承载 WebUI 生命周期。
|
||||
- 新增 `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx`:页面生命周期测试。
|
||||
- 修改 `apps/web-antdv-next/src/api/qqbot/napcat.spec.ts`:WebUI session caller 测试。
|
||||
|
||||
---
|
||||
|
||||
### Task 1:增加契约、权限种子和 RED 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts`
|
||||
- Modify: `sql/qqbot-init.sql`
|
||||
- Modify: `sql/refactor-v3/01-seed-core.sql`
|
||||
- Modify: `sql/refactor-v3/99-verify.sql`
|
||||
|
||||
- [ ] **Step 1:写失败的结构测试**
|
||||
|
||||
创建 `test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const repoRoot = resolve(__dirname, '../../../..');
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(repoRoot, path), 'utf8');
|
||||
|
||||
describe('NapCat WebUI Gateway contract seeds', () => {
|
||||
it('registers a dedicated Admin permission for full NapCat WebUI access', () => {
|
||||
const coreSeed = read('sql/refactor-v3/01-seed-core.sql');
|
||||
const qqbotSeed = read('sql/qqbot-init.sql');
|
||||
|
||||
expect(coreSeed).toContain('QqBot:Account:WebUI');
|
||||
expect(coreSeed).toContain('QqBotAccountNapcatWebui');
|
||||
expect(qqbotSeed).toContain('QqBot:Account:WebUI');
|
||||
expect(qqbotSeed).toContain('QqBotAccountNapcatWebui');
|
||||
});
|
||||
|
||||
it('verifies the gateway audit table during full schema checks', () => {
|
||||
const verifySql = read('sql/refactor-v3/99-verify.sql');
|
||||
|
||||
expect(verifySql).toContain('qqbot_napcat_webui_gateway_audit');
|
||||
expect(verifySql).toContain('QqBot:Account:WebUI');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:失败,提示 `QqBot:Account:WebUI` 和 `qqbot_napcat_webui_gateway_audit` 不存在。
|
||||
|
||||
- [ ] **Step 3:增加 SQL 种子**
|
||||
|
||||
在 `sql/qqbot-init.sql` 和 `sql/refactor-v3/01-seed-core.sql` 的 QQBot account 菜单附近加入:
|
||||
|
||||
```sql
|
||||
(2041700000000100412, 2041700000000100400, 'QqBotAccountNapcatWebui', '/qqbot/account/:accountId/napcat-webui', '/qqbot/account/napcat-webui/index', NULL, 'QqBot:Account:WebUI', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"NapCat WebUI"}', 1, 0),
|
||||
(2041700000000120407, 2041700000000100402, 'QqBotAccountWebUI', NULL, NULL, NULL, 'QqBot:Account:WebUI', 'button', '{"title":"NapCat WebUI"}', 1, 0),
|
||||
```
|
||||
|
||||
保持 ID 唯一,不改无关菜单。
|
||||
|
||||
- [ ] **Step 4:增加 schema 校验 SQL**
|
||||
|
||||
在 `sql/refactor-v3/99-verify.sql` 按现有风格加入:
|
||||
|
||||
```sql
|
||||
SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name,
|
||||
COUNT(*) AS matched
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qqbot_napcat_webui_gateway_audit';
|
||||
|
||||
SELECT 'QqBot Account WebUI permission exists' AS check_name,
|
||||
COUNT(*) AS matched
|
||||
FROM admin_menu
|
||||
WHERE auth_code = 'QqBot:Account:WebUI';
|
||||
```
|
||||
|
||||
当前菜单表是 `admin_menu`,权限字段是 `auth_code`;校验 SQL 固定使用这两个名称。
|
||||
|
||||
- [ ] **Step 5:运行契约测试确认 GREEN**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:PASS。
|
||||
|
||||
- [ ] **Step 6:提交 Task 1**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api add test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts sql/qqbot-init.sql sql/refactor-v3/01-seed-core.sql sql/refactor-v3/99-verify.sql
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI权限契约"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2:实现 API session 接口和审计实体
|
||||
|
||||
**Files:**
|
||||
- Create: `test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts`
|
||||
- Create: `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts`
|
||||
- Create: `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts`
|
||||
- Create: `src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts`
|
||||
- Create: `src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts`
|
||||
- Create: `src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/qqbot-napcat.module.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/persistence/index.ts`
|
||||
|
||||
- [ ] **Step 1:写失败的 API service 测试**
|
||||
|
||||
创建 `test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts`,测试必须断言 Admin 响应不含 `webuiToken`、Credential、端口或容器拓扑,并且 WebUI 离线时不会调用 Gateway。
|
||||
|
||||
- [ ] **Step 2:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:失败,因为 service 和 DTO 尚不存在。
|
||||
|
||||
- [ ] **Step 3:创建 DTO**
|
||||
|
||||
创建 `src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts`,包含 `QqbotNapcatWebuiSessionCreateDto` 和 `QqbotNapcatWebuiSessionResponseDto`,字段与英文计划一致。
|
||||
|
||||
- [ ] **Step 4:创建审计实体**
|
||||
|
||||
创建 `NapcatWebuiGatewayAudit`,表名 `qqbot_napcat_webui_gateway_audit`,字段包含 `sessionId/adminUserId/accountId/selfId/containerId/eventType/clientIp/userAgent/detailJson/createTime`,禁止保存 token、Credential、密码、验证码或二维码内容。
|
||||
|
||||
- [ ] **Step 5:创建 Gateway 内部 client**
|
||||
|
||||
创建 `QqbotNapcatWebuiGatewayClient`,提供 `createSession`、`heartbeat`、`revoke`,从 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL` 和 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 调 Gateway 内部接口,错误信息必须脱敏。
|
||||
|
||||
- [ ] **Step 6:创建 API service**
|
||||
|
||||
创建 `QqbotNapcatWebuiGatewayService`:
|
||||
|
||||
- `createSession()` 校验账号存在。
|
||||
- 解析主 NapCat container。
|
||||
- WebUI 离线或 token/port 不完整时拒绝。
|
||||
- 调 Gateway client 创建 session。
|
||||
- 返回安全字段:`account/container/sessionId/iframeUrl/expiresAt`。
|
||||
|
||||
在 `QqbotNapcatContainerService` 新增 `findPrimaryContainerByAccountId(accountId: string)`,补 JSDoc 和聚焦单测,然后由 `QqbotNapcatWebuiGatewayService` 调用该方法。
|
||||
|
||||
- [ ] **Step 7:创建 API controller**
|
||||
|
||||
创建 `QqbotNapcatWebuiGatewayController`,路径:
|
||||
|
||||
```text
|
||||
POST /qqbot/napcat/webui/session
|
||||
POST /qqbot/napcat/webui/session/:sessionId/heartbeat
|
||||
POST /qqbot/napcat/webui/session/:sessionId/revoke
|
||||
```
|
||||
|
||||
使用 `JwtAuthGuard`,通过 `vbenSuccess` 返回。
|
||||
|
||||
- [ ] **Step 8:注册 provider 和 entity**
|
||||
|
||||
修改 `qqbot-napcat.module.ts` 与 `napcat/infrastructure/persistence/index.ts`,注册 controller、service、client 和 audit entity。
|
||||
|
||||
- [ ] **Step 9:运行 API 测试和类型检查**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts --runInBand
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS。
|
||||
|
||||
- [ ] **Step 10:提交 Task 2**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/napcat/webui-gateway src/modules/qqbot/napcat/qqbot-napcat.module.ts src/modules/qqbot/napcat/infrastructure/persistence/index.ts test/modules/qqbot/napcat-webui-gateway
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI会话接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3:实现 Gateway App、Session Store 和 Bootstrap Ticket
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
- Create: `test/apps/napcat-webui-gateway/session-store.spec.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/main.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts`
|
||||
|
||||
- [ ] **Step 1:增加依赖**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api add @nestjs-modules/ioredis ioredis http-proxy-middleware
|
||||
```
|
||||
|
||||
期望:更新 `package.json` 和 `pnpm-lock.yaml`。保留已有 `ws`。不要加 `connect-redis`,因为 Gateway session 是领域 session,不是 Express 登录 session。
|
||||
|
||||
- [ ] **Step 2:增加启动脚本**
|
||||
|
||||
在 `package.json` scripts 增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
API 仓库已有 `ts-node` 和 `tsconfig-paths`,dev script 固定使用 TypeScript entrypoint,生产脚本固定使用编译后的 `dist/apps/napcat-webui-gateway/main`。
|
||||
|
||||
- [ ] **Step 3:写 session 生命周期测试**
|
||||
|
||||
创建 `test/apps/napcat-webui-gateway/session-store.spec.ts`,覆盖:
|
||||
|
||||
- 同一 Admin 用户 + 同一账号创建新 session 时撤销旧 session。
|
||||
- heartbeat 延长 active session。
|
||||
- revoked session 不允许 heartbeat。
|
||||
|
||||
- [ ] **Step 4:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:失败,因为 Gateway 类型和 service 不存在。
|
||||
|
||||
- [ ] **Step 5:创建 Gateway domain types**
|
||||
|
||||
创建 `NapcatWebuiGatewaySessionStatus`、`NapcatWebuiGatewaySession`、`NapcatWebuiGatewaySessionStore`,字段与英文计划一致。
|
||||
|
||||
- [ ] **Step 6:实现 session service**
|
||||
|
||||
创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`heartbeat()` 只延长 active session,必须拒绝 `created`、终态、过期、缺失或 user/account index 不再指向当前 `sessionId` 的 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;只有 `markActive()` 可以把 `created` session 提升为 `active`;`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。
|
||||
|
||||
- [ ] **Step 7:实现 Redis store 和 ticket service**
|
||||
|
||||
使用 `@nestjs-modules/ioredis` 的 `@InjectRedis()` 注入 Redis client,不自写 Redis provider。Redis key:
|
||||
|
||||
```text
|
||||
napcat:webui:session:{sessionId}
|
||||
napcat:webui:user-account:{adminUserId}:{accountId}
|
||||
napcat:webui:ticket:{ticket}
|
||||
```
|
||||
|
||||
ticket TTL 不超过 60 秒,redeem 时先删除 ticket 再返回 session id。
|
||||
|
||||
- [ ] **Step 8:增加 Gateway module 和内部 controller**
|
||||
|
||||
`napcat-webui-gateway.module.ts` 必须通过 `RedisModule.forRootAsync` 接入 Redis:
|
||||
|
||||
```ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RedisModule } from '@nestjs-modules/ioredis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
RedisModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'single',
|
||||
url:
|
||||
config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_URL') ||
|
||||
`redis://${config.get<string>('NAPCAT_WEBUI_GATEWAY_REDIS_HOST') || '127.0.0.1'}:${config.get<number>('NAPCAT_WEBUI_GATEWAY_REDIS_PORT') || 6379}`,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class NapcatWebuiGatewayModule {}
|
||||
```
|
||||
|
||||
内部路径:
|
||||
|
||||
```text
|
||||
POST /internal/sessions
|
||||
POST /internal/sessions/:sessionId/heartbeat
|
||||
POST /internal/sessions/:sessionId/revoke
|
||||
GET /internal/health
|
||||
```
|
||||
|
||||
所有 mutating internal call 必须校验 `x-kt-gateway-secret`。
|
||||
|
||||
- [ ] **Step 9:增加 Gateway bootstrap**
|
||||
|
||||
创建 `src/apps/napcat-webui-gateway/main.ts`,监听 `NAPCAT_WEBUI_GATEWAY_PORT || 48086`,使用 `Logger`、`json`、`urlencoded`,写明 JSDoc。
|
||||
|
||||
- [ ] **Step 10:运行测试和类型检查**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts --runInBand
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS。
|
||||
|
||||
- [ ] **Step 11:提交 Task 3**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api add package.json pnpm-lock.yaml src/apps/napcat-webui-gateway test/apps/napcat-webui-gateway
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 增加NapCat WebUI Gateway会话服务"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4:增加 Credential 交换和 WebUI 代理
|
||||
|
||||
**Files:**
|
||||
- Create: `test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts`
|
||||
- Create: `src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts`
|
||||
- Modify: `src/apps/napcat-webui-gateway/main.ts`
|
||||
- Modify: `src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts`
|
||||
|
||||
- [ ] **Step 1:写代理重写测试**
|
||||
|
||||
创建 `proxy-rewrite.spec.ts`,覆盖:
|
||||
|
||||
- 禁止 `https://evil.test/api`。
|
||||
- 禁止 `../api/auth/login`。
|
||||
- `api/QQLogin/CheckLoginStatus` 规范化成 `/api/QQLogin/CheckLoginStatus`。
|
||||
- `Location: /webui/login` 重写到 `/napcat-webui/session/:sessionId/webui/webui/login`。
|
||||
- `buildGatewayCookiePathRewrite({ sessionId })` 返回 `http-proxy-middleware` 的 `cookiePathRewrite` 配置,把 cookie path 限定到当前 session。
|
||||
|
||||
- [ ] **Step 2:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:失败,因为 proxy helper 不存在。
|
||||
|
||||
- [ ] **Step 3:实现 Credential client**
|
||||
|
||||
按现有 `NapcatWebuiHttpClient` 契约实现:`sha256(webuiToken + ".napcat")`,POST `/api/auth/login`,每个 session 缓存 Credential 到 revoke/expire。不要记录 token、hash 或 Credential。
|
||||
|
||||
- [ ] **Step 4:实现 proxy helper**
|
||||
|
||||
导出 `sanitizeGatewayProxyPath`、`rewriteNapcatLocationHeader`、`buildGatewayCookiePathRewrite`,逻辑与英文计划一致。Cookie path 改写交给 `http-proxy-middleware` 的 `cookiePathRewrite`,不要手写 `Set-Cookie` 字符串替换。
|
||||
|
||||
- [ ] **Step 5:实现 proxy service**
|
||||
|
||||
使用 `createProxyMiddleware`:
|
||||
|
||||
```ts
|
||||
{
|
||||
changeOrigin: true,
|
||||
cookiePathRewrite: buildGatewayCookiePathRewrite({ sessionId }),
|
||||
on: {
|
||||
proxyReq: handleProxyReq,
|
||||
proxyReqWs: handleProxyReqWs,
|
||||
proxyRes: handleProxyRes,
|
||||
},
|
||||
pathRewrite: (_path, req) => sanitizeGatewayProxyPath(req.params[0] || ''),
|
||||
secure: false,
|
||||
ws: true,
|
||||
selfHandleResponse: false,
|
||||
}
|
||||
```
|
||||
|
||||
代理前必须通过 `sessionService.requireProxySession(sessionId)` 解析 active session,拒绝非 active、stale、终态、过期或缺失 session,换取 Credential,注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。Proxy 路径不要再调用 `markActive()`;session 必须在 bootstrap redirect 前完成激活。
|
||||
|
||||
WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)`;`main.ts` 在 `app.listen()` 后调用:
|
||||
|
||||
```ts
|
||||
const server = app.getHttpServer();
|
||||
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(server);
|
||||
```
|
||||
|
||||
- [ ] **Step 6:实现公开 controller**
|
||||
|
||||
公开路径:
|
||||
|
||||
```text
|
||||
GET /napcat-webui/session/:sessionId/bootstrap
|
||||
ALL /napcat-webui/session/:sessionId/webui/*
|
||||
```
|
||||
|
||||
bootstrap 兑换一次性 ticket,通过 `sessionService.requireBootstrapSession(sessionId)` 校验 bootstrap session,调用 `sessionService.markActive(sessionId)`,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、active-only session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。
|
||||
|
||||
- [ ] **Step 7:运行测试和类型检查**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/apps/napcat-webui-gateway/session-store.spec.ts test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts --runInBand
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS。
|
||||
|
||||
- [ ] **Step 8:提交 Task 4**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api add src/apps/napcat-webui-gateway test/apps/napcat-webui-gateway
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 代理NapCat WebUI流量"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5:接入构建、Docker、K8s 和 API Gateway env
|
||||
|
||||
**Files:**
|
||||
- Create: `test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts`
|
||||
- Create: `dockerfile.gateway`
|
||||
- Modify: `Jenkinsfile`
|
||||
- Modify: `k8s/prod/api.yaml`
|
||||
- Modify: `README.md`
|
||||
- Modify: `API.md`
|
||||
|
||||
- [ ] **Step 1:写部署结构测试**
|
||||
|
||||
创建 `gateway-deployment.spec.ts`,断言:
|
||||
|
||||
- `dockerfile.gateway` 包含 `dist/apps/napcat-webui-gateway/main` 和 `EXPOSE 48086`。
|
||||
- `k8s/prod/api.yaml` 包含 `kt-napcat-webui-gateway`、`containerPort: 48086`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`。
|
||||
- `Jenkinsfile` 包含 `GATEWAY_IMAGE_NAME`、`dockerfile.gateway`、`kt-napcat-webui-gateway`。
|
||||
|
||||
- [ ] **Step 2:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand
|
||||
```
|
||||
|
||||
期望:失败,因为部署文件尚未接入。
|
||||
|
||||
- [ ] **Step 3:新增 `dockerfile.gateway`**
|
||||
|
||||
以现有 `dockerfile` 为基线,改:
|
||||
|
||||
```dockerfile
|
||||
ENV APP_PORT=48086
|
||||
ENV LOG_APP_NAME=kt-napcat-webui-gateway
|
||||
EXPOSE 48086
|
||||
CMD ["node", "dist/apps/napcat-webui-gateway/main"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4:修改 Jenkins**
|
||||
|
||||
新增 `GATEWAY_IMAGE_NAME` 参数,计算 `GATEWAY_DOCKER_IMAGE` 和 `GATEWAY_DOCKER_IMAGE_LATEST`,Docker Build 阶段构建 `dockerfile.gateway`,Docker Push 阶段推送 Gateway 镜像,K8s Deploy 阶段对 API 和 Gateway 两个 Deployment 分别 set image 和 rollout status。
|
||||
|
||||
- [ ] **Step 5:修改 K8s manifest**
|
||||
|
||||
在 `k8s/prod/api.yaml` 中新增 `kt-napcat-webui-gateway` Deployment/Service,容器端口 `48086`,Redis 指向 `kt-qqbot-plugin-redis:6379`,Gateway env secret 复用 `kt-template-online-api-env`。给 API Deployment 增加:
|
||||
|
||||
```yaml
|
||||
- name: NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL
|
||||
value: http://kt-napcat-webui-gateway:48086
|
||||
- name: NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL
|
||||
value: /napcat-webui
|
||||
```
|
||||
|
||||
`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只从线上私有 env secret 读取,不写入 Git。
|
||||
|
||||
- [ ] **Step 6:更新 README/API 文档**
|
||||
|
||||
记录 Gateway env、端口、公开路由、内部路由、验证命令和“浏览器不出现 token/Credential/容器端口”的验收条件。
|
||||
|
||||
- [ ] **Step 7:运行部署测试和构建检查**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run build
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS,build PASS,`dist/apps/napcat-webui-gateway/main.js` 存在。
|
||||
|
||||
- [ ] **Step 8:提交 Task 5**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api add dockerfile.gateway Jenkinsfile k8s/prod/api.yaml README.md API.md test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "feat: 部署NapCat WebUI Gateway"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6:增加 Admin API Client、路由和账号操作
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web-antdv-next/src/api/qqbot/napcat.ts`
|
||||
- Modify: `apps/web-antdv-next/src/api/qqbot/napcat.spec.ts`
|
||||
- Modify: `apps/web-antdv-next/src/router/routes/modules/qqbot.ts`
|
||||
- Modify: `apps/web-antdv-next/src/views/qqbot/account/list.tsx`
|
||||
- Modify: `apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts`
|
||||
|
||||
- [ ] **Step 1:增加 Admin API RED 测试**
|
||||
|
||||
在 `napcat.spec.ts` 中测试 `createQqbotNapcatWebuiSession`、`heartbeatQqbotNapcatWebuiSession`、`revokeQqbotNapcatWebuiSession` 调用正确 URL。
|
||||
|
||||
- [ ] **Step 2:增加 boundary RED 测试**
|
||||
|
||||
在 `napcat-boundary.spec.ts` 中断言 `list.tsx` 只包含路由名 `QqBotAccountNapcatWebui`,不包含 create/heartbeat/revoke caller 和 iframe。
|
||||
|
||||
- [ ] **Step 3:运行 Admin 测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts
|
||||
```
|
||||
|
||||
期望:失败。
|
||||
|
||||
- [ ] **Step 4:增加 Admin API 函数**
|
||||
|
||||
在 `napcat.ts` 增加 `WebuiGatewaySession` 类型,以及 create/heartbeat/revoke 三个函数,路径分别是:
|
||||
|
||||
```text
|
||||
/qqbot/napcat/webui/session
|
||||
/qqbot/napcat/webui/session/:sessionId/heartbeat
|
||||
/qqbot/napcat/webui/session/:sessionId/revoke
|
||||
```
|
||||
|
||||
- [ ] **Step 5:增加隐藏路由**
|
||||
|
||||
在 `qqbot.ts` 增加:
|
||||
|
||||
```ts
|
||||
{
|
||||
component: () => import('#/views/qqbot/account/napcat-webui'),
|
||||
meta: {
|
||||
activePath: '/qqbot/account',
|
||||
hideInMenu: true,
|
||||
title: 'NapCat WebUI',
|
||||
},
|
||||
name: 'QqBotAccountNapcatWebui',
|
||||
path: '/qqbot/account/:accountId/napcat-webui',
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6:增加账号行操作**
|
||||
|
||||
在 `list.tsx` 的 `rowActions` 中加入 WebUI 动作:
|
||||
|
||||
```ts
|
||||
{
|
||||
disabled: (row) => !row.napcat?.containerName || getWebuiStatus(row) === 'offline',
|
||||
key: 'napcatWebui',
|
||||
label: 'WebUI',
|
||||
onClick: openNapcatWebui,
|
||||
permissionCodes: ['QqBot:Account:WebUI'],
|
||||
}
|
||||
```
|
||||
|
||||
`openNapcatWebui(row)` 通过 router push 到 `QqBotAccountNapcatWebui`。
|
||||
|
||||
- [ ] **Step 7:运行 Admin API 和 boundary 测试**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts
|
||||
```
|
||||
|
||||
期望:PASS。
|
||||
|
||||
- [ ] **Step 8:提交 Task 6**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin add apps/web-antdv-next/src/api/qqbot/napcat.ts apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/router/routes/modules/qqbot.ts apps/web-antdv-next/src/views/qqbot/account/list.tsx apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加NapCat WebUI入口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7:实现 Admin 二级页面和生命周期 composable
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx`
|
||||
- Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss`
|
||||
- Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts`
|
||||
- Create: `apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx`
|
||||
|
||||
- [ ] **Step 1:写页面生命周期测试**
|
||||
|
||||
创建 `napcat-webui.spec.tsx`,测试 mounted 调 create session,iframe src 使用返回的 `iframeUrl`,unmount 调 revoke。
|
||||
|
||||
- [ ] **Step 2:运行测试确认 RED**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx
|
||||
```
|
||||
|
||||
期望:失败,因为页面不存在。
|
||||
|
||||
- [ ] **Step 3:实现生命周期 composable**
|
||||
|
||||
创建 `useNapcatWebuiGatewaySession.ts`,状态为 `idle/loading/ready/error/revoked`。使用 Admin 已有的 `@vueuse/core` `useIntervalFn(callback, 20_000, { immediate: false })` 管理 heartbeat,不手写原生定时器。`open()` 创建 session,ready 后 `resumeHeartbeat()`;heartbeat 失败进入 error 并 `pauseHeartbeat()`;`revoke()` 先 `pauseHeartbeat()` 再调 revoke endpoint;`onBeforeUnmount` 自动 revoke。每个函数补 JSDoc。
|
||||
|
||||
- [ ] **Step 4:实现 route 页面**
|
||||
|
||||
创建 `index.tsx`:
|
||||
|
||||
- 单一稳定 root。
|
||||
- 顶部控制栏:返回账号列表、重新打开、关闭 session、展示 selfId/container。
|
||||
- `state=loading` 显示 Spin。
|
||||
- `state=error/revoked` 显示 Alert 和重新打开按钮。
|
||||
- `state=ready` 且有 `iframeUrl` 时显示 iframe。
|
||||
|
||||
- [ ] **Step 5:增加 SCSS**
|
||||
|
||||
创建 `index.scss`,使用 `height: var(--vben-content-height)`、`overflow: hidden`、`hsl(var(--background))`、`hsl(var(--border))`,iframe 占满剩余高度。
|
||||
|
||||
- [ ] **Step 6:运行 Admin 测试和 typecheck**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next run typecheck
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS。
|
||||
|
||||
- [ ] **Step 7:提交 Task 7**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin add apps/web-antdv-next/src/views/qqbot/account/napcat-webui
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加NapCat WebUI二级页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8:本地端到端 smoke
|
||||
|
||||
**Files:**
|
||||
- 无计划内源码改动。验证发现的问题必须回到 Task 1-7 对应失败文件中修复,并在对应仓库提交。
|
||||
|
||||
- [ ] **Step 1:确认仓库类型和包管理器**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch
|
||||
Get-Content D:\MyFiles\KT\Node\kt-template-online-api\.node-version
|
||||
Get-Content D:\MyFiles\KT\Node\kt-template-online-api\package.json | Select-String '"packageManager"'
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin status --short --branch
|
||||
Get-Content D:\MyFiles\KT\Vue\kt-template-admin\.node-version
|
||||
Get-Content D:\MyFiles\KT\Vue\kt-template-admin\package.json | Select-String '"packageManager"'
|
||||
```
|
||||
|
||||
期望:两个仓库都是 Git;API 使用 pnpm 9.15.9;Admin 使用 pnpm 10.28.2。
|
||||
|
||||
- [ ] **Step 2:运行 API 聚焦验证**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/webui-gateway-contract.spec.ts test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts test/apps/napcat-webui-gateway/session-store.spec.ts test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run build
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS,build PASS。
|
||||
|
||||
- [ ] **Step 3:运行 Admin 聚焦验证**
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx
|
||||
pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin --filter @vben/web-antdv-next run typecheck
|
||||
```
|
||||
|
||||
期望:测试 PASS,typecheck PASS。
|
||||
|
||||
- [ ] **Step 4:启动本地 API 和 Gateway**
|
||||
|
||||
```powershell
|
||||
Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Node\kt-template-online-api; pnpm run start:dev *> .kt-workspace\logs\api-webui-gateway-api.log'
|
||||
Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Node\kt-template-online-api; pnpm run start:gateway:dev *> .kt-workspace\logs\api-webui-gateway-service.log'
|
||||
```
|
||||
|
||||
期望:API 监听 `48085`,Gateway 监听 `48086`。
|
||||
|
||||
- [ ] **Step 5:本地调用 API session endpoint**
|
||||
|
||||
使用本地 Admin token 调用:
|
||||
|
||||
```powershell
|
||||
curl.exe -sS -X POST "http://127.0.0.1:48085/qqbot/napcat/webui/session" -H "authorization: Bearer <local-admin-token>" -H "content-type: application/json" --data "{\"accountId\":\"<local-account-id>\"}"
|
||||
```
|
||||
|
||||
期望:返回 `sessionId/iframeUrl/account/container`,不包含 `webuiToken`、`Credential`、`6100`、Docker/NAS 路径。
|
||||
|
||||
- [ ] **Step 6:本地打开 Admin 路由**
|
||||
|
||||
```powershell
|
||||
Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoLogo','-Command','cd D:\MyFiles\KT\Vue\kt-template-admin; pnpm -F @vben/web-antdv-next run dev *> .kt-workspace\logs\admin-webui-gateway.log'
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5999/#/qqbot/account/<local-account-id>/napcat-webui
|
||||
```
|
||||
|
||||
期望:二级页面渲染,创建 session,iframe shell 加载,返回账号列表时 revoke。
|
||||
|
||||
- [ ] **Step 7:清理本地进程**
|
||||
|
||||
```powershell
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.CommandLine -like '*kt-template-online-api*start:dev*' -or $_.CommandLine -like '*kt-template-online-api*start:gateway:dev*' -or $_.CommandLine -like '*kt-template-admin*web-antdv-next*dev*' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
```
|
||||
|
||||
- [ ] **Step 8:提交验证修复**
|
||||
|
||||
验证过程若发现缺陷,分别提交 API/Admin 仓库,只提交本功能相关文件。
|
||||
|
||||
---
|
||||
|
||||
### Task 9:文档、Review、Push、部署和线上闭环
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\MyFiles\KT\TASKS.md`
|
||||
|
||||
- [ ] **Step 1:更新 `TASKS.md`**
|
||||
|
||||
记录范围、关键词和验证证据:API Gateway service、Admin WebUI 二级页面、K8s/Jenkins Gateway 发布、`/qqbot/account/:accountId/napcat-webui`、route-bound session、heartbeat/revoke、完整 WebUI 操作、token/Credential 不下发浏览器。
|
||||
|
||||
- [ ] **Step 2:运行最终本地门禁**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api diff --check
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin diff --check
|
||||
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project api --changed-files <comma-separated-api-files>
|
||||
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project admin --changed-files <comma-separated-admin-files>
|
||||
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run
|
||||
```
|
||||
|
||||
期望:diff check PASS,global-review findings=[],cleanup dry-run deleted=[]。
|
||||
|
||||
- [ ] **Step 3:提交剩余文档**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT add TASKS.md
|
||||
git -C D:\MyFiles\KT commit -m "docs: 记录NapCat WebUI Gateway实施"
|
||||
```
|
||||
|
||||
- [ ] **Step 4:用户明确要求后再 push**
|
||||
|
||||
```powershell
|
||||
git -C D:\MyFiles\KT\Node\kt-template-online-api push origin main
|
||||
git -C D:\MyFiles\KT\Vue\kt-template-admin push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 5:观察 Jenkins/K8s**
|
||||
|
||||
API 仍用 deploy observation:
|
||||
|
||||
```powershell
|
||||
pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --commit <api-commit> --execute
|
||||
```
|
||||
|
||||
Gateway 用 NAS 只读命令补充验证:
|
||||
|
||||
```powershell
|
||||
$script = @'
|
||||
set -eu
|
||||
KUBECONFIG_PATH='/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml'
|
||||
kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod get deployment kt-napcat-webui-gateway -o wide
|
||||
kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod get pod -l app=kt-napcat-webui-gateway
|
||||
kubectl --kubeconfig "$KUBECONFIG_PATH" -n kt-prod logs -l app=kt-napcat-webui-gateway --tail=80
|
||||
'@
|
||||
$script | ssh nas "tr -d '\015' | bash -s"
|
||||
```
|
||||
|
||||
期望:API/Gateway Running/Ready,restartCount 0,Gateway 镜像 tag 与 Jenkins build 匹配。
|
||||
|
||||
- [ ] **Step 6:配置 Caddy/Admin 路由**
|
||||
|
||||
按稳定 Caddy 规则备份 `/opt/nas-gateway/caddy/Caddyfile`,加入 `/napcat-webui/*` 到 Gateway 的反代,执行 `caddy validate` 和 reload,再验证:
|
||||
|
||||
```powershell
|
||||
curl.exe -I https://admin.kwitsukasa.top/napcat-webui/
|
||||
```
|
||||
|
||||
期望:公网路由能到 Gateway,响应不暴露 upstream host。
|
||||
|
||||
- [ ] **Step 7:线上功能 smoke**
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
https://admin.kwitsukasa.top/#/qqbot/account
|
||||
```
|
||||
|
||||
对账号 `1914728559`:
|
||||
|
||||
1. 点击 `WebUI`。
|
||||
2. 确认路由进入 `/#/qqbot/account/<account-id>/napcat-webui`。
|
||||
3. 确认 iframe 加载原版 NapCat WebUI。
|
||||
4. 执行安全 WebUI 操作,例如读取登录状态或打开设置页。
|
||||
5. 确认浏览器 URL 不包含 `webuiToken`、`Credential`、`6100`、容器 IP、NAS 路径或 SSH 路由。
|
||||
6. 返回账号列表。
|
||||
7. 验证 Gateway 日志或审计表出现 revoke 或 heartbeat timeout cleanup。
|
||||
|
||||
- [ ] **Step 8:最终报告**
|
||||
|
||||
报告 API commit、Admin commit、root TASKS commit、测试/typecheck/build 证据、Jenkins/K8s 证据、线上 smoke 证据和剩余 blocker。
|
||||
|
||||
---
|
||||
|
||||
## 自检
|
||||
|
||||
- 覆盖设计:已覆盖账号行入口、二级路由、页面生命周期 session、独立 Gateway、完整 WebUI 操作、浏览器不泄漏 token/Credential、Redis session、MySQL 审计、K8s/Jenkins 部署和线上 smoke。
|
||||
- 禁止词扫描:计划正文没有保留禁止词或跨任务简写。
|
||||
- 类型一致性:统一使用 `QqBot:Account:WebUI`、`/qqbot/napcat/webui/session`、`/napcat-webui/session/:sessionId`、`kt-napcat-webui-gateway`、`48086`、`QqbotNapcatWebuiGatewayService`。
|
||||
|
||||
## 执行交接
|
||||
|
||||
计划已保存到 `docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md`。
|
||||
|
||||
两个执行选项:
|
||||
|
||||
1. **Subagent-Driven(推荐)**:每个任务派一个新 subagent,任务之间主线程 review,迭代更快。
|
||||
2. **Inline Execution**:在当前会话用 executing-plans 执行,按批次检查。
|
||||
|
||||
你选哪个?
|
||||
@ -0,0 +1,386 @@
|
||||
# QQBot NapCat WebUI Gateway Design
|
||||
|
||||
## Background
|
||||
|
||||
QQBot already manages NapCat containers, WebUI tokens, login sessions, runtime
|
||||
profile evidence, and split account status inside the API. Admin can observe and
|
||||
drive the curated login flow, but it cannot open the original NapCat WebUI for a
|
||||
specific account without manually exposing container ports and tokens.
|
||||
|
||||
The new requirement is to open the original NapCat WebUI from Admin for one
|
||||
QQBot account, with full WebUI operation capability, while keeping NapCat
|
||||
container addresses, WebUI tokens, credentials, and host topology server-side.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a QQBot account-row operation that opens the selected account's NapCat
|
||||
WebUI in a second-level Admin route page.
|
||||
- Implement NapCat WebUI access through an independent
|
||||
`kt-napcat-webui-gateway` service, not through direct browser access to
|
||||
NapCat container ports.
|
||||
- Bind gateway access sessions to the Admin route page lifecycle.
|
||||
- Allow full NapCat WebUI operations inside the embedded page.
|
||||
- Keep NapCat WebUI tokens, NapCat credentials, container IPs, host ports, and
|
||||
NAS topology out of browser-visible payloads.
|
||||
- Record auditable evidence for session creation, activation, heartbeat,
|
||||
revocation, expiration, and proxy failures.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not reimplement the NapCat WebUI as native Admin components.
|
||||
- Do not make the first version read-only.
|
||||
- Do not expose NapCat container ports to the public network.
|
||||
- Do not let the browser call arbitrary upstream URLs through the gateway.
|
||||
- Do not merge this gateway into the existing login SSE state machine.
|
||||
|
||||
## User Experience
|
||||
|
||||
Admin adds a new WebUI action on the QQBot account list connection/action area.
|
||||
Clicking it navigates to:
|
||||
|
||||
```text
|
||||
/qqbot/account/:accountId/napcat-webui
|
||||
```
|
||||
|
||||
The route page is a remote console, not a drawer. It has:
|
||||
|
||||
- a compact Admin-owned header with back navigation, account identity, container
|
||||
status, session status, refresh session, and close session actions;
|
||||
- one full-height iframe that loads the gateway URL;
|
||||
- a failure state for unauthorized, missing container, WebUI offline, session
|
||||
expired, or gateway unavailable;
|
||||
- no page-level body scrolling beyond the existing Vben layout content area.
|
||||
|
||||
The route page owns the gateway session. Opening the same WebUI from the account
|
||||
list always creates a new route-bound session. Page refresh creates a new
|
||||
session and lets the previous session expire or be revoked by heartbeat timeout.
|
||||
|
||||
## High-level Architecture
|
||||
|
||||
```text
|
||||
Admin account list
|
||||
-> Admin route /qqbot/account/:accountId/napcat-webui
|
||||
-> API: create route-bound gateway session
|
||||
-> Gateway: store active session and NapCat target metadata
|
||||
-> Admin iframe: gateway bootstrap URL
|
||||
-> Gateway: proxy NapCat WebUI HTML, assets, APIs, and WebSocket traffic
|
||||
-> NapCat WebUI container
|
||||
```
|
||||
|
||||
The API remains the authority for Admin authentication and QQBot account
|
||||
binding. The Gateway remains the authority for active WebUI proxy sessions and
|
||||
NapCat WebUI credential exchange.
|
||||
|
||||
## Service Boundaries
|
||||
|
||||
### API service
|
||||
|
||||
The API service owns Admin-facing authorization and account resolution:
|
||||
|
||||
- verify the Admin JWT and QQBot account permissions;
|
||||
- resolve account, selfId, primary NapCat binding, container id/name, WebUI
|
||||
base URL, and WebUI token from existing persistence;
|
||||
- reject missing, offline, or non-owned NapCat targets before a gateway session
|
||||
is created;
|
||||
- call the Gateway internal API with a service-to-service secret;
|
||||
- return only an opaque `sessionId`, `iframeUrl`, and safe display metadata to
|
||||
Admin;
|
||||
- proxy heartbeat/revoke calls from Admin to the Gateway internal API;
|
||||
- write or forward audit events.
|
||||
|
||||
The API must not return `webuiToken`, NapCat credential, upstream host, upstream
|
||||
port, Docker container IP, or NAS SSH details.
|
||||
|
||||
### Gateway service
|
||||
|
||||
`kt-napcat-webui-gateway` is deployed as a separate process, image, and K8s
|
||||
Deployment. It owns:
|
||||
|
||||
- active session storage and TTL enforcement;
|
||||
- one-time iframe bootstrap tickets;
|
||||
- NapCat WebUI credential login using the existing `sha256(token + ".napcat")`
|
||||
contract;
|
||||
- proxying HTTP methods, static assets, WebUI API calls, redirects, cookies, and
|
||||
WebSocket upgrade traffic;
|
||||
- session heartbeat, revocation, expiration, and cleanup;
|
||||
- gateway-side audit events and structured logs.
|
||||
|
||||
The Gateway only proxies to the target already stored in the session. It never
|
||||
accepts a browser-supplied upstream URL.
|
||||
|
||||
### Admin frontend
|
||||
|
||||
Admin owns the route UI and lifecycle:
|
||||
|
||||
- navigate from account list action to the second-level route page;
|
||||
- create a session when the page mounts;
|
||||
- load the returned iframe URL only after session creation succeeds;
|
||||
- send heartbeat while the route is mounted;
|
||||
- revoke the session on route leave, explicit close, component unmount, and
|
||||
best-effort browser unload;
|
||||
- render expired/revoked/error states without blank iframes.
|
||||
|
||||
## Gateway Session Lifecycle
|
||||
|
||||
Sessions are route-bound leases:
|
||||
|
||||
```text
|
||||
created -> active -> revoked
|
||||
created -> expired
|
||||
active -> expired
|
||||
created/active -> failed
|
||||
```
|
||||
|
||||
Creation flow:
|
||||
|
||||
1. Admin route mounts and calls `POST /qqbot/napcat/webui/session`.
|
||||
2. API authorizes the current Admin user and resolves the selected account's
|
||||
active NapCat target.
|
||||
3. API calls Gateway internal `POST /internal/sessions` with safe account
|
||||
metadata plus encrypted or service-internal target credentials.
|
||||
4. Gateway creates an active-session record with TTL and a one-time bootstrap
|
||||
ticket.
|
||||
5. API returns the public iframe bootstrap URL.
|
||||
6. The iframe loads the bootstrap URL. Gateway redeems the one-time ticket,
|
||||
sets an HttpOnly path-scoped gateway cookie, and redirects to the session
|
||||
WebUI root.
|
||||
7. Gateway marks the session active after the first successful proxied WebUI
|
||||
response.
|
||||
|
||||
Heartbeat and cleanup:
|
||||
|
||||
- Admin sends heartbeat every 15 to 30 seconds while the route is mounted.
|
||||
- Gateway updates `lastSeenAt`.
|
||||
- If no heartbeat arrives for 60 to 90 seconds, Gateway expires the session.
|
||||
- Route leave, explicit close, and component unmount call revoke.
|
||||
- `beforeunload` uses a best-effort sendBeacon revoke, but correctness relies on
|
||||
TTL timeout because browser unload is not guaranteed.
|
||||
|
||||
Concurrent access policy:
|
||||
|
||||
- One active WebUI session is allowed per Admin user plus account.
|
||||
- Creating a new session for the same Admin user and account revokes the older
|
||||
session.
|
||||
- Other Admin users require their own authorization and separate sessions.
|
||||
|
||||
Expired or revoked sessions return HTTP 410 for iframe/proxy requests and a
|
||||
small gateway error page that tells the Admin page to reopen the route session.
|
||||
|
||||
## Public and Internal Contracts
|
||||
|
||||
### API Admin contracts
|
||||
|
||||
```text
|
||||
POST /qqbot/napcat/webui/session
|
||||
Body: { accountId: string }
|
||||
Result: {
|
||||
sessionId: string;
|
||||
iframeUrl: string;
|
||||
expiresAt: number;
|
||||
account: { id: string; selfId: string; nickname?: string };
|
||||
container: { id: string; name: string; webuiStatus: string };
|
||||
}
|
||||
|
||||
POST /qqbot/napcat/webui/session/:sessionId/heartbeat
|
||||
Result: { sessionId: string; expiresAt: number; status: "active" }
|
||||
|
||||
POST /qqbot/napcat/webui/session/:sessionId/revoke
|
||||
Result: true
|
||||
```
|
||||
|
||||
The Admin API responses contain only display-safe metadata.
|
||||
|
||||
### Gateway public contracts
|
||||
|
||||
```text
|
||||
GET /napcat-webui/session/:sessionId/bootstrap?ticket=...
|
||||
GET /napcat-webui/session/:sessionId/webui/*
|
||||
POST /napcat-webui/session/:sessionId/webui/*
|
||||
WS /napcat-webui/session/:sessionId/webui/*
|
||||
```
|
||||
|
||||
The bootstrap ticket is short-lived and one-time. After redemption, the browser
|
||||
uses only a path-scoped gateway session cookie. The iframe URL should not keep a
|
||||
long-lived bearer token in the address bar.
|
||||
|
||||
### Gateway internal contracts
|
||||
|
||||
```text
|
||||
POST /internal/sessions
|
||||
POST /internal/sessions/:sessionId/heartbeat
|
||||
POST /internal/sessions/:sessionId/revoke
|
||||
GET /internal/health
|
||||
```
|
||||
|
||||
Internal endpoints require a service-to-service secret or mTLS-equivalent
|
||||
cluster boundary. They are not exposed through the public Caddy/Admin route.
|
||||
|
||||
## Proxy Behavior
|
||||
|
||||
The Gateway must support:
|
||||
|
||||
- all HTTP methods used by the NapCat WebUI;
|
||||
- JSON and binary payloads;
|
||||
- static assets;
|
||||
- redirects with `Location` rewritten back under the gateway session prefix;
|
||||
- cookies rewritten to a session-scoped path and never exposed across accounts;
|
||||
- WebSocket upgrade traffic if NapCat WebUI uses it;
|
||||
- `Cache-Control: no-store` for sensitive proxied responses;
|
||||
- removal or replacement of upstream frame headers that would block Admin's
|
||||
iframe, while preserving safe CSP around the gateway route.
|
||||
|
||||
If NapCat WebUI emits absolute `/api` or `/webui` paths, Gateway rewrites the
|
||||
HTML and response headers so browser requests remain under:
|
||||
|
||||
```text
|
||||
/napcat-webui/session/:sessionId/webui/
|
||||
```
|
||||
|
||||
The proxy must reject path traversal, encoded upstream URL injection, and any
|
||||
request that escapes the bound session prefix.
|
||||
|
||||
## Security Model
|
||||
|
||||
Full NapCat WebUI operations are allowed. Authorization is therefore explicit:
|
||||
an Admin user who can open the route for an account can fully operate that
|
||||
account's NapCat WebUI.
|
||||
|
||||
Security controls:
|
||||
|
||||
- Admin JWT is checked by API before session creation.
|
||||
- Gateway session is opaque, short-lived, and bound to Admin user id, account id,
|
||||
selfId, container id, client metadata, and route lease.
|
||||
- NapCat WebUI token and credential never leave server-side services.
|
||||
- Browser-visible URLs contain at most a one-time bootstrap ticket.
|
||||
- Gateway cookies are HttpOnly, Secure, SameSite, and path-scoped to the
|
||||
session.
|
||||
- Public gateway requests cannot select upstream hosts or ports.
|
||||
- Internal API-to-Gateway calls are authenticated.
|
||||
- Audit records include user id, account id, selfId, container id, session id,
|
||||
event type, client IP, user agent, timestamps, and safe error summaries.
|
||||
|
||||
## Data Model
|
||||
|
||||
Active runtime session state lives in Redis so Gateway replicas can share it:
|
||||
|
||||
```text
|
||||
napcat:webui:session:{sessionId}
|
||||
status
|
||||
adminUserId
|
||||
accountId
|
||||
selfId
|
||||
containerId
|
||||
containerName
|
||||
upstreamBaseUrl
|
||||
encryptedWebuiToken or encrypted credential material
|
||||
createdAt
|
||||
activeAt
|
||||
lastSeenAt
|
||||
expiresAt
|
||||
revokedAt
|
||||
```
|
||||
|
||||
Gateway audit history is persisted in MySQL, either through the API service or a
|
||||
Gateway-owned table managed from the API repository:
|
||||
|
||||
```text
|
||||
qqbot_napcat_webui_gateway_audit
|
||||
id
|
||||
session_id
|
||||
admin_user_id
|
||||
account_id
|
||||
self_id
|
||||
container_id
|
||||
event_type
|
||||
client_ip
|
||||
user_agent
|
||||
detail_json
|
||||
create_time
|
||||
```
|
||||
|
||||
No WebUI token, NapCat credential, QQ password, captcha ticket, QR payload, or
|
||||
raw proxied response is stored in audit rows.
|
||||
|
||||
## Deployment
|
||||
|
||||
The first implementation keeps the service source in the API repository but
|
||||
builds and deploys it as a separate service:
|
||||
|
||||
- independent app entry for `kt-napcat-webui-gateway`;
|
||||
- independent Dockerfile or Docker target;
|
||||
- Jenkins build and push for the Gateway image;
|
||||
- K8s Deployment and Service;
|
||||
- Caddy/Admin domain route such as:
|
||||
|
||||
```text
|
||||
https://admin.kwitsukasa.top/napcat-webui/*
|
||||
```
|
||||
|
||||
The route points to the Gateway service, while the existing API remains under
|
||||
`/api/*`. Local development should provide an equivalent Vite or Caddy proxy so
|
||||
Admin can test the iframe route without exposing NapCat container ports.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing account or no active NapCat binding: Admin route shows a clear empty
|
||||
state with a link back to the account list.
|
||||
- Container offline or WebUI unavailable: session creation fails before iframe
|
||||
load and shows the runtime evidence returned by API.
|
||||
- Gateway cannot authenticate to NapCat WebUI: session fails with a sanitized
|
||||
error and an audit event.
|
||||
- Session expired or revoked: iframe receives HTTP 410 and Admin page offers a
|
||||
one-click session reopen.
|
||||
- Gateway target proxy timeout: render an error overlay and keep route controls
|
||||
available.
|
||||
- API and Gateway clocks must not be used as the only correctness boundary;
|
||||
Redis TTL and heartbeat timestamps drive cleanup.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Backend/API:
|
||||
|
||||
- unit test account authorization and session creation DTOs;
|
||||
- unit test no secret fields are returned to Admin;
|
||||
- integration test API creates and revokes Gateway sessions through a mocked
|
||||
internal Gateway client;
|
||||
- audit tests for create, active, heartbeat, revoke, expire, and proxy failure.
|
||||
|
||||
Gateway:
|
||||
|
||||
- unit test bootstrap ticket redemption and one-time use;
|
||||
- unit test session TTL, heartbeat, revoke, and concurrent session revocation;
|
||||
- proxy tests for path rewriting, cookie scoping, redirect rewriting, and
|
||||
forbidden upstream URL injection;
|
||||
- WebSocket upgrade smoke if NapCat WebUI requires it.
|
||||
|
||||
Admin:
|
||||
|
||||
- route test for account-row WebUI action navigation;
|
||||
- lifecycle test for mount create, heartbeat, unmount revoke;
|
||||
- dark-theme and layout tests for the remote console page;
|
||||
- Playwright smoke that opens the route, waits for iframe shell load, leaves the
|
||||
route, and verifies revoke was called.
|
||||
|
||||
Online:
|
||||
|
||||
- deploy API and Gateway through Jenkins/K8s;
|
||||
- verify Gateway health and route availability under the Admin domain;
|
||||
- open the WebUI page for account `1914728559`;
|
||||
- confirm the iframe loads the original NapCat WebUI without exposing tokens or
|
||||
container ports in the browser URL;
|
||||
- perform one safe WebUI operation such as reading login status or opening a
|
||||
settings page;
|
||||
- leave the route and verify the session is revoked or expires by heartbeat
|
||||
timeout.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Admin has a working second-level NapCat WebUI route per QQBot account.
|
||||
- The Gateway is independently deployed and observable.
|
||||
- Full NapCat WebUI operation works through the iframe route.
|
||||
- Browser-visible data contains no NapCat token, credential, container IP, host
|
||||
port, NAS SSH route, or QQ password.
|
||||
- Session lifecycle follows the Admin route lifecycle and cleans up on route
|
||||
leave or heartbeat timeout.
|
||||
- API, Gateway, Admin tests and online smoke evidence are recorded before the
|
||||
feature is considered complete.
|
||||
@ -0,0 +1,321 @@
|
||||
# QQBot NapCat WebUI Gateway 设计
|
||||
|
||||
## 背景
|
||||
|
||||
QQBot 当前已经由 API 管理 NapCat 容器、WebUI token、登录 session、运行态证据和账号拆分状态。Admin 可以使用定制过的登录弹窗完成扫码、验证码和新设备流程,但还不能从某个 QQBot 账号定点打开原版 NapCat WebUI。
|
||||
|
||||
本次目标是在 Admin 中打开指定账号对应的原版 NapCat WebUI,并允许完整操作,同时不把 NapCat 容器端口、WebUI token、Credential 或 NAS 拓扑暴露给浏览器。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在 QQBot 账号列表的账号连接列或操作区增加 WebUI 入口。
|
||||
- 点击入口后进入二级路由页面,而不是抽屉。
|
||||
- 使用独立 `kt-napcat-webui-gateway` 微服务承载 NapCat WebUI 代理。
|
||||
- Gateway session 跟随二级页面生命周期创建、心跳和销毁。
|
||||
- iframe 内允许 NapCat WebUI 完整操作。
|
||||
- 浏览器侧不暴露 NapCat token、Credential、容器 IP、宿主端口或 NAS 内网信息。
|
||||
- 记录 session 创建、激活、心跳、撤销、过期和代理失败审计证据。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把 NapCat WebUI 重写成 Admin 原生组件。
|
||||
- 第一版不做只读限制。
|
||||
- 不直接公开 NapCat 容器端口。
|
||||
- 不允许浏览器通过 Gateway 任意代理外部 URL。
|
||||
- 不把该 Gateway 合并到现有 NapCat 登录 SSE 状态机。
|
||||
|
||||
## 页面形态
|
||||
|
||||
Admin 新增二级路由:
|
||||
|
||||
```text
|
||||
/qqbot/account/:accountId/napcat-webui
|
||||
```
|
||||
|
||||
QQBot 账号列表的 WebUI 按钮跳转到该路由。页面是远程控制台,而不是抽屉。页面结构:
|
||||
|
||||
- 顶部是 Admin 自己的轻量控制栏:返回账号列表、账号信息、容器状态、session 状态、刷新 session、关闭 session。
|
||||
- 主体是全高 iframe,加载 Gateway 返回的 NapCat WebUI URL。
|
||||
- 未授权、无容器、WebUI 离线、session 过期、Gateway 不可用时显示明确错误态。
|
||||
- 页面高度遵循 Vben Layout 内容区,不额外制造 body 滚动。
|
||||
|
||||
刷新页面会创建新 session;旧 session 通过主动 revoke 或 heartbeat 超时清理。重复从账号列表打开同一账号时创建新 session,不复用旧 session。
|
||||
|
||||
## 总体架构
|
||||
|
||||
```text
|
||||
Admin 账号列表
|
||||
-> Admin 二级路由 /qqbot/account/:accountId/napcat-webui
|
||||
-> API 创建 route-bound Gateway session
|
||||
-> Gateway 保存活跃 session 与 NapCat 目标信息
|
||||
-> Admin iframe 打开 Gateway bootstrap URL
|
||||
-> Gateway 反代 NapCat WebUI HTML、静态资源、API 和 WebSocket
|
||||
-> NapCat WebUI 容器
|
||||
```
|
||||
|
||||
API 是 Admin 鉴权和 QQBot 账号绑定的权威。Gateway 是 WebUI 代理 session 和 NapCat WebUI Credential 交换的权威。
|
||||
|
||||
## 服务边界
|
||||
|
||||
### API 服务
|
||||
|
||||
API 负责 Admin 侧鉴权和账号解析:
|
||||
|
||||
- 校验 Admin JWT 与 QQBot 账号权限。
|
||||
- 根据账号解析 selfId、主 NapCat 绑定、容器 ID/名称、WebUI base URL 和 WebUI token。
|
||||
- 在创建 Gateway session 前拒绝无容器、容器离线、WebUI 不可用或无权限的请求。
|
||||
- 使用服务间密钥调用 Gateway 内部接口。
|
||||
- 只把 `sessionId`、`iframeUrl` 和安全展示字段返回给 Admin。
|
||||
- 代理 Admin 发来的 heartbeat/revoke 到 Gateway 内部接口。
|
||||
- 写入或转发审计事件。
|
||||
|
||||
API 不返回 `webuiToken`、NapCat Credential、容器 IP、宿主端口、Docker 网络或 NAS SSH 信息。
|
||||
|
||||
### Gateway 服务
|
||||
|
||||
`kt-napcat-webui-gateway` 独立进程、独立镜像、独立 K8s Deployment。它负责:
|
||||
|
||||
- 活跃 session 存储与 TTL 管理。
|
||||
- 一次性 iframe bootstrap ticket。
|
||||
- 按 NapCat 现有契约使用 `sha256(token + ".napcat")` 登录 WebUI 并换取 Credential。
|
||||
- 反代 HTTP 方法、静态资源、WebUI API、重定向、Cookie 和 WebSocket upgrade。
|
||||
- session heartbeat、revoke、expire 和清理。
|
||||
- Gateway 侧审计和结构化日志。
|
||||
|
||||
Gateway 只代理 session 内已绑定的目标容器,不接受浏览器传入的 upstream URL。
|
||||
|
||||
### Admin 前端
|
||||
|
||||
Admin 负责路由页面和生命周期:
|
||||
|
||||
- 账号列表 WebUI 按钮跳转到二级路由。
|
||||
- 页面 mounted 时创建 session。
|
||||
- session 创建成功后再加载 iframe。
|
||||
- 页面 mounted 期间持续 heartbeat。
|
||||
- 路由离开、显式关闭、组件 unmount、浏览器卸载时尽量 revoke。
|
||||
- session 过期或失败时展示可读错误态,避免 iframe 白屏。
|
||||
|
||||
## Gateway Session 生命周期
|
||||
|
||||
session 是页面生命周期租约:
|
||||
|
||||
```text
|
||||
created -> active -> revoked
|
||||
created -> expired
|
||||
active -> expired
|
||||
created/active -> failed
|
||||
```
|
||||
|
||||
创建流程:
|
||||
|
||||
1. Admin 二级页面 mounted,调用 `POST /qqbot/napcat/webui/session`。
|
||||
2. API 校验当前 Admin 用户并解析目标账号的 NapCat 容器。
|
||||
3. API 调用 Gateway 内部 `POST /internal/sessions`,传入安全账号元数据和服务端目标凭据。
|
||||
4. Gateway 创建带 TTL 的 session,并生成一次性 bootstrap ticket。
|
||||
5. API 返回公开 iframe bootstrap URL。
|
||||
6. iframe 打开 bootstrap URL,Gateway 兑换一次性 ticket,设置 HttpOnly、路径隔离的 gateway cookie,并跳转到 session WebUI 根路径。
|
||||
7. Gateway 在第一次成功代理 WebUI 响应后将 session 标记为 active。
|
||||
|
||||
心跳和清理:
|
||||
|
||||
- Admin 页面 mounted 时每 15 到 30 秒发送 heartbeat。
|
||||
- Gateway 更新 `lastSeenAt`。
|
||||
- 60 到 90 秒无 heartbeat 时 Gateway 自动 expire。
|
||||
- 路由离开、显式关闭、组件 unmount 时主动 revoke。
|
||||
- `beforeunload` 使用 sendBeacon 尽量 revoke;正确性仍依赖 TTL,因为浏览器卸载不保证执行。
|
||||
|
||||
并发策略:
|
||||
|
||||
- 同一个 Admin 用户对同一个 QQBot 账号只允许一个活跃 WebUI session。
|
||||
- 创建新 session 时撤销旧 session。
|
||||
- 不同 Admin 用户需要各自鉴权并创建独立 session。
|
||||
|
||||
revoked 或 expired 后,Gateway 对 iframe/proxy 请求返回 410,并展示“会话已关闭,请重新打开”的轻量错误页。
|
||||
|
||||
## 接口契约
|
||||
|
||||
### API Admin 接口
|
||||
|
||||
```text
|
||||
POST /qqbot/napcat/webui/session
|
||||
Body: { accountId: string }
|
||||
Result: {
|
||||
sessionId: string;
|
||||
iframeUrl: string;
|
||||
expiresAt: number;
|
||||
account: { id: string; selfId: string; nickname?: string };
|
||||
container: { id: string; name: string; webuiStatus: string };
|
||||
}
|
||||
|
||||
POST /qqbot/napcat/webui/session/:sessionId/heartbeat
|
||||
Result: { sessionId: string; expiresAt: number; status: "active" }
|
||||
|
||||
POST /qqbot/napcat/webui/session/:sessionId/revoke
|
||||
Result: true
|
||||
```
|
||||
|
||||
这些接口只返回展示安全字段。
|
||||
|
||||
### Gateway 公开接口
|
||||
|
||||
```text
|
||||
GET /napcat-webui/session/:sessionId/bootstrap?ticket=...
|
||||
GET /napcat-webui/session/:sessionId/webui/*
|
||||
POST /napcat-webui/session/:sessionId/webui/*
|
||||
WS /napcat-webui/session/:sessionId/webui/*
|
||||
```
|
||||
|
||||
bootstrap ticket 短期有效且只能使用一次。ticket 兑换后,浏览器只依赖路径隔离的 gateway cookie,不在地址栏长期保留 bearer token。
|
||||
|
||||
### Gateway 内部接口
|
||||
|
||||
```text
|
||||
POST /internal/sessions
|
||||
POST /internal/sessions/:sessionId/heartbeat
|
||||
POST /internal/sessions/:sessionId/revoke
|
||||
GET /internal/health
|
||||
```
|
||||
|
||||
内部接口需要服务间密钥或等价的集群内边界,不通过公开 Admin 域暴露。
|
||||
|
||||
## 代理行为
|
||||
|
||||
Gateway 必须支持:
|
||||
|
||||
- NapCat WebUI 使用的全部 HTTP 方法。
|
||||
- JSON 和二进制 payload。
|
||||
- 静态资源。
|
||||
- `Location` 重写到 Gateway session 前缀下。
|
||||
- Cookie path 改写到当前 session,避免跨账号串用。
|
||||
- WebSocket upgrade。
|
||||
- 敏感响应使用 `Cache-Control: no-store`。
|
||||
- 处理或替换会阻止 Admin iframe 的 upstream frame header,同时保留 Gateway 自身安全 CSP。
|
||||
|
||||
如果 NapCat WebUI 输出绝对 `/api` 或 `/webui` 路径,Gateway 需要重写 HTML 和响应头,让浏览器请求始终留在:
|
||||
|
||||
```text
|
||||
/napcat-webui/session/:sessionId/webui/
|
||||
```
|
||||
|
||||
Gateway 必须拒绝路径穿越、编码 URL 注入和任何逃逸当前 session 前缀的请求。
|
||||
|
||||
## 安全模型
|
||||
|
||||
iframe 内允许 NapCat WebUI 完整操作。因此权限语义必须直接明确:能进入该账号 NapCat WebUI 二级页面的 Admin 用户,就拥有该账号 NapCat WebUI 的完整操作权。
|
||||
|
||||
安全控制:
|
||||
|
||||
- API 创建 session 前校验 Admin JWT。
|
||||
- Gateway session 绑定 Admin 用户 ID、账号 ID、selfId、容器 ID、客户端信息和页面租约。
|
||||
- NapCat WebUI token 与 Credential 只存在服务端。
|
||||
- 浏览器可见 URL 最多包含一次性 bootstrap ticket。
|
||||
- Gateway cookie 使用 HttpOnly、Secure、SameSite,并按 session path 隔离。
|
||||
- 公开 Gateway 请求不能选择 upstream host 或 port。
|
||||
- API 到 Gateway 的内部调用必须鉴权。
|
||||
- 审计记录用户、账号、selfId、容器、session、事件类型、IP、UA、时间和安全错误摘要。
|
||||
|
||||
## 数据模型
|
||||
|
||||
活跃 session 使用 Redis 保存,便于 Gateway 多副本共享:
|
||||
|
||||
```text
|
||||
napcat:webui:session:{sessionId}
|
||||
status
|
||||
adminUserId
|
||||
accountId
|
||||
selfId
|
||||
containerId
|
||||
containerName
|
||||
upstreamBaseUrl
|
||||
encryptedWebuiToken or encrypted credential material
|
||||
createdAt
|
||||
activeAt
|
||||
lastSeenAt
|
||||
expiresAt
|
||||
revokedAt
|
||||
```
|
||||
|
||||
审计历史持久化到 MySQL,可由 API 写入,也可以由 Gateway 通过 API 仓库管理的表写入:
|
||||
|
||||
```text
|
||||
qqbot_napcat_webui_gateway_audit
|
||||
id
|
||||
session_id
|
||||
admin_user_id
|
||||
account_id
|
||||
self_id
|
||||
container_id
|
||||
event_type
|
||||
client_ip
|
||||
user_agent
|
||||
detail_json
|
||||
create_time
|
||||
```
|
||||
|
||||
审计表不保存 WebUI token、NapCat Credential、QQ 密码、验证码 ticket、二维码内容或原始代理响应。
|
||||
|
||||
## 部署
|
||||
|
||||
第一版源码放在 API 仓库,但作为独立服务构建和部署:
|
||||
|
||||
- 独立 `kt-napcat-webui-gateway` app entry。
|
||||
- 独立 Dockerfile 或 Docker build target。
|
||||
- Jenkins 构建并推送 Gateway 镜像。
|
||||
- K8s 独立 Deployment 和 Service。
|
||||
- Caddy/Admin 域增加路由,例如:
|
||||
|
||||
```text
|
||||
https://admin.kwitsukasa.top/napcat-webui/*
|
||||
```
|
||||
|
||||
该路由指向 Gateway 服务,现有 API 继续位于 `/api/*`。本地开发需要等价 Vite 或 Caddy proxy,避免为了 iframe 调试而暴露 NapCat 容器端口。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 账号不存在或无有效 NapCat 绑定:页面显示空状态并提供返回账号列表入口。
|
||||
- 容器离线或 WebUI 不可用:session 创建前失败,并展示 API 返回的运行态证据。
|
||||
- Gateway 无法登录 NapCat WebUI:session failed,展示脱敏错误并写审计。
|
||||
- session 过期或撤销:iframe 收到 410,Admin 页面提供重新打开按钮。
|
||||
- Gateway 代理目标超时:显示错误覆盖层,保留页面顶部控制栏。
|
||||
- API/Gateway 不只依赖本机时钟判断正确性,清理以 Redis TTL 和 heartbeat 时间戳为准。
|
||||
|
||||
## 验证策略
|
||||
|
||||
API:
|
||||
|
||||
- 单测账号权限和 session 创建 DTO。
|
||||
- 单测 Admin 响应不包含敏感字段。
|
||||
- 集成测试 API 通过 mock Gateway client 创建、心跳和撤销 session。
|
||||
- 审计测试覆盖 create、active、heartbeat、revoke、expire、proxy failure。
|
||||
|
||||
Gateway:
|
||||
|
||||
- 单测 bootstrap ticket 兑换和一次性使用。
|
||||
- 单测 session TTL、heartbeat、revoke 和同用户同账号并发 session 撤销。
|
||||
- 代理测试覆盖路径重写、Cookie 隔离、重定向重写、禁止 upstream URL 注入。
|
||||
- 如果 NapCat WebUI 使用 WebSocket,则增加 WebSocket upgrade smoke。
|
||||
|
||||
Admin:
|
||||
|
||||
- 账号行 WebUI 按钮路由跳转测试。
|
||||
- 页面生命周期测试:mounted 创建 session、heartbeat、unmount revoke。
|
||||
- 暗色主题和 Layout 高度测试。
|
||||
- Playwright smoke:打开二级路由、等待 iframe shell 加载、离开路由、确认 revoke 被调用。
|
||||
|
||||
线上:
|
||||
|
||||
- Jenkins/K8s 部署 API 和 Gateway。
|
||||
- 验证 Gateway health 与 Admin 域 `/napcat-webui/*` 路由。
|
||||
- 打开账号 `1914728559` 的 WebUI 页面。
|
||||
- 确认 iframe 加载原版 NapCat WebUI,浏览器 URL 不暴露 token、Credential、容器端口。
|
||||
- 执行一个安全 WebUI 操作,例如读取登录状态或打开设置页。
|
||||
- 离开路由后确认 session revoke 或 heartbeat 超时回收。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- Admin 支持按 QQBot 账号打开二级 NapCat WebUI 页面。
|
||||
- Gateway 独立部署且可观测。
|
||||
- iframe 中完整 NapCat WebUI 操作可用。
|
||||
- 浏览器侧不暴露 NapCat token、Credential、容器 IP、宿主端口、NAS SSH 路由或 QQ 密码。
|
||||
- session 生命周期跟随 Admin 二级页面,路由离开或 heartbeat 超时后会清理。
|
||||
- API、Gateway、Admin 测试和线上 smoke 证据齐全后才算完成。
|
||||
@ -42,6 +42,10 @@ spec:
|
||||
value: kt:qqbot:plugin-task
|
||||
- name: BANGDREAM_TSUGU_CACHE_ROOT
|
||||
value: /data/qqbot/plugins/bangdream/cache
|
||||
- name: NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL
|
||||
value: http://kt-napcat-webui-gateway:48086
|
||||
- name: NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL
|
||||
value: /napcat-webui
|
||||
# Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。
|
||||
envFrom:
|
||||
- secretRef:
|
||||
@ -162,6 +166,87 @@ spec:
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kt-napcat-webui-gateway
|
||||
namespace: kt-prod
|
||||
labels:
|
||||
app: kt-napcat-webui-gateway
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: kt-napcat-webui-gateway
|
||||
ports:
|
||||
- name: http
|
||||
port: 48086
|
||||
targetPort: 48086
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kt-napcat-webui-gateway
|
||||
namespace: kt-prod
|
||||
labels:
|
||||
app: kt-napcat-webui-gateway
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kt-napcat-webui-gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: kt-napcat-webui-gateway
|
||||
spec:
|
||||
containers:
|
||||
- name: gateway
|
||||
image: k3d-kt-registry.localhost:5000/kt-napcat-webui-gateway:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 48086
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: TZ
|
||||
value: Asia/Shanghai
|
||||
- name: NAPCAT_WEBUI_GATEWAY_PORT
|
||||
value: "48086"
|
||||
- name: NAPCAT_WEBUI_GATEWAY_REDIS_HOST
|
||||
value: kt-qqbot-plugin-redis
|
||||
- name: NAPCAT_WEBUI_GATEWAY_REDIS_PORT
|
||||
value: "6379"
|
||||
- name: NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kt-template-online-api-env
|
||||
key: NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 48086
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 48086
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kt-qqbot-plugin-redis
|
||||
namespace: kt-prod
|
||||
|
||||
@ -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": "^3.0.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.15.1",
|
||||
|
||||
245
pnpm-lock.yaml
245
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: ^3.0.7
|
||||
version: 3.0.7
|
||||
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:
|
||||
@ -1237,6 +1304,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/http-proxy@1.17.17':
|
||||
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6':
|
||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||
|
||||
@ -1499,6 +1569,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 +1733,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 +1878,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 +1901,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 +1932,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'}
|
||||
@ -2256,6 +2345,9 @@ packages:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@ -2606,6 +2698,14 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy-middleware@3.0.7:
|
||||
resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
|
||||
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
http-proxy@1.18.1:
|
||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -2665,6 +2765,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'}
|
||||
@ -2731,6 +2835,10 @@ packages:
|
||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-object@5.0.0:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
@ -3801,6 +3909,9 @@ packages:
|
||||
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
|
||||
engines: {node: '>=0.10.5'}
|
||||
|
||||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
resolve-cwd@3.0.0:
|
||||
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
||||
engines: {node: '>=8'}
|
||||
@ -4580,6 +4691,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 +5233,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 +5739,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 +5889,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 +5910,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': {}
|
||||
|
||||
@ -5946,6 +6100,10 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/http-proxy@1.17.17':
|
||||
dependencies:
|
||||
'@types/node': 22.19.19
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6': {}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.3':
|
||||
@ -6272,6 +6430,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:
|
||||
@ -6340,7 +6503,7 @@ snapshots:
|
||||
|
||||
axios@1.17.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.0(debug@4.4.3)
|
||||
form-data: 4.0.5
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
@ -6448,6 +6611,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
|
||||
@ -6556,7 +6731,7 @@ snapshots:
|
||||
|
||||
centra@2.7.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.0(debug@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
@ -6589,6 +6764,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 +6779,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 +6810,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
cluster-key-slot@1.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
co@4.6.0: {}
|
||||
@ -6982,6 +7165,8 @@ snapshots:
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@5.1.1:
|
||||
@ -7132,7 +7317,9 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
follow-redirects@1.16.0(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
@ -7490,6 +7677,25 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-middleware@3.0.7:
|
||||
dependencies:
|
||||
'@types/http-proxy': 1.17.17
|
||||
debug: 4.4.3
|
||||
http-proxy: 1.18.1(debug@4.4.3)
|
||||
is-glob: 4.0.3
|
||||
is-plain-object: 5.0.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy@1.18.1(debug@4.4.3):
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
follow-redirects: 1.16.0(debug@4.4.3)
|
||||
requires-port: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
@ -7550,6 +7756,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: {}
|
||||
@ -7597,6 +7815,8 @@ snapshots:
|
||||
|
||||
is-plain-obj@4.1.0: {}
|
||||
|
||||
is-plain-object@5.0.0: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-property@1.0.2: {}
|
||||
@ -9070,6 +9290,8 @@ snapshots:
|
||||
|
||||
requireindex@1.2.0: {}
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
resolve-cwd@3.0.0:
|
||||
dependencies:
|
||||
resolve-from: 5.0.0
|
||||
@ -9639,7 +9861,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 +9879,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 +10087,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: {}
|
||||
|
||||
@ -255,6 +255,24 @@ CREATE TABLE IF NOT EXISTS `napcat_risk_mode` (
|
||||
KEY `idx_napcat_risk_mode_mode` (`risk_mode`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `qqbot_napcat_webui_gateway_audit` (
|
||||
`id` bigint NOT NULL,
|
||||
`session_id` varchar(64) NOT NULL,
|
||||
`admin_user_id` bigint NOT NULL,
|
||||
`account_id` bigint NOT NULL,
|
||||
`self_id` varchar(32) NOT NULL,
|
||||
`container_id` bigint NOT NULL,
|
||||
`event_type` varchar(64) NOT NULL,
|
||||
`client_ip` varchar(128) DEFAULT NULL,
|
||||
`user_agent` varchar(512) DEFAULT NULL,
|
||||
`detail_json` json DEFAULT NULL,
|
||||
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_napcat_webui_gateway_audit_session` (`session_id`),
|
||||
KEY `idx_napcat_webui_gateway_audit_account_event` (`account_id`, `event_type`),
|
||||
KEY `idx_napcat_webui_gateway_audit_admin_time` (`admin_user_id`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `qqbot_config` (
|
||||
`id` bigint NOT NULL,
|
||||
`config_key` varchar(120) NOT NULL,
|
||||
@ -1031,12 +1049,14 @@ VALUES
|
||||
(2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0),
|
||||
(2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1),
|
||||
(2041700000000100410, 2041700000000100400, 'QqBotAccountConfig', '/qqbot/account/config', '/qqbot/account/config', NULL, 'QqBot:Account:Config', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"账号功能配置"}', 1, 0),
|
||||
(2041700000000100412, 2041700000000100400, 'QqBotAccountNapcatWebui', '/qqbot/account/:accountId/napcat-webui', '/qqbot/account/napcat-webui/index', NULL, 'QqBot:Account:WebUI', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"NapCat WebUI"}', 1, 0),
|
||||
(2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0),
|
||||
(2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0),
|
||||
(2041700000000120406, 2041700000000100402, 'QqBotAccountConfigButton', NULL, NULL, NULL, 'QqBot:Account:Config', 'button', '{"title":"配置"}', 1, 0),
|
||||
(2041700000000120407, 2041700000000100402, 'QqBotAccountWebUI', NULL, NULL, NULL, 'QqBot:Account:WebUI', 'button', '{"title":"NapCat WebUI"}', 1, 0),
|
||||
(2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2),
|
||||
(2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
|
||||
@ -1125,3 +1125,20 @@ CREATE TABLE IF NOT EXISTS napcat_risk_mode (
|
||||
UNIQUE KEY uk_napcat_risk_mode_account (account_id),
|
||||
KEY idx_napcat_risk_mode_mode (risk_mode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqbot_napcat_webui_gateway_audit (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
admin_user_id BIGINT NOT NULL,
|
||||
account_id BIGINT NOT NULL,
|
||||
self_id VARCHAR(32) NOT NULL,
|
||||
container_id BIGINT NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
client_ip VARCHAR(128) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
detail_json JSON NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_napcat_webui_gateway_audit_session (session_id),
|
||||
KEY idx_napcat_webui_gateway_audit_account_event (account_id, event_type),
|
||||
KEY idx_napcat_webui_gateway_audit_admin_time (admin_user_id, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@ -120,12 +120,14 @@ INSERT INTO admin_menu (
|
||||
(2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0),
|
||||
(2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1),
|
||||
(2041700000000100410, 2041700000000100400, 'QqBotAccountConfig', '/qqbot/account/config', '/qqbot/account/config', NULL, 'QqBot:Account:Config', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"账号功能配置"}', 1, 0),
|
||||
(2041700000000100412, 2041700000000100400, 'QqBotAccountNapcatWebui', '/qqbot/account/:accountId/napcat-webui', '/qqbot/account/napcat-webui/index', NULL, 'QqBot:Account:WebUI', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"NapCat WebUI"}', 1, 0),
|
||||
(2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0),
|
||||
(2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0),
|
||||
(2041700000000120406, 2041700000000100402, 'QqBotAccountConfigButton', NULL, NULL, NULL, 'QqBot:Account:Config', 'button', '{"title":"配置"}', 1, 0),
|
||||
(2041700000000120407, 2041700000000100402, 'QqBotAccountWebUI', NULL, NULL, NULL, 'QqBot:Account:WebUI', 'button', '{"title":"NapCat WebUI"}', 1, 0),
|
||||
(2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2),
|
||||
(2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
|
||||
@ -19,6 +19,11 @@ SELECT 'napcat_risk_mode' AS table_name, COUNT(*) AS row_count FROM napcat_risk_
|
||||
SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task;
|
||||
SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run;
|
||||
|
||||
SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qqbot_napcat_webui_gateway_audit';
|
||||
|
||||
SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM admin_user
|
||||
WHERE username = 'admin'
|
||||
@ -83,6 +88,10 @@ WHERE plugin_key = 'bangdream'
|
||||
AND enabled = 1
|
||||
AND is_deleted = 0;
|
||||
|
||||
SELECT 'seed_qqbot_account_webui_permission' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM admin_menu
|
||||
WHERE auth_code = 'QqBot:Account:WebUI';
|
||||
|
||||
SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
|
||||
@ -0,0 +1,308 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
GoneException,
|
||||
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 normalizedInput = this.validateCreateInput(input);
|
||||
const existing = await this.store.findActiveByUserAndAccount(
|
||||
normalizedInput.adminUserId,
|
||||
normalizedInput.accountId,
|
||||
);
|
||||
if (existing) {
|
||||
await this.updateSession(existing.sessionId, {
|
||||
revokedAt: this.config.now(),
|
||||
status: 'revoked',
|
||||
});
|
||||
}
|
||||
|
||||
const now = this.config.now();
|
||||
const session: NapcatWebuiGatewaySession = {
|
||||
accountId: normalizedInput.accountId,
|
||||
adminUserId: normalizedInput.adminUserId,
|
||||
clientIp: this.toOptionalText(normalizedInput.clientIp),
|
||||
containerId: normalizedInput.containerId,
|
||||
containerName: normalizedInput.containerName,
|
||||
createdAt: now,
|
||||
expiresAt: now + this.config.ttlMs(),
|
||||
selfId: normalizedInput.selfId,
|
||||
sessionId: randomUUID(),
|
||||
status: 'created',
|
||||
upstreamBaseUrl: normalizedInput.upstreamBaseUrl,
|
||||
userAgent: this.toOptionalText(normalizedInput.userAgent),
|
||||
webuiToken: normalizedInput.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.requireBootstrapSession(sessionId);
|
||||
const now = this.config.now();
|
||||
|
||||
return this.updateSession(sessionId, {
|
||||
activeAt: session.activeAt || now,
|
||||
expiresAt: now + this.config.ttlMs(),
|
||||
lastSeenAt: now,
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends an Admin-owned Gateway session that bootstrap already activated.
|
||||
* @param input - Session id plus Admin ownership and request evidence.
|
||||
* @returns Browser-safe lifecycle result.
|
||||
*/
|
||||
async heartbeat(input: NapcatWebuiGatewayLifecycleInput) {
|
||||
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
|
||||
const session = await this.requireProxySession(input.sessionId);
|
||||
this.assertOwner(session, adminUserId);
|
||||
const now = this.config.now();
|
||||
const expiresAt = now + this.config.ttlMs();
|
||||
|
||||
const updated = await this.updateSession(input.sessionId, {
|
||||
clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
|
||||
expiresAt,
|
||||
lastSeenAt: now,
|
||||
status: 'active',
|
||||
userAgent: this.toOptionalText(input.userAgent) || session.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
expiresAt: updated.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 adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
|
||||
const session = await this.requireUsableSession(input.sessionId);
|
||||
this.assertOwner(session, adminUserId);
|
||||
|
||||
const updated = await this.updateSession(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 eligible for one-time bootstrap.
|
||||
* @param sessionId - Gateway session id redeemed from a bootstrap ticket.
|
||||
* @returns Indexed non-terminal session that may be marked active.
|
||||
*/
|
||||
async requireBootstrapSession(sessionId: string) {
|
||||
return this.requireUsableSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session only when it is active and usable for 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) {
|
||||
const session = await this.requireUsableSession(sessionId);
|
||||
if (session.status !== 'active') {
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates a non-terminal, non-expired, currently indexed 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 GoneException('Gateway session is not active');
|
||||
}
|
||||
if (session.expiresAt <= this.config.now()) {
|
||||
await this.updateSession(sessionId, { status: 'expired' });
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
|
||||
const indexed = await this.store.findActiveByUserAndAccount(
|
||||
session.adminUserId,
|
||||
session.accountId,
|
||||
);
|
||||
if (!indexed || indexed.sessionId !== session.sessionId) {
|
||||
throw new GoneException('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 ForbiddenException('Gateway session owner mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a store update and maps expected stale lifecycle rejections to 410.
|
||||
* @param sessionId - Gateway session id to update.
|
||||
* @param patch - Session fields to merge in the store.
|
||||
* @returns Updated session from the backing store.
|
||||
*/
|
||||
private async updateSession(
|
||||
sessionId: string,
|
||||
patch: Partial<NapcatWebuiGatewaySession>,
|
||||
) {
|
||||
try {
|
||||
return await this.store.update(sessionId, patch);
|
||||
} catch (error) {
|
||||
if (this.isInactiveStoreError(error)) {
|
||||
throw new GoneException('Gateway session is not active');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects stale or inactive store errors that should not become HTTP 500.
|
||||
* @param error - Error thrown by the session store.
|
||||
* @returns Whether the error represents an expected inactive session race.
|
||||
*/
|
||||
private isInactiveStoreError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('Gateway session is not active') ||
|
||||
message.includes('Gateway terminal session cannot become active')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a lifecycle Admin actor before owner comparison.
|
||||
* @param adminUserId - Candidate Admin actor id from heartbeat or revoke body.
|
||||
* @returns Trimmed Admin actor id.
|
||||
*/
|
||||
private requireLifecycleAdminUserId(adminUserId: string) {
|
||||
return this.requireText(adminUserId, 'adminUserId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes the internal create-session payload before persistence.
|
||||
* @param input - Internal API payload supplied by the main API process.
|
||||
* @returns Normalized create payload with required fields trimmed.
|
||||
*/
|
||||
private validateCreateInput(input: NapcatWebuiGatewayCreateSessionInput) {
|
||||
const normalized = {
|
||||
...input,
|
||||
accountId: this.requireText(input.accountId, 'accountId'),
|
||||
adminUserId: this.requireText(input.adminUserId, 'adminUserId'),
|
||||
containerId: this.requireText(input.containerId, 'containerId'),
|
||||
containerName: this.requireText(input.containerName, 'containerName'),
|
||||
selfId: this.requireText(input.selfId, 'selfId'),
|
||||
upstreamBaseUrl: this.requireUpstreamBaseUrl(input.upstreamBaseUrl),
|
||||
webuiToken: this.requireText(input.webuiToken, 'webuiToken'),
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a non-empty text field from the internal create-session payload.
|
||||
* @param value - Candidate field value.
|
||||
* @param fieldName - Payload field name used in the error message.
|
||||
* @returns Trimmed field text.
|
||||
*/
|
||||
private requireText(value: string, fieldName: string) {
|
||||
const text = this.toOptionalText(value);
|
||||
if (!text) {
|
||||
throw new BadRequestException(
|
||||
`Gateway session field ${fieldName} is required`,
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the upstream WebUI base URL without restricting Docker host shape.
|
||||
* @param value - Candidate upstream URL.
|
||||
* @returns Trimmed http or https URL.
|
||||
*/
|
||||
private requireUpstreamBaseUrl(value: string) {
|
||||
const text = this.requireText(value, 'upstreamBaseUrl');
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('Unsupported protocol');
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
throw new BadRequestException('Gateway session upstream URL is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,127 @@
|
||||
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 DEFAULT_UPSTREAM_TIMEOUT_MS = 5000;
|
||||
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 bounded timeout for NapCat WebUI upstream HTTP calls.
|
||||
* @returns Positive timeout in milliseconds, defaulting to 5 seconds.
|
||||
*/
|
||||
upstreamTimeoutMs() {
|
||||
return this.getPositiveNumber(
|
||||
'NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS',
|
||||
DEFAULT_UPSTREAM_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import axios from 'axios';
|
||||
import { BadGatewayException, Injectable } from '@nestjs/common';
|
||||
import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../domain/napcat-webui-gateway.types';
|
||||
|
||||
type NapcatCredentialBody = {
|
||||
Credential?: string;
|
||||
};
|
||||
|
||||
type NapcatCredentialResponse =
|
||||
| NapcatCredentialBody
|
||||
| {
|
||||
data?: NapcatCredentialBody;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NapcatWebuiCredentialClient {
|
||||
private readonly credentials = new Map<
|
||||
string,
|
||||
{ credential: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Creates the standalone Gateway credential client.
|
||||
* @param config - Gateway config used for bounded upstream requests and time.
|
||||
*/
|
||||
constructor(private readonly config: NapcatWebuiGatewayConfigService) {}
|
||||
|
||||
/**
|
||||
* Returns a cached or freshly exchanged NapCat WebUI credential for one Gateway session.
|
||||
* @param session - Server-only Gateway session metadata containing token and upstream URL.
|
||||
* @returns NapCat WebUI Credential string for upstream Authorization.
|
||||
*/
|
||||
async getCredential(session: NapcatWebuiGatewaySession) {
|
||||
const cached = this.credentials.get(session.sessionId);
|
||||
const now = this.config.now();
|
||||
if (
|
||||
cached &&
|
||||
now < cached.expiresAt &&
|
||||
cached.expiresAt <= session.expiresAt
|
||||
) {
|
||||
return cached.credential;
|
||||
}
|
||||
|
||||
const credential = await this.exchangeCredential(session);
|
||||
this.credentials.set(session.sessionId, {
|
||||
credential,
|
||||
expiresAt: session.expiresAt,
|
||||
});
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a cached credential when a Gateway session is revoked.
|
||||
* @param sessionId - Gateway session id whose cached credential should be removed.
|
||||
*/
|
||||
clear(sessionId: string) {
|
||||
this.credentials.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the server-only WebUI token for a NapCat Credential.
|
||||
* @param session - Gateway session containing upstream base URL and WebUI token.
|
||||
* @returns Credential returned by NapCat WebUI.
|
||||
*/
|
||||
private async exchangeCredential(session: NapcatWebuiGatewaySession) {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${session.webuiToken}.napcat`)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
const response = await axios.post<NapcatCredentialResponse>(
|
||||
new URL('/api/auth/login', session.upstreamBaseUrl).toString(),
|
||||
{ hash },
|
||||
{
|
||||
timeout: this.config.upstreamTimeoutMs(),
|
||||
},
|
||||
);
|
||||
const credential = this.extractCredential(response.data);
|
||||
if (!credential) {
|
||||
throw new Error('Missing Credential');
|
||||
}
|
||||
return credential;
|
||||
} catch {
|
||||
throw new BadGatewayException('NapCat WebUI credential exchange failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Credential from either the raw NapCat payload or its data wrapper.
|
||||
* @param body - Axios response body from `/api/auth/login`.
|
||||
* @returns Credential when present.
|
||||
*/
|
||||
private extractCredential(body: NapcatCredentialResponse) {
|
||||
if ('data' in body) {
|
||||
return body.data?.Credential;
|
||||
}
|
||||
return (body as NapcatCredentialBody).Credential;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,349 @@
|
||||
import type { IncomingMessage, Server } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import {
|
||||
createProxyMiddleware,
|
||||
fixRequestBody,
|
||||
type RequestHandler,
|
||||
} from 'http-proxy-middleware';
|
||||
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
|
||||
|
||||
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
|
||||
const STRIPPED_UPSTREAM_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'x-admin-token',
|
||||
'x-api-token',
|
||||
'x-access-token',
|
||||
'x-kt-access-token',
|
||||
'x-kt-gateway-secret',
|
||||
'x-wordpress-cookie',
|
||||
] as const;
|
||||
|
||||
type ProxyPathInput = string | string[] | undefined;
|
||||
|
||||
type RewriteLocationInput = {
|
||||
location: string;
|
||||
sessionId: string;
|
||||
upstreamBaseUrl: string;
|
||||
};
|
||||
|
||||
type CookiePathRewriteInput = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a Gateway route tail into a safe upstream pathname.
|
||||
* @param input - Route parameter from Nest/path-to-regexp.
|
||||
* @returns Absolute upstream pathname beginning with `/`.
|
||||
*/
|
||||
export function sanitizeGatewayProxyPath(input: ProxyPathInput) {
|
||||
const raw = Array.isArray(input) ? input.join('/') : String(input || '');
|
||||
const trimmed = raw.trim();
|
||||
const decoded = decodeProxyPath(trimmed);
|
||||
|
||||
if (
|
||||
!decoded ||
|
||||
decoded.includes('\\') ||
|
||||
decoded.startsWith('//') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(decoded)
|
||||
) {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
const path = decoded.startsWith('/') ? decoded : `/${decoded}`;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.some((segment) => segment === '..')) {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites NapCat redirects so browsers stay under the Gateway session route.
|
||||
* @param input - Upstream Location header plus Gateway session context.
|
||||
* @returns Rewritten safe Location header.
|
||||
*/
|
||||
export function rewriteNapcatLocationHeader(input: RewriteLocationInput) {
|
||||
const gatewayPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(
|
||||
input.sessionId,
|
||||
)}/webui`;
|
||||
const location = input.location.trim();
|
||||
if (!location) return location;
|
||||
|
||||
const fallback = `${gatewayPrefix}/webui`;
|
||||
if (location.startsWith('//')) {
|
||||
try {
|
||||
const upstream = new URL(input.upstreamBaseUrl);
|
||||
const target = new URL(`${upstream.protocol}${location}`);
|
||||
return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) {
|
||||
try {
|
||||
const target = new URL(location, 'http://gateway.local');
|
||||
return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const target = new URL(location);
|
||||
if (target.protocol === 'http:' || target.protocol === 'https:') {
|
||||
return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
|
||||
}
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds HPM cookie path rewrite config for Gateway-scoped upstream cookies.
|
||||
* @param input - Gateway session id used in the public route prefix.
|
||||
* @returns HPM cookiePathRewrite object.
|
||||
*/
|
||||
export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) {
|
||||
return {
|
||||
'*': `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(input.sessionId)}/webui`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes path text until stable so nested encoded traversal cannot pass through.
|
||||
* @param value - Raw route path text.
|
||||
* @returns Decoded path text.
|
||||
*/
|
||||
function decodeProxyPath(value: string) {
|
||||
try {
|
||||
let decoded = value;
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
return next;
|
||||
}
|
||||
decoded = next;
|
||||
}
|
||||
} catch {
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
throw new BadRequestException('Gateway proxy path is invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a browser redirect that keeps only the upstream pathname.
|
||||
* @param gatewayPrefix - Public Gateway session route prefix for one Admin session.
|
||||
* @param upstreamPathname - Upstream redirect pathname after URL parsing removed search/hash.
|
||||
* @returns Gateway-scoped Location header, falling back to WebUI root for unsafe paths.
|
||||
*/
|
||||
function toGatewayRedirectLocation(
|
||||
gatewayPrefix: string,
|
||||
upstreamPathname: string,
|
||||
) {
|
||||
try {
|
||||
return `${gatewayPrefix}${sanitizeGatewayProxyPath(
|
||||
upstreamPathname || '/webui',
|
||||
)}`;
|
||||
} catch {
|
||||
return `${gatewayPrefix}/webui`;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NapcatWebuiProxyService {
|
||||
/**
|
||||
* Creates the Gateway proxy service.
|
||||
* @param sessionService - Session lifecycle guard for bootstrap/proxy eligibility.
|
||||
* @param credentialClient - NapCat WebUI credential exchange/cache client.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly credentialClient: NapcatWebuiCredentialClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Proxies one HTTP request to the active session's NapCat WebUI.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param proxyPath - Route tail mapped to the upstream pathname.
|
||||
* @param req - Express request delegated from the public controller.
|
||||
* @param res - Express response owned by HPM after delegation.
|
||||
* @param next - Express next callback used by HPM.
|
||||
*/
|
||||
async handleHttpProxy(
|
||||
sessionId: string,
|
||||
proxyPath: ProxyPathInput,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const session = await this.sessionService.requireProxySession(sessionId);
|
||||
const upstreamPath = this.buildUpstreamPath(proxyPath, req.originalUrl);
|
||||
const credential = await this.credentialClient.getCredential(session);
|
||||
this.stripBrowserHeaders(req);
|
||||
req.url = upstreamPath;
|
||||
|
||||
const proxy = this.createProxy(session, credential);
|
||||
return proxy(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes the Gateway HTTP server to NapCat WebUI WebSocket upgrades.
|
||||
* @param server - HTTP server returned by the Nest application.
|
||||
*/
|
||||
bindWebSocketUpgrade(server: Server) {
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
void this.handleWebSocketUpgrade(req, socket as Socket, head);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one matching WebSocket upgrade and ignores unrelated upgrade URLs.
|
||||
* @param req - Raw Node upgrade request.
|
||||
* @param socket - TCP socket for the upgrade.
|
||||
* @param head - First packet of the upgraded stream.
|
||||
*/
|
||||
private async handleWebSocketUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Socket,
|
||||
head: Buffer,
|
||||
) {
|
||||
try {
|
||||
const match = this.matchGatewayUpgrade(req.url || '');
|
||||
if (!match) return;
|
||||
const session = await this.sessionService.requireProxySession(
|
||||
match.sessionId,
|
||||
);
|
||||
const credential = await this.credentialClient.getCredential(session);
|
||||
this.stripBrowserHeaders(req);
|
||||
req.url = `${match.proxyPath}${match.search}`;
|
||||
const proxy = this.createProxy(session, credential);
|
||||
proxy.upgrade(req, socket, head);
|
||||
} catch {
|
||||
this.rejectUpgrade(socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one HPM proxy bound to a validated session and server-side credential.
|
||||
* @param session - Active Gateway session metadata.
|
||||
* @param credential - NapCat WebUI Credential for upstream Authorization.
|
||||
* @returns HPM request handler with HTTP and WebSocket support.
|
||||
*/
|
||||
private createProxy(
|
||||
session: NapcatWebuiGatewaySession,
|
||||
credential: string,
|
||||
): RequestHandler<Request, Response, NextFunction> {
|
||||
return createProxyMiddleware<Request, Response, NextFunction>({
|
||||
changeOrigin: true,
|
||||
cookiePathRewrite: buildGatewayCookiePathRewrite({
|
||||
sessionId: session.sessionId,
|
||||
}),
|
||||
on: {
|
||||
error: (_error, _req, res) => {
|
||||
this.writeProxyError(res);
|
||||
},
|
||||
proxyReq: (proxyReq, req) => {
|
||||
proxyReq.removeHeader('cookie');
|
||||
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
||||
fixRequestBody(proxyReq, req);
|
||||
},
|
||||
proxyReqWs: (proxyReq) => {
|
||||
proxyReq.removeHeader('cookie');
|
||||
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
const location = proxyRes.headers.location;
|
||||
if (typeof location === 'string') {
|
||||
proxyRes.headers.location = rewriteNapcatLocationHeader({
|
||||
location,
|
||||
sessionId: session.sessionId,
|
||||
upstreamBaseUrl: session.upstreamBaseUrl,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
secure: false,
|
||||
target: session.upstreamBaseUrl,
|
||||
ws: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the upstream URL path while preserving the original query string.
|
||||
* @param proxyPath - Gateway route tail to sanitize.
|
||||
* @param originalUrl - Original Express URL containing the query string.
|
||||
* @returns Upstream path plus query string.
|
||||
*/
|
||||
private buildUpstreamPath(proxyPath: ProxyPathInput, originalUrl?: string) {
|
||||
const pathname = sanitizeGatewayProxyPath(proxyPath);
|
||||
const queryIndex = String(originalUrl || '').indexOf('?');
|
||||
const query = queryIndex >= 0 ? String(originalUrl).slice(queryIndex) : '';
|
||||
return `${pathname}${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a public WebSocket upgrade URL into Gateway session and upstream path.
|
||||
* @param rawUrl - Raw URL from the Node upgrade request.
|
||||
* @returns Parsed session id, sanitized upstream path, and query string when matched.
|
||||
*/
|
||||
private matchGatewayUpgrade(rawUrl: string) {
|
||||
const url = new URL(rawUrl, 'http://gateway.local');
|
||||
const match = url.pathname.match(
|
||||
/^\/napcat-webui\/session\/([^/]+)\/webui(?:\/(.*))?$/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
|
||||
return {
|
||||
proxyPath: sanitizeGatewayProxyPath(match[2] || ''),
|
||||
search: url.search,
|
||||
sessionId: decodeURIComponent(match[1]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes browser-provided auth/session headers before HPM builds upstream requests.
|
||||
* @param req - Express or Node request whose headers are being proxied.
|
||||
*/
|
||||
private stripBrowserHeaders(req: IncomingMessage) {
|
||||
STRIPPED_UPSTREAM_HEADERS.forEach((header) => {
|
||||
delete req.headers[header];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a generic HTTP proxy failure without exposing upstream target data.
|
||||
* @param res - HTTP response object passed by HPM.
|
||||
*/
|
||||
private writeProxyError(res: Response | Socket) {
|
||||
if ('headersSent' in res) {
|
||||
if (res.headersSent) return;
|
||||
res.status(HttpStatus.BAD_GATEWAY).json({
|
||||
message: 'NapCat WebUI proxy failed',
|
||||
statusCode: HttpStatus.BAD_GATEWAY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.rejectUpgrade(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a compact HTTP error for failed WebSocket upgrade validation.
|
||||
* @param socket - Upgrade socket to close after the error response.
|
||||
*/
|
||||
private rejectUpgrade(socket: Socket) {
|
||||
if (socket.writable) {
|
||||
socket.write(
|
||||
'HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
|
||||
);
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
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'];
|
||||
const UPDATE_SESSION_SCRIPT = `
|
||||
local currentJson = redis.call("GET", KEYS[1])
|
||||
if not currentJson then
|
||||
return {0, "Gateway session is not active"}
|
||||
end
|
||||
|
||||
local current = cjson.decode(currentJson)
|
||||
local patch = cjson.decode(ARGV[2])
|
||||
local terminal = { expired = true, failed = true, revoked = true }
|
||||
local next = {}
|
||||
|
||||
for key, value in pairs(current) do
|
||||
next[key] = value
|
||||
end
|
||||
for key, value in pairs(patch) do
|
||||
next[key] = value
|
||||
end
|
||||
|
||||
next["sessionId"] = ARGV[1]
|
||||
next["adminUserId"] = current["adminUserId"]
|
||||
next["accountId"] = current["accountId"]
|
||||
|
||||
if current["expiresAt"] and patch["expiresAt"] and tonumber(current["expiresAt"]) > tonumber(patch["expiresAt"]) then
|
||||
next["expiresAt"] = current["expiresAt"]
|
||||
end
|
||||
if current["lastSeenAt"] and patch["lastSeenAt"] and tonumber(current["lastSeenAt"]) > tonumber(patch["lastSeenAt"]) then
|
||||
next["lastSeenAt"] = current["lastSeenAt"]
|
||||
end
|
||||
if current["activeAt"] then
|
||||
next["activeAt"] = current["activeAt"]
|
||||
end
|
||||
if current["revokedAt"] then
|
||||
next["revokedAt"] = current["revokedAt"]
|
||||
end
|
||||
|
||||
local indexKey = ARGV[3] .. current["adminUserId"] .. ":" .. current["accountId"]
|
||||
local indexValue = redis.call("GET", indexKey)
|
||||
local now = tonumber(ARGV[4])
|
||||
local ttl = math.max(1, tonumber(next["expiresAt"]) - now)
|
||||
local nextJson = cjson.encode(next)
|
||||
|
||||
if terminal[current["status"]] and not terminal[next["status"]] then
|
||||
return {0, "Gateway session is not active"}
|
||||
end
|
||||
|
||||
if terminal[next["status"]] then
|
||||
redis.call("PSETEX", KEYS[1], ttl, nextJson)
|
||||
if indexValue == ARGV[1] then
|
||||
redis.call("DEL", indexKey)
|
||||
end
|
||||
return {1, nextJson}
|
||||
end
|
||||
|
||||
if indexValue ~= ARGV[1] then
|
||||
return {0, "Gateway session is not active"}
|
||||
end
|
||||
|
||||
redis.call("PSETEX", KEYS[1], ttl, nextJson)
|
||||
redis.call("SET", indexKey, ARGV[1], "PX", ttl)
|
||||
return {1, nextJson}
|
||||
`;
|
||||
|
||||
@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>,
|
||||
) {
|
||||
return this.mergeSessionPatchAtomically(sessionId, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically merges a patch into current Redis JSON and maintains the index.
|
||||
* @param sessionId - Gateway session id to update.
|
||||
* @param patch - Partial session fields to merge inside Redis.
|
||||
* @returns Final merged session returned by the Lua script.
|
||||
*/
|
||||
private async mergeSessionPatchAtomically(
|
||||
sessionId: string,
|
||||
patch: Partial<NapcatWebuiGatewaySession>,
|
||||
) {
|
||||
const result = (await this.redis.eval(
|
||||
UPDATE_SESSION_SCRIPT,
|
||||
1,
|
||||
this.sessionKey(sessionId),
|
||||
sessionId,
|
||||
JSON.stringify(patch),
|
||||
USER_ACCOUNT_KEY_PREFIX,
|
||||
this.config.now(),
|
||||
)) as [number, string];
|
||||
if (!Array.isArray(result) || Number(result[0]) !== 1) {
|
||||
throw new Error(String(result?.[1] || 'Gateway session is not active'));
|
||||
}
|
||||
|
||||
return JSON.parse(result[1]) as NapcatWebuiGatewaySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,64 @@
|
||||
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 with Redis GETDEL so deletion happens before returning.
|
||||
* @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.getdel(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}`;
|
||||
}
|
||||
}
|
||||
23
src/apps/napcat-webui-gateway/main.ts
Normal file
23
src/apps/napcat-webui-gateway/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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 { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module';
|
||||
|
||||
/**
|
||||
* Starts the standalone NapCat WebUI Gateway process.
|
||||
*/
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(NapcatWebuiGatewayModule, {
|
||||
bodyParser: false,
|
||||
bufferLogs: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.use('/internal', json({ limit: '64kb' }));
|
||||
app.use('/internal', urlencoded({ extended: true, limit: '64kb' }));
|
||||
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
||||
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer());
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
64
src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts
Normal file
64
src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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 { NapcatWebuiCredentialClient } from './infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
|
||||
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';
|
||||
import { PublicWebuiController } from './presentation/public-webui.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, PublicWebuiController],
|
||||
providers: [
|
||||
NapcatWebuiGatewayConfigService,
|
||||
NapcatWebuiCredentialClient,
|
||||
NapcatWebuiProxyService,
|
||||
NapcatWebuiGatewaySessionService,
|
||||
NapcatWebuiGatewayRedisStore,
|
||||
NapcatWebuiGatewayTicketService,
|
||||
{
|
||||
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
||||
useExisting: NapcatWebuiGatewayRedisStore,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class NapcatWebuiGatewayModule {}
|
||||
@ -0,0 +1,133 @@
|
||||
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 { NapcatWebuiCredentialClient } from '../infrastructure/napcat-webui-credential.client';
|
||||
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 credentialClient - Server-side credential cache cleared on revoke.
|
||||
* @param config - Gateway config used for secret validation and public URL prefix.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
||||
private readonly credentialClient: NapcatWebuiCredentialClient,
|
||||
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, relative iframe URL, and display metadata.
|
||||
*/
|
||||
@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 {
|
||||
account: {
|
||||
accountId: session.accountId,
|
||||
selfId: session.selfId,
|
||||
},
|
||||
container: {
|
||||
containerName: session.containerName,
|
||||
},
|
||||
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')
|
||||
async revoke(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Headers('x-kt-gateway-secret') secret: string,
|
||||
@Body() body: LifecycleBody,
|
||||
) {
|
||||
this.requireInternalSecret(secret);
|
||||
const result = await this.sessionService.revoke({
|
||||
...body,
|
||||
sessionId,
|
||||
});
|
||||
this.credentialClient.clear(sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Get,
|
||||
GoneException,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { NapcatWebuiGatewaySessionService } from '../application/napcat-webui-gateway-session.service';
|
||||
import { NapcatWebuiProxyService } from '../infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
|
||||
@Controller('napcat-webui')
|
||||
export class PublicWebuiController {
|
||||
/**
|
||||
* Creates the browser-facing NapCat WebUI controller.
|
||||
* @param sessionService - Gateway session lifecycle service.
|
||||
* @param ticketService - One-time bootstrap ticket service.
|
||||
* @param proxyService - HTTP/WebSocket proxy delegation service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
||||
private readonly proxyService: NapcatWebuiProxyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Redeems a one-time bootstrap ticket, activates the session, and enters WebUI.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param ticket - One-time ticket issued by the internal create-session endpoint.
|
||||
* @param res - Express response used to set the scoped cookie and redirect.
|
||||
*/
|
||||
@Get('session/:sessionId/bootstrap')
|
||||
async bootstrap(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Query('ticket') ticket: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const redeemedSessionId = await this.ticketService.redeem(
|
||||
this.requireTicket(ticket),
|
||||
);
|
||||
if (redeemedSessionId !== sessionId) {
|
||||
throw new GoneException('Gateway bootstrap ticket is not active');
|
||||
}
|
||||
|
||||
await this.sessionService.requireBootstrapSession(sessionId);
|
||||
await this.sessionService.markActive(sessionId);
|
||||
res.cookie('kt_napcat_webui_gateway', 'active', {
|
||||
httpOnly: true,
|
||||
path: `/napcat-webui/session/${encodeURIComponent(sessionId)}`,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.redirect(
|
||||
HttpStatus.FOUND,
|
||||
`/napcat-webui/session/${encodeURIComponent(sessionId)}/webui/webui`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates Gateway-owned WebUI HTTP routes to the proxy service.
|
||||
* @param sessionId - Gateway session id from the public route.
|
||||
* @param proxyPath - Path-to-regexp route tail for the upstream pathname.
|
||||
* @param req - Express request passed through to HPM.
|
||||
* @param res - Express response passed through to HPM.
|
||||
* @param next - Express next callback passed through to HPM.
|
||||
*/
|
||||
@All('session/:sessionId/webui/*proxyPath')
|
||||
proxy(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Param('proxyPath') proxyPath: string | string[] | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
return this.proxyService.handleHttpProxy(
|
||||
sessionId,
|
||||
proxyPath,
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a non-empty bootstrap ticket before touching the ticket store.
|
||||
* @param ticket - Candidate query ticket.
|
||||
* @returns Trimmed ticket value.
|
||||
*/
|
||||
private requireTicket(ticket: string) {
|
||||
const value = String(ticket || '').trim();
|
||||
if (!value) {
|
||||
throw new GoneException('Gateway bootstrap ticket is not active');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -1205,6 +1205,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
return container ? this.toRuntime(container) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the primary bound NapCat container runtime for an account.
|
||||
* @param accountId - QQBot account id whose primary NapCat binding should be resolved.
|
||||
* @returns Primary runtime with WebUI token selected, or null when no active binding exists.
|
||||
*/
|
||||
async findPrimaryContainerByAccountId(accountId: string) {
|
||||
return this.getPrimaryRuntime(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 NapCat 登录运行态数据。
|
||||
* @param containerId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。
|
||||
|
||||
@ -9,6 +9,7 @@ import { NapcatRiskMode } from './napcat-risk-mode.entity';
|
||||
import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity';
|
||||
import { NapcatRuntimeProfile } from './napcat-runtime-profile.entity';
|
||||
import { NapcatSessionBehaviorProfile } from './napcat-session-behavior-profile.entity';
|
||||
import { NapcatWebuiGatewayAudit } from '../../webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity';
|
||||
|
||||
export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
|
||||
tables: [
|
||||
@ -23,6 +24,7 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
|
||||
'napcat_session_behavior_profile',
|
||||
'napcat_login_event',
|
||||
'napcat_risk_mode',
|
||||
'qqbot_napcat_webui_gateway_audit',
|
||||
],
|
||||
} as const;
|
||||
|
||||
@ -38,6 +40,7 @@ export const NAPCAT_RUNTIME_ENTITIES = [
|
||||
NapcatSessionBehaviorProfile,
|
||||
NapcatLoginEvent,
|
||||
NapcatRiskMode,
|
||||
NapcatWebuiGatewayAudit,
|
||||
];
|
||||
|
||||
export {
|
||||
@ -46,4 +49,5 @@ export {
|
||||
NapcatRiskMode,
|
||||
NapcatRuntimeProfile,
|
||||
NapcatSessionBehaviorProfile,
|
||||
NapcatWebuiGatewayAudit,
|
||||
};
|
||||
|
||||
@ -13,6 +13,12 @@ import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtim
|
||||
import { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service';
|
||||
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
||||
import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller';
|
||||
import {
|
||||
NapcatWebuiGatewayAuditService,
|
||||
QqbotNapcatWebuiGatewayService,
|
||||
} from './webui-gateway/application/qqbot-napcat-webui-gateway.service';
|
||||
import { QqbotNapcatWebuiGatewayController } from './webui-gateway/contract/qqbot-napcat-webui-gateway.controller';
|
||||
import { QqbotNapcatWebuiGatewayClient } from './webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client';
|
||||
import { NapcatRuntimeProfileInspectionScriptService } from './infrastructure/integration/container/napcat-runtime-profile-inspection-script.service';
|
||||
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
|
||||
import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service';
|
||||
@ -24,6 +30,7 @@ export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES];
|
||||
export const QQBOT_NAPCAT_CONTROLLERS = [
|
||||
QqbotNapcatLoginController,
|
||||
QqbotNapcatRuntimeController,
|
||||
QqbotNapcatWebuiGatewayController,
|
||||
];
|
||||
|
||||
export const QQBOT_NAPCAT_PROVIDERS = [
|
||||
@ -34,8 +41,11 @@ export const QQBOT_NAPCAT_PROVIDERS = [
|
||||
NapcatRuntimeProfileInspectionScriptService,
|
||||
NapcatRuntimeProfileService,
|
||||
NapcatSessionBehaviorService,
|
||||
NapcatWebuiGatewayAuditService,
|
||||
QqbotNapcatAccountRuntimeService,
|
||||
QqbotNapcatContainerService,
|
||||
QqbotNapcatWebuiGatewayClient,
|
||||
QqbotNapcatWebuiGatewayService,
|
||||
QqbotNapcatLoginService,
|
||||
QqbotNapcatWatchdogService,
|
||||
{
|
||||
|
||||
@ -0,0 +1,350 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { throwVbenError } from '@/common';
|
||||
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
|
||||
import type {
|
||||
QqbotNapcatRuntime,
|
||||
QqbotNapcatWebuiStatus,
|
||||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
||||
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
|
||||
import { NapcatWebuiGatewayAudit } from '../infrastructure/persistence/napcat-webui-gateway-audit.entity';
|
||||
import {
|
||||
QqbotNapcatWebuiGatewayClient,
|
||||
type QqbotNapcatWebuiGatewayLifecycleResult,
|
||||
} from '../infrastructure/qqbot-napcat-webui-gateway.client';
|
||||
import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napcat-webui-gateway.dto';
|
||||
|
||||
const SENSITIVE_DETAIL_KEY_PATTERN =
|
||||
/^(baseurl|captcha|captchaticket|credential|credentialheader|dockerip|headers|hostport|internalsecret|naspath|nasroute|password|qrpayload|qrcode|rawheaders|secret|targetbaseurl|ticket|token|upstreambaseurl|upstreamurl|webuiport|webuitoken)$/i;
|
||||
const SENSITIVE_DETAIL_KEY_FAMILIES = [
|
||||
'authorization',
|
||||
'captcha',
|
||||
'cookie',
|
||||
'credential',
|
||||
'password',
|
||||
'secret',
|
||||
'ticket',
|
||||
'token',
|
||||
];
|
||||
const SENSITIVE_DETAIL_KEY_SUBSTRINGS = [
|
||||
'dockerip',
|
||||
'hostport',
|
||||
'naspath',
|
||||
'nasroute',
|
||||
'targetbaseurl',
|
||||
'upstreambaseurl',
|
||||
'upstreamurl',
|
||||
'webuiport',
|
||||
'webuitoken',
|
||||
];
|
||||
const UNSAFE_DETAIL_STRING_PATTERN =
|
||||
/(\bBearer\s+\S+|\bCredential\b|(?:^|[?&\s])(token|ticket|secret|password|credential|captcha)=|webui[_-]?token|https?:\/\/(?:127\.0\.0\.1|localhost|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|[^/\s]*:\d+)|\/internal\/sessions\b|\bnas(?:route|path)?\b|\/vol\d\b|\bdocker[_-]?ip\b)/i;
|
||||
const REDACTED_DETAIL_VALUE = '[REDACTED]';
|
||||
const ACCOUNT_ID_PATTERN = /^[1-9]\d{0,31}$/;
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
export type QqbotNapcatWebuiGatewaySessionCreateInput = {
|
||||
accountId: string;
|
||||
adminUserId: string;
|
||||
clientIp?: string | null;
|
||||
userAgent?: string | null;
|
||||
};
|
||||
|
||||
export type QqbotNapcatWebuiGatewaySessionLifecycleInput = {
|
||||
adminUserId: string;
|
||||
clientIp?: string | null;
|
||||
sessionId: string;
|
||||
userAgent?: string | null;
|
||||
};
|
||||
|
||||
export type NapcatWebuiGatewayAuditRecordInput = {
|
||||
accountId: string;
|
||||
adminUserId: string;
|
||||
clientIp?: string | null;
|
||||
containerId: string;
|
||||
detailJson?: null | Record<string, unknown>;
|
||||
eventType: string;
|
||||
selfId: string;
|
||||
sessionId: string;
|
||||
userAgent?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NapcatWebuiGatewayAuditService {
|
||||
/**
|
||||
* Creates the audit recorder for browser Gateway session lifecycle evidence.
|
||||
* @param auditRepository - TypeORM repository for sanitized WebUI Gateway audit rows.
|
||||
*/
|
||||
constructor(
|
||||
@InjectRepository(NapcatWebuiGatewayAudit)
|
||||
private readonly auditRepository: Repository<NapcatWebuiGatewayAudit>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persists one sanitized Gateway audit event.
|
||||
* @param input - Event identity, actor, client evidence, and already-safe detail fields.
|
||||
* @returns Saved audit entity.
|
||||
*/
|
||||
async record(input: NapcatWebuiGatewayAuditRecordInput) {
|
||||
const entity = this.auditRepository.create({
|
||||
accountId: input.accountId,
|
||||
adminUserId: input.adminUserId,
|
||||
clientIp: this.toNullableText(input.clientIp, 128),
|
||||
containerId: input.containerId,
|
||||
detailJson: this.sanitizeDetail(input.detailJson),
|
||||
eventType: input.eventType,
|
||||
selfId: input.selfId,
|
||||
sessionId: input.sessionId,
|
||||
userAgent: this.toNullableText(input.userAgent, 512),
|
||||
});
|
||||
|
||||
return this.auditRepository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes known secret-bearing keys from nested audit detail objects.
|
||||
* @param detail - Candidate detail payload supplied by the application service.
|
||||
* @returns Sanitized detail object or null when no detail is supplied.
|
||||
*/
|
||||
private sanitizeDetail(detail?: null | Record<string, unknown>) {
|
||||
if (!detail) return null;
|
||||
return this.sanitizeValue(detail) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitizes arrays and objects without preserving sensitive keys.
|
||||
* @param value - Arbitrary audit detail value.
|
||||
* @returns Value safe to serialize into the audit table.
|
||||
*/
|
||||
private sanitizeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.sanitizeValue(item));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return this.isUnsafeDetailString(value) ? REDACTED_DETAIL_VALUE : value;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !this.isSensitiveDetailKey(key))
|
||||
.map(([key, item]) => [key, this.sanitizeValue(item)]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes key style variants before checking whether a field can carry secrets.
|
||||
* @param key - Raw object key from audit detail.
|
||||
* @returns Whether the key should be dropped from persisted audit JSON.
|
||||
*/
|
||||
private isSensitiveDetailKey(key: string) {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return (
|
||||
SENSITIVE_DETAIL_KEY_PATTERN.test(normalized) ||
|
||||
SENSITIVE_DETAIL_KEY_FAMILIES.some((family) =>
|
||||
normalized.includes(family),
|
||||
) ||
|
||||
SENSITIVE_DETAIL_KEY_SUBSTRINGS.some((substring) =>
|
||||
normalized.includes(substring),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects secret-bearing string values that should never be persisted in audit detail.
|
||||
* @param value - Raw string value from audit detail.
|
||||
* @returns Whether the value should be replaced with a redaction marker.
|
||||
*/
|
||||
private isUnsafeDetailString(value: string) {
|
||||
return UNSAFE_DETAIL_STRING_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts optional client evidence into a bounded nullable column value.
|
||||
* @param value - Raw IP or user-agent value from the request.
|
||||
* @param limit - Database column length limit.
|
||||
* @returns Trimmed string or null for empty input.
|
||||
*/
|
||||
private toNullableText(value: null | string | undefined, limit: number) {
|
||||
const text = String(value || '').trim();
|
||||
return text ? text.slice(0, limit) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QqbotNapcatWebuiGatewayService {
|
||||
/**
|
||||
* Creates the Admin-facing WebUI Gateway application service.
|
||||
* @param accountService - QQBot account reader used to validate and serialize account identity.
|
||||
* @param containerService - NapCat runtime resolver that supplies server-only WebUI target data.
|
||||
* @param gatewayClient - Internal Gateway client used for session lifecycle requests.
|
||||
* @param auditService - Sanitized audit recorder for Admin session events.
|
||||
*/
|
||||
constructor(
|
||||
private readonly accountService: QqbotAccountService,
|
||||
private readonly containerService: QqbotNapcatContainerService,
|
||||
private readonly gatewayClient: QqbotNapcatWebuiGatewayClient,
|
||||
private readonly auditService: NapcatWebuiGatewayAuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a browser-safe WebUI Gateway session for one QQBot account.
|
||||
* @param input - Admin actor, client evidence, and target QQBot account id.
|
||||
* @returns Browser-safe session response without WebUI token, upstream URL, or port.
|
||||
*/
|
||||
async createSession(
|
||||
input: QqbotNapcatWebuiGatewaySessionCreateInput,
|
||||
): Promise<QqbotNapcatWebuiSessionResponseDto> {
|
||||
const accountId = this.requireAccountId(input.accountId);
|
||||
const account = await this.accountService.findById(accountId);
|
||||
if (!account) {
|
||||
throwVbenError('QQBot 账号不存在');
|
||||
}
|
||||
|
||||
const runtime = await this.containerService.findPrimaryContainerByAccountId(
|
||||
account.id,
|
||||
);
|
||||
if (!runtime?.id) {
|
||||
throwVbenError('账号未绑定 NapCat 容器');
|
||||
}
|
||||
if (runtime.sourceContainerOnline !== true) {
|
||||
throwVbenError('NapCat WebUI 不在线');
|
||||
}
|
||||
|
||||
const target = this.toGatewayTarget(runtime);
|
||||
const gatewaySession = await this.gatewayClient.createSession({
|
||||
accountId: account.id,
|
||||
adminUserId: input.adminUserId,
|
||||
clientIp: input.clientIp || undefined,
|
||||
containerId: runtime.id,
|
||||
containerName: runtime.name,
|
||||
selfId: account.selfId,
|
||||
userAgent: input.userAgent || undefined,
|
||||
...target,
|
||||
});
|
||||
const webuiStatus = this.toWebuiStatus(runtime);
|
||||
|
||||
await this.auditService.record({
|
||||
accountId: account.id,
|
||||
adminUserId: input.adminUserId,
|
||||
clientIp: input.clientIp,
|
||||
containerId: runtime.id,
|
||||
detailJson: {
|
||||
accountName: account.name,
|
||||
containerName: runtime.name,
|
||||
webuiStatus,
|
||||
},
|
||||
eventType: 'session.create',
|
||||
selfId: account.selfId,
|
||||
sessionId: gatewaySession.sessionId,
|
||||
userAgent: input.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
account: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
selfId: account.selfId,
|
||||
},
|
||||
container: {
|
||||
webuiStatus,
|
||||
},
|
||||
expiresAt: gatewaySession.expiresAt,
|
||||
iframeUrl: gatewaySession.iframeUrl,
|
||||
sessionId: gatewaySession.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards a Gateway heartbeat for an existing Admin WebUI session.
|
||||
* @param input - Gateway session id plus Admin actor and request evidence.
|
||||
* @returns Gateway lifecycle response.
|
||||
*/
|
||||
heartbeat(
|
||||
input: QqbotNapcatWebuiGatewaySessionLifecycleInput,
|
||||
): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
||||
return this.gatewayClient.heartbeat({
|
||||
adminUserId: input.adminUserId,
|
||||
clientIp: input.clientIp || undefined,
|
||||
sessionId: this.requireSessionId(input.sessionId),
|
||||
userAgent: input.userAgent || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an existing Admin WebUI Gateway session.
|
||||
* @param input - Gateway session id plus Admin actor and request evidence.
|
||||
* @returns Gateway lifecycle response.
|
||||
*/
|
||||
revoke(
|
||||
input: QqbotNapcatWebuiGatewaySessionLifecycleInput,
|
||||
): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
||||
return this.gatewayClient.revoke({
|
||||
adminUserId: input.adminUserId,
|
||||
clientIp: input.clientIp || undefined,
|
||||
sessionId: this.requireSessionId(input.sessionId),
|
||||
userAgent: input.userAgent || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a QQBot account id before querying persistence or container state.
|
||||
* @param accountId - Candidate account id supplied by Admin.
|
||||
* @returns Trimmed account id.
|
||||
*/
|
||||
private requireAccountId(accountId: string) {
|
||||
const normalized = String(accountId || '').trim();
|
||||
if (!ACCOUNT_ID_PATTERN.test(normalized)) {
|
||||
throwVbenError('QQBot 账号ID不合法', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gateway session id before forwarding lifecycle calls.
|
||||
* @param sessionId - Candidate session id from the Admin route.
|
||||
* @returns Trimmed Gateway session id.
|
||||
*/
|
||||
private requireSessionId(sessionId: string) {
|
||||
const normalized = String(sessionId || '').trim();
|
||||
if (!SESSION_ID_PATTERN.test(normalized)) {
|
||||
throwVbenError('Gateway 会话ID不合法', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts private NapCat runtime fields into the Gateway client payload.
|
||||
* @param runtime - Primary NapCat runtime containing upstream URL, port, and WebUI token.
|
||||
* @returns Internal-only Gateway target metadata without exposing the raw WebUI port separately.
|
||||
*/
|
||||
private toGatewayTarget(runtime: QqbotNapcatRuntime) {
|
||||
const upstreamBaseUrl = String(runtime.baseUrl || '').trim();
|
||||
const webuiToken = String(runtime.webuiToken || '').trim();
|
||||
const webuiPort = Number(runtime.webuiPort);
|
||||
|
||||
if (!upstreamBaseUrl || !webuiToken || !Number.isFinite(webuiPort)) {
|
||||
throwVbenError('NapCat WebUI 配置不完整');
|
||||
}
|
||||
if (webuiPort <= 0) {
|
||||
throwVbenError('NapCat WebUI 配置不完整');
|
||||
}
|
||||
|
||||
return {
|
||||
upstreamBaseUrl,
|
||||
webuiToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps runtime evidence to the browser-safe WebUI status field.
|
||||
* @param runtime - Primary NapCat runtime with container online evidence.
|
||||
* @returns Browser-safe WebUI status string.
|
||||
*/
|
||||
private toWebuiStatus(
|
||||
runtime: QqbotNapcatRuntime,
|
||||
): QqbotNapcatWebuiStatus {
|
||||
return runtime.sourceContainerOnline ? 'online' : 'offline';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentAdminUser, throwVbenError, vbenSuccess } from '@/common';
|
||||
import type { AdminRequest } from '@/modules/admin/contract/admin.types';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
||||
import { QqbotNapcatWebuiGatewayService } from '../application/qqbot-napcat-webui-gateway.service';
|
||||
import {
|
||||
QqbotNapcatWebuiSessionCreateDto,
|
||||
QqbotNapcatWebuiSessionResponseDto,
|
||||
} from './qqbot-napcat-webui-gateway.dto';
|
||||
|
||||
const WEBUI_PERMISSION_AUTH_CODE = 'QqBot:Account:WebUI';
|
||||
const ACCOUNT_ID_PATTERN = /^[1-9]\d{0,31}$/;
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
@ApiTags('QQBot - NapCat WebUI Gateway')
|
||||
@Controller('qqbot/napcat/webui')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class QqbotNapcatWebuiGatewayController {
|
||||
/**
|
||||
* Creates the Admin-authenticated WebUI Gateway controller.
|
||||
* @param gatewayService - Application service that creates, heartbeats, and revokes Gateway sessions.
|
||||
*/
|
||||
constructor(
|
||||
private readonly gatewayService: QqbotNapcatWebuiGatewayService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a browser-safe Gateway session for an account-bound NapCat WebUI.
|
||||
* @param body - Request body containing the QQBot account id.
|
||||
* @param user - Authenticated Admin user from JwtAuthGuard.
|
||||
* @param req - Express request used only for IP and user-agent audit evidence.
|
||||
* @returns Vben response containing safe session metadata for the Admin page.
|
||||
*/
|
||||
@Post('session')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '创建 NapCat WebUI Gateway 会话' })
|
||||
@ApiOkResponse({ type: QqbotNapcatWebuiSessionResponseDto })
|
||||
async createSession(
|
||||
@Body() body: QqbotNapcatWebuiSessionCreateDto,
|
||||
@CurrentAdminUser() user: AdminUser,
|
||||
@Req() req: AdminRequest,
|
||||
) {
|
||||
this.assertWebuiPermission(user);
|
||||
|
||||
return vbenSuccess(
|
||||
await this.gatewayService.createSession({
|
||||
accountId: this.requireAccountId(body.accountId),
|
||||
...this.toClientEvidence(user, req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Gateway heartbeat for one active WebUI session.
|
||||
* @param sessionId - Gateway session id returned by the create-session endpoint.
|
||||
* @param user - Authenticated Admin user from JwtAuthGuard.
|
||||
* @param req - Express request used only for IP and user-agent Gateway evidence.
|
||||
* @returns Vben response containing Gateway lifecycle state.
|
||||
*/
|
||||
@Post('session/:sessionId/heartbeat')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新 NapCat WebUI Gateway 会话心跳' })
|
||||
async heartbeat(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentAdminUser() user: AdminUser,
|
||||
@Req() req: AdminRequest,
|
||||
) {
|
||||
this.assertWebuiPermission(user);
|
||||
|
||||
return vbenSuccess(
|
||||
await this.gatewayService.heartbeat({
|
||||
sessionId: this.requireSessionId(sessionId),
|
||||
...this.toClientEvidence(user, req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes one active WebUI Gateway session.
|
||||
* @param sessionId - Gateway session id returned by the create-session endpoint.
|
||||
* @param user - Authenticated Admin user from JwtAuthGuard.
|
||||
* @param req - Express request used only for IP and user-agent Gateway evidence.
|
||||
* @returns Vben response containing Gateway lifecycle state.
|
||||
*/
|
||||
@Post('session/:sessionId/revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '撤销 NapCat WebUI Gateway 会话' })
|
||||
async revoke(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentAdminUser() user: AdminUser,
|
||||
@Req() req: AdminRequest,
|
||||
) {
|
||||
this.assertWebuiPermission(user);
|
||||
|
||||
return vbenSuccess(
|
||||
await this.gatewayService.revoke({
|
||||
sessionId: this.requireSessionId(sessionId),
|
||||
...this.toClientEvidence(user, req),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the Admin menu permission required for NapCat WebUI session access.
|
||||
* @param user - Authenticated Admin user with eager role/menu relations.
|
||||
*/
|
||||
private assertWebuiPermission(user: AdminUser) {
|
||||
if (!this.hasWebuiPermission(user)) {
|
||||
throwVbenError('无权访问 NapCat WebUI', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks active roles for WebUI menu permission, allowing active super role as bypass.
|
||||
* @param user - Authenticated Admin user.
|
||||
* @returns Whether the user may create or manage WebUI Gateway sessions.
|
||||
*/
|
||||
private hasWebuiPermission(user: AdminUser) {
|
||||
const roles = Array.isArray(user?.roles) ? user.roles : [];
|
||||
return roles.some((role) => {
|
||||
if (!role || role.isDeleted || role.status !== 1) return false;
|
||||
if (role.roleCode === 'super') return true;
|
||||
|
||||
const menus = Array.isArray(role.menus) ? role.menus : [];
|
||||
return menus.some((menu) => {
|
||||
return (
|
||||
!!menu &&
|
||||
!menu.isDeleted &&
|
||||
(menu.status === undefined || menu.status === 1) &&
|
||||
menu.authCode === WEBUI_PERMISSION_AUTH_CODE
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a QQBot account id before handing work to the application service.
|
||||
* @param accountId - Candidate account id from the request body.
|
||||
* @returns Trimmed account id.
|
||||
*/
|
||||
private requireAccountId(accountId: string) {
|
||||
const normalized = String(accountId || '').trim();
|
||||
if (!ACCOUNT_ID_PATTERN.test(normalized)) {
|
||||
throwVbenError('QQBot 账号ID不合法', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gateway session id before lifecycle forwarding.
|
||||
* @param sessionId - Candidate session id from the route.
|
||||
* @returns Trimmed Gateway session id.
|
||||
*/
|
||||
private requireSessionId(sessionId: string) {
|
||||
const normalized = String(sessionId || '').trim();
|
||||
if (!SESSION_ID_PATTERN.test(normalized)) {
|
||||
throwVbenError('Gateway 会话ID不合法', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Admin actor and client evidence passed through to Gateway ownership checks.
|
||||
* @param user - Authenticated Admin user.
|
||||
* @param req - Express request carrying IP and user-agent.
|
||||
* @returns Lifecycle evidence for application and Gateway calls.
|
||||
*/
|
||||
private toClientEvidence(user: AdminUser, req: AdminRequest) {
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
return {
|
||||
adminUserId: user.id,
|
||||
clientIp: req.ip,
|
||||
userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { QqbotNapcatWebuiStatus } from '@/modules/qqbot/core/contract/qqbot.types';
|
||||
|
||||
export class QqbotNapcatWebuiSessionCreateDto {
|
||||
@ApiProperty({ description: 'QQBot account id bound to the NapCat WebUI.' })
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export class QqbotNapcatWebuiSessionAccountDto {
|
||||
@ApiProperty({ description: 'QQBot account id.' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'QQBot account display name.' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'QQ self id for the account.' })
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
export class QqbotNapcatWebuiSessionContainerDto {
|
||||
@ApiProperty({
|
||||
description: 'Browser-safe WebUI availability status.',
|
||||
enum: ['offline', 'online', 'unknown'],
|
||||
})
|
||||
webuiStatus: QqbotNapcatWebuiStatus;
|
||||
}
|
||||
|
||||
export class QqbotNapcatWebuiSessionResponseDto {
|
||||
@ApiProperty({ type: QqbotNapcatWebuiSessionAccountDto })
|
||||
account: QqbotNapcatWebuiSessionAccountDto;
|
||||
|
||||
@ApiProperty({ type: QqbotNapcatWebuiSessionContainerDto })
|
||||
container: QqbotNapcatWebuiSessionContainerDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Gateway session expiry timestamp in milliseconds.',
|
||||
})
|
||||
expiresAt: number;
|
||||
|
||||
@ApiProperty({ description: 'Browser-safe iframe URL served by Gateway.' })
|
||||
iframeUrl: string;
|
||||
|
||||
@ApiProperty({ description: 'Gateway session id used for lifecycle calls.' })
|
||||
sessionId: string;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime } from '@/common';
|
||||
|
||||
@Entity('qqbot_napcat_webui_gateway_audit')
|
||||
@Index('idx_napcat_webui_gateway_audit_session', ['sessionId'])
|
||||
@Index('idx_napcat_webui_gateway_audit_account_event', [
|
||||
'accountId',
|
||||
'eventType',
|
||||
])
|
||||
@Index('idx_napcat_webui_gateway_audit_admin_time', [
|
||||
'adminUserId',
|
||||
'createTime',
|
||||
])
|
||||
export class NapcatWebuiGatewayAudit {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ length: 64, name: 'session_id', type: 'varchar' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ name: 'admin_user_id', type: 'bigint' })
|
||||
adminUserId: string;
|
||||
|
||||
@Column({ name: 'account_id', type: 'bigint' })
|
||||
accountId: string;
|
||||
|
||||
@Column({ length: 32, name: 'self_id' })
|
||||
selfId: string;
|
||||
|
||||
@Column({ name: 'container_id', type: 'bigint' })
|
||||
containerId: string;
|
||||
|
||||
@Column({ length: 64, name: 'event_type' })
|
||||
eventType: string;
|
||||
|
||||
@Column({ default: null, length: 128, name: 'client_ip', nullable: true })
|
||||
clientIp: null | string;
|
||||
|
||||
@Column({ default: null, length: 512, name: 'user_agent', nullable: true })
|
||||
userAgent: null | string;
|
||||
|
||||
@Column({ default: null, name: 'detail_json', nullable: true, type: 'json' })
|
||||
detailJson: null | Record<string, unknown>;
|
||||
|
||||
@KtCreateDateColumn({ name: 'create_time' })
|
||||
createTime: KtDateTime;
|
||||
|
||||
/**
|
||||
* Assigns a Snowflake id before persisting an Admin WebUI gateway audit row.
|
||||
*/
|
||||
@BeforeInsert()
|
||||
createId() {
|
||||
ensureSnowflakeId(this);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,302 @@
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwVbenError } from '@/common';
|
||||
|
||||
const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086';
|
||||
const DEFAULT_GATEWAY_TIMEOUT_MS = 5000;
|
||||
const GATEWAY_PUBLIC_SESSION_PREFIX = '/napcat-webui/session/';
|
||||
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const SAFE_BOOTSTRAP_TICKET_PATTERN = /^[A-Za-z0-9._~-]+$/;
|
||||
const UNSAFE_GATEWAY_RESULT_PATTERN =
|
||||
/(\bCredential\b|\bBearer\s+\S+|webui[_-]?token|(?:^|[?&\s])(token|secret|password|credential|captcha)=|https?:\/\/|\/\/|127\.0\.0\.1|localhost|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|:\d{2,5}\b|\bdocker\b|\bnas\b|\/vol\d\b|\/internal\/sessions\b)/i;
|
||||
|
||||
export type QqbotNapcatWebuiGatewayCreateSessionRequest = {
|
||||
accountId: string;
|
||||
adminUserId: string;
|
||||
clientIp?: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
selfId: string;
|
||||
upstreamBaseUrl: string;
|
||||
userAgent?: string;
|
||||
webuiToken: string;
|
||||
};
|
||||
|
||||
export type QqbotNapcatWebuiGatewayLifecycleRequest = {
|
||||
adminUserId: string;
|
||||
clientIp?: string;
|
||||
sessionId: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
export type QqbotNapcatWebuiGatewaySessionResult = {
|
||||
expiresAt: number;
|
||||
iframeUrl: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type QqbotNapcatWebuiGatewayLifecycleResult = Record<string, unknown>;
|
||||
|
||||
type GatewayResponseBody<T> = T | { data: T };
|
||||
|
||||
@Injectable()
|
||||
export class QqbotNapcatWebuiGatewayClient {
|
||||
/**
|
||||
* Creates the internal Gateway client backed by bounded axios requests.
|
||||
* @param configService - Nest config source for Gateway base URL, internal secret, and timeout.
|
||||
*/
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Creates a proxied NapCat WebUI session through the internal Gateway.
|
||||
* @param input - Server-only target metadata, including WebUI token and upstream endpoint.
|
||||
* @returns Browser-safe Gateway session metadata.
|
||||
*/
|
||||
async createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) {
|
||||
return this.validateSessionResult(
|
||||
await this.post<QqbotNapcatWebuiGatewaySessionResult>(
|
||||
'/internal/sessions',
|
||||
input,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes one Gateway session heartbeat without exposing internal target data.
|
||||
* @param input - Gateway session id plus Admin actor and request evidence.
|
||||
* @returns Gateway lifecycle response body.
|
||||
*/
|
||||
heartbeat(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
|
||||
const { sessionId, ...data } = input;
|
||||
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
||||
`/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes one Gateway session without exposing internal target data.
|
||||
* @param input - Gateway session id plus Admin actor and request evidence.
|
||||
* @returns Gateway lifecycle response body.
|
||||
*/
|
||||
revoke(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
|
||||
const { sessionId, ...data } = input;
|
||||
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
||||
`/internal/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one bounded POST request to the internal Gateway and strips raw axios errors.
|
||||
* @param path - Internal Gateway path starting with `/internal`.
|
||||
* @param data - Optional JSON payload sent only server-to-server.
|
||||
* @returns Unwrapped Gateway response data.
|
||||
*/
|
||||
private async post<T>(path: string, data?: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
data,
|
||||
headers: this.getHeaders(),
|
||||
method: 'POST',
|
||||
timeout: this.getTimeoutMs(),
|
||||
url: this.buildUrl(path),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.request<GatewayResponseBody<T>>(config);
|
||||
return this.unwrapGatewayBody<T>(response.data);
|
||||
} catch {
|
||||
throwVbenError(
|
||||
'NapCat WebUI Gateway 请求失败',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete Gateway URL from a configured base URL and fixed internal path.
|
||||
* @param path - Internal Gateway path supplied by the service method.
|
||||
* @returns Absolute Gateway URL without duplicate slashes.
|
||||
*/
|
||||
private buildUrl(path: string) {
|
||||
return `${this.getBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and normalizes the internal Gateway base URL.
|
||||
* @returns Configured base URL or the local Gateway default.
|
||||
*/
|
||||
private getBaseUrl() {
|
||||
const configured = this.configService.get<string>(
|
||||
'NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL',
|
||||
);
|
||||
return (configured || DEFAULT_GATEWAY_BASE_URL).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the optional Gateway shared secret and maps it to the internal header.
|
||||
* @returns Header map when a secret is configured, otherwise undefined.
|
||||
*/
|
||||
private getHeaders() {
|
||||
const secret = this.getInternalSecret();
|
||||
|
||||
return { 'x-kt-gateway-secret': secret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the required internal Gateway secret and fails closed when it is missing.
|
||||
* @returns Configured non-empty shared secret.
|
||||
*/
|
||||
private getInternalSecret() {
|
||||
const secret = String(
|
||||
this.configService.get<string>('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') ||
|
||||
'',
|
||||
).trim();
|
||||
|
||||
if (!secret) {
|
||||
throwVbenError(
|
||||
'NapCat WebUI Gateway 内部密钥未配置',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates the Gateway request timeout.
|
||||
* @returns Positive timeout in milliseconds.
|
||||
*/
|
||||
private getTimeoutMs() {
|
||||
const configured = Number(
|
||||
this.configService.get<string>('NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS') || '',
|
||||
);
|
||||
|
||||
return Number.isFinite(configured) && configured > 0
|
||||
? configured
|
||||
: DEFAULT_GATEWAY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts both raw Gateway bodies and Vben-like `{ data }` wrappers.
|
||||
* @param body - Axios response body returned by the internal Gateway.
|
||||
* @returns The unwrapped data payload expected by API callers.
|
||||
*/
|
||||
private unwrapGatewayBody<T>(body: GatewayResponseBody<T>): T {
|
||||
if (body && typeof body === 'object' && 'data' in body) {
|
||||
return (body as { data: T }).data;
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the create-session result before returning it to Admin callers.
|
||||
* @param result - Raw Gateway create-session result.
|
||||
* @returns Browser-safe session result.
|
||||
*/
|
||||
private validateSessionResult(
|
||||
result: QqbotNapcatWebuiGatewaySessionResult,
|
||||
): QqbotNapcatWebuiGatewaySessionResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
this.throwInvalidSessionResult();
|
||||
}
|
||||
|
||||
const sessionId = String(result.sessionId || '').trim();
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||
this.throwInvalidSessionResult();
|
||||
}
|
||||
if (!Number.isFinite(result.expiresAt)) {
|
||||
this.throwInvalidSessionResult();
|
||||
}
|
||||
if (!this.isSafeIframeUrl(result.iframeUrl, sessionId)) {
|
||||
this.throwInvalidSessionResult();
|
||||
}
|
||||
|
||||
return {
|
||||
expiresAt: result.expiresAt,
|
||||
iframeUrl: result.iframeUrl,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the iframe URL is a relative Gateway-owned route with only an optional bootstrap ticket.
|
||||
* @param iframeUrl - Raw iframe URL returned by Gateway.
|
||||
* @param sessionId - Validated Gateway session id.
|
||||
* @returns Whether the URL is safe for the browser response.
|
||||
*/
|
||||
private isSafeIframeUrl(iframeUrl: unknown, sessionId: string) {
|
||||
if (typeof iframeUrl !== 'string' || iframeUrl.trim() !== iframeUrl) {
|
||||
return false;
|
||||
}
|
||||
if (!iframeUrl.startsWith(GATEWAY_PUBLIC_SESSION_PREFIX)) return false;
|
||||
if (iframeUrl.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(iframeUrl)) {
|
||||
return false;
|
||||
}
|
||||
if (iframeUrl.includes('\\')) return false;
|
||||
|
||||
const queryStart = iframeUrl.indexOf('?');
|
||||
const path =
|
||||
queryStart >= 0 ? iframeUrl.slice(0, queryStart) : iframeUrl;
|
||||
const query = queryStart >= 0 ? iframeUrl.slice(queryStart + 1) : '';
|
||||
const expectedPrefix = `${GATEWAY_PUBLIC_SESSION_PREFIX}${sessionId}/`;
|
||||
if (!path.startsWith(expectedPrefix)) return false;
|
||||
|
||||
const isBootstrapRoute = path === `${expectedPrefix}bootstrap`;
|
||||
if (query.includes('?') || /%3f/i.test(query)) return false;
|
||||
if (query && this.hasUnsafeGatewayEvidence(query)) return false;
|
||||
|
||||
const params = new URLSearchParams(query);
|
||||
const entries = [...params.entries()];
|
||||
const ticketValues = params.getAll('ticket');
|
||||
if (query) {
|
||||
if (!isBootstrapRoute) return false;
|
||||
if (entries.length !== 1 || ticketValues.length !== 1) return false;
|
||||
const [key, ticket] = entries[0];
|
||||
if (key !== 'ticket') return false;
|
||||
if (!SAFE_BOOTSTRAP_TICKET_PATTERN.test(ticket)) return false;
|
||||
} else if (/ticket/i.test(iframeUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unsafeScanValue = query
|
||||
? `${path}?ticket=`
|
||||
: iframeUrl;
|
||||
return !this.hasUnsafeGatewayEvidence(unsafeScanValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects host, secret, and internal-route evidence in Gateway browser-facing URLs.
|
||||
* @param value - Candidate iframe URL with allowed bootstrap ticket value stripped.
|
||||
* @returns Whether the string contains unsafe evidence.
|
||||
*/
|
||||
private hasUnsafeGatewayEvidence(value: string) {
|
||||
const decoded = this.tryDecodeURIComponent(value);
|
||||
return UNSAFE_GATEWAY_RESULT_PATTERN.test(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes URL text for security scanning without leaking parsing errors to callers.
|
||||
* @param value - URL text to decode.
|
||||
* @returns Decoded value when possible, otherwise the original text.
|
||||
*/
|
||||
private tryDecodeURIComponent(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws the sanitized error used for invalid Gateway create-session responses.
|
||||
*/
|
||||
private throwInvalidSessionResult(): never {
|
||||
return throwVbenError(
|
||||
'NapCat WebUI Gateway 返回无效会话',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
341
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal file
341
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal file
@ -0,0 +1,341 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { HttpStatus, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import axios from 'axios';
|
||||
import * as request from 'supertest';
|
||||
import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service';
|
||||
import type { NapcatWebuiGatewaySession } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
|
||||
import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
|
||||
import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||
import {
|
||||
buildGatewayCookiePathRewrite,
|
||||
NapcatWebuiProxyService,
|
||||
rewriteNapcatLocationHeader,
|
||||
sanitizeGatewayProxyPath,
|
||||
} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
|
||||
import { PublicWebuiController } from '../../../src/apps/napcat-webui-gateway/presentation/public-webui.controller';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const SESSION_ID = 'session-1';
|
||||
const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099';
|
||||
const repoRoot = resolve(__dirname, '../../..');
|
||||
const mockedAxiosPost = axios.post as jest.Mock;
|
||||
|
||||
/**
|
||||
* Creates server-only Gateway session metadata for public bootstrap tests.
|
||||
* @param override - Session fields that should replace the default fixture.
|
||||
* @returns Gateway session fixture.
|
||||
*/
|
||||
function createGatewaySession(
|
||||
override: Partial<NapcatWebuiGatewaySession> = {},
|
||||
): NapcatWebuiGatewaySession {
|
||||
return {
|
||||
accountId: 'account-1',
|
||||
adminUserId: 'admin-1',
|
||||
containerId: 'container-1',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
createdAt: 1000,
|
||||
expiresAt: 61_000,
|
||||
selfId: '1914728559',
|
||||
sessionId: SESSION_ID,
|
||||
status: 'created',
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
webuiToken: ['webui', 'token', 'fixture'].join('-'),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Gateway config fixture for credential client tests.
|
||||
* @param currentTime - Mutable timestamp source used to check cache expiry decisions.
|
||||
* @returns Config facade shape required by NapcatWebuiCredentialClient.
|
||||
*/
|
||||
function createCredentialConfig(currentTime: { value: number }) {
|
||||
return {
|
||||
now: () => currentTime.value,
|
||||
upstreamTimeoutMs: () => 5000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads source files from the API repository for proxy bootstrap boundary checks.
|
||||
* @param relativePath - Repository-relative TypeScript source path.
|
||||
* @returns File text used by structural regression assertions.
|
||||
*/
|
||||
function readApiSource(relativePath: string) {
|
||||
return readFileSync(resolve(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
describe('Napcat WebUI proxy rewrite helpers', () => {
|
||||
it('rejects absolute URL proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('../api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects encoded dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('%2e%2e/api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects double-encoded dot-segment traversal proxy paths', () => {
|
||||
expect(() => sanitizeGatewayProxyPath('%252e%252e/api/auth/login')).toThrow(
|
||||
'Gateway proxy path is invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes string proxy paths to absolute upstream paths', () => {
|
||||
expect(sanitizeGatewayProxyPath('api/QQLogin/CheckLoginStatus')).toBe(
|
||||
'/api/QQLogin/CheckLoginStatus',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes path-to-regexp array proxy paths to absolute upstream paths', () => {
|
||||
expect(
|
||||
sanitizeGatewayProxyPath(['api', 'QQLogin', 'CheckLoginStatus']),
|
||||
).toBe('/api/QQLogin/CheckLoginStatus');
|
||||
});
|
||||
|
||||
it('rewrites NapCat relative redirects under the Gateway session prefix', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '/webui/login',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('rewrites absolute redirects under the Gateway session prefix without leaking origin', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://127.0.0.1:6099/webui/login?next=/',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://container.internal:6099/webui/login',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('rewrites protocol-relative redirects under the Gateway session prefix without leaking origin', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '//container.internal:6099/webui/login?next=/',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('drops upstream redirect query and hash fragments before returning browser locations', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '/webui/login?Credential=secret#token',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'webui/login?ticket=secret#hash',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
|
||||
});
|
||||
|
||||
it('fails closed for malformed absolute redirects', () => {
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: 'http://%',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
|
||||
expect(
|
||||
rewriteNapcatLocationHeader({
|
||||
location: '//%',
|
||||
sessionId: SESSION_ID,
|
||||
upstreamBaseUrl: UPSTREAM_BASE_URL,
|
||||
}),
|
||||
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
|
||||
});
|
||||
|
||||
it('scopes all upstream cookies to the Gateway WebUI path', () => {
|
||||
expect(buildGatewayCookiePathRewrite({ sessionId: SESSION_ID })).toEqual({
|
||||
'*': `/napcat-webui/session/${SESSION_ID}/webui`,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps request bodies available for the public proxy route', () => {
|
||||
const mainSource = readApiSource('src/apps/napcat-webui-gateway/main.ts');
|
||||
const proxySource = readApiSource(
|
||||
'src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts',
|
||||
);
|
||||
|
||||
expect(mainSource).toContain("app.use('/internal', json");
|
||||
expect(mainSource).toContain("app.use('/internal', urlencoded");
|
||||
expect(mainSource).toContain('bodyParser: false');
|
||||
expect(mainSource).not.toContain('app.use(json({ limit');
|
||||
expect(mainSource).not.toContain('app.use(urlencoded({ extended');
|
||||
expect(proxySource).toContain('fixRequestBody');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapcatWebuiCredentialClient', () => {
|
||||
beforeEach(() => {
|
||||
mockedAxiosPost.mockReset();
|
||||
});
|
||||
|
||||
it('exchanges the WebUI token hash and caches the server-side Credential until session expiry', async () => {
|
||||
const currentTime = { value: 1000 };
|
||||
const client = new NapcatWebuiCredentialClient(
|
||||
createCredentialConfig(currentTime) as never,
|
||||
);
|
||||
mockedAxiosPost.mockResolvedValue({ data: { Credential: 'credential-1' } });
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
|
||||
expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockedAxiosPost).toHaveBeenCalledWith(
|
||||
`${UPSTREAM_BASE_URL}/api/auth/login`,
|
||||
{
|
||||
hash: createHash('sha256')
|
||||
.update('webui-token-fixture.napcat')
|
||||
.digest('hex'),
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears cached Credential when the Gateway session is revoked', async () => {
|
||||
const currentTime = { value: 1000 };
|
||||
const client = new NapcatWebuiCredentialClient(
|
||||
createCredentialConfig(currentTime) as never,
|
||||
);
|
||||
mockedAxiosPost
|
||||
.mockResolvedValueOnce({ data: { Credential: 'credential-1' } })
|
||||
.mockResolvedValueOnce({ data: { Credential: 'credential-2' } });
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-1',
|
||||
);
|
||||
client.clear(SESSION_ID);
|
||||
|
||||
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
|
||||
'credential-2',
|
||||
);
|
||||
expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicWebuiController bootstrap', () => {
|
||||
let app: INestApplication;
|
||||
let ticketService: { redeem: jest.Mock };
|
||||
let sessionService: {
|
||||
markActive: jest.Mock;
|
||||
requireBootstrapSession: jest.Mock;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
ticketService = {
|
||||
redeem: jest.fn(),
|
||||
};
|
||||
sessionService = {
|
||||
markActive: jest.fn(),
|
||||
requireBootstrapSession: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [PublicWebuiController],
|
||||
providers: [
|
||||
{
|
||||
provide: NapcatWebuiGatewaySessionService,
|
||||
useValue: sessionService,
|
||||
},
|
||||
{
|
||||
provide: NapcatWebuiGatewayTicketService,
|
||||
useValue: ticketService,
|
||||
},
|
||||
{
|
||||
provide: NapcatWebuiProxyService,
|
||||
useValue: {
|
||||
handleHttpProxy: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ticketService.redeem.mockReset();
|
||||
sessionService.markActive.mockReset();
|
||||
sessionService.requireBootstrapSession.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('redeems a ticket, activates the session, sets an HttpOnly cookie, and redirects to WebUI', async () => {
|
||||
ticketService.redeem.mockResolvedValue(SESSION_ID);
|
||||
sessionService.requireBootstrapSession.mockResolvedValue(
|
||||
createGatewaySession(),
|
||||
);
|
||||
sessionService.markActive.mockResolvedValue(
|
||||
createGatewaySession({ status: 'active' }),
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=ticket-1`)
|
||||
.expect(HttpStatus.FOUND);
|
||||
|
||||
expect(ticketService.redeem).toHaveBeenCalledWith('ticket-1');
|
||||
expect(sessionService.requireBootstrapSession).toHaveBeenCalledWith(
|
||||
SESSION_ID,
|
||||
);
|
||||
expect(sessionService.markActive).toHaveBeenCalledWith(SESSION_ID);
|
||||
expect(response.headers.location).toBe(
|
||||
`/napcat-webui/session/${SESSION_ID}/webui/webui`,
|
||||
);
|
||||
expect(response.headers['set-cookie']).toEqual([
|
||||
expect.stringContaining(`Path=/napcat-webui/session/${SESSION_ID}`),
|
||||
]);
|
||||
expect(response.headers['set-cookie'][0]).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('rejects expired tickets without activating the session', async () => {
|
||||
ticketService.redeem.mockResolvedValue(undefined);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=expired`)
|
||||
.expect(HttpStatus.GONE);
|
||||
|
||||
expect(sessionService.requireBootstrapSession).not.toHaveBeenCalled();
|
||||
expect(sessionService.markActive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
957
test/apps/napcat-webui-gateway/session-store.spec.ts
Normal file
957
test/apps/napcat-webui-gateway/session-store.spec.ts
Normal file
@ -0,0 +1,957 @@
|
||||
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 { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reads and deletes one key from the fake Redis store.
|
||||
* @param key - Redis key to consume.
|
||||
* @returns Stored value before deletion or null.
|
||||
*/
|
||||
async getdel(key: string) {
|
||||
this.calls.push(`getdel:${key}`);
|
||||
const value = this.values.get(key) ?? null;
|
||||
this.values.delete(key);
|
||||
this.ttl.delete(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates Gateway Redis Lua scripts used by ticket and session store tests.
|
||||
* @param script - Lua script text.
|
||||
* @param keyCount - Number of Redis keys in the script call.
|
||||
* @param args - Redis keys and script arguments after `keyCount`.
|
||||
* @returns Script-shaped response used by the store.
|
||||
*/
|
||||
async eval(script: string, keyCount: number, ...args: string[]) {
|
||||
this.calls.push(`eval:${keyCount}:${args.join(':')}`);
|
||||
if (!script.includes('redis.call')) {
|
||||
throw new Error('Unexpected Redis script');
|
||||
}
|
||||
if (keyCount !== 1) {
|
||||
throw new Error('Unexpected Redis key count');
|
||||
}
|
||||
|
||||
const [sessionKey, sessionId, patchJson, userAccountKeyPrefix, now] = args;
|
||||
const currentJson = this.values.get(sessionKey);
|
||||
if (!currentJson) return [0, 'Gateway session is not active'];
|
||||
|
||||
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
|
||||
const patch = JSON.parse(patchJson) as Partial<NapcatWebuiGatewaySession>;
|
||||
const next = {
|
||||
...current,
|
||||
...patch,
|
||||
accountId: current.accountId,
|
||||
adminUserId: current.adminUserId,
|
||||
sessionId,
|
||||
} as NapcatWebuiGatewaySession;
|
||||
next.expiresAt = Math.max(current.expiresAt, patch.expiresAt || 0);
|
||||
if (current.lastSeenAt || patch.lastSeenAt) {
|
||||
next.lastSeenAt = Math.max(
|
||||
current.lastSeenAt || 0,
|
||||
patch.lastSeenAt || 0,
|
||||
);
|
||||
}
|
||||
if (current.activeAt) {
|
||||
next.activeAt = current.activeAt;
|
||||
}
|
||||
if (current.revokedAt) {
|
||||
next.revokedAt = current.revokedAt;
|
||||
}
|
||||
|
||||
const terminalStatuses = ['expired', 'failed', 'revoked'];
|
||||
const currentTerminal = terminalStatuses.includes(current.status);
|
||||
const nextTerminal = terminalStatuses.includes(next.status);
|
||||
const indexKey = `${userAccountKeyPrefix}${current.adminUserId}:${current.accountId}`;
|
||||
const indexValue = this.values.get(indexKey);
|
||||
const ttlMs = Math.max(1, next.expiresAt - Number(now));
|
||||
const nextSessionJson = JSON.stringify(next);
|
||||
|
||||
if (currentTerminal && !nextTerminal) {
|
||||
return [0, 'Gateway session is not active'];
|
||||
}
|
||||
|
||||
if (nextTerminal) {
|
||||
this.values.set(sessionKey, nextSessionJson);
|
||||
this.ttl.set(sessionKey, ttlMs);
|
||||
if (indexValue === sessionId) {
|
||||
this.values.delete(indexKey);
|
||||
this.ttl.delete(indexKey);
|
||||
}
|
||||
return [1, nextSessionJson];
|
||||
}
|
||||
|
||||
if (indexValue !== sessionId) {
|
||||
return [0, 'Gateway session is not active'];
|
||||
}
|
||||
|
||||
this.values.set(sessionKey, nextSessionJson);
|
||||
this.ttl.set(sessionKey, ttlMs);
|
||||
this.values.set(indexKey, sessionId);
|
||||
this.ttl.set(indexKey, ttlMs);
|
||||
return [1, nextSessionJson];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('does not allow terminal sessions to become active again', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig({ value: 1000 }) as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await service.revoke({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
await expect(service.markActive(session.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.heartbeat({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(await store.find(session.sessionId)).toMatchObject({
|
||||
status: 'revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects blank required create fields and invalid upstream URLs', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig({ value: 1000 }) as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.create(createSessionInput({ webuiToken: ' ' })),
|
||||
).rejects.toThrow('Gateway session field webuiToken is required');
|
||||
await expect(
|
||||
service.create(
|
||||
createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' }),
|
||||
),
|
||||
).rejects.toThrow('Gateway session upstream URL is invalid');
|
||||
expect(store.sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
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 service.markActive(session.sessionId);
|
||||
|
||||
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 heartbeat for created sessions without activating them', 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-1',
|
||||
sessionId: session.sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(await store.find(session.sessionId)).toMatchObject({
|
||||
status: 'created',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps created sessions out of proxy access until bootstrap marks them active', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const currentTime = { value: 1000 };
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig(currentTime) as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
await expect(
|
||||
service.requireBootstrapSession(session.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: session.sessionId,
|
||||
status: 'created',
|
||||
});
|
||||
|
||||
currentTime.value = 5000;
|
||||
await service.markActive(session.sessionId);
|
||||
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
activeAt: 5000,
|
||||
sessionId: session.sessionId,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects revoked sessions for proxy access', async () => {
|
||||
const store = new MemorySessionStore();
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
createConfig({ value: 1000 }) as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
await service.revoke({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.requireProxySession(session.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('keeps the newer user-account index when an older revoked session is revoked again', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const config = createConfig({ value: 1000 });
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
|
||||
const first = await service.create(createSessionInput());
|
||||
const second = await service.create(createSessionInput());
|
||||
|
||||
await expect(
|
||||
service.revoke({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: first.sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
await expect(
|
||||
store.findActiveByUserAndAccount('admin-1', 'account-1'),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: second.sessionId,
|
||||
status: 'created',
|
||||
});
|
||||
expect(
|
||||
redis.values.get('napcat:webui:user-account:admin-1:account-1'),
|
||||
).toBe(second.sessionId);
|
||||
});
|
||||
|
||||
it('rejects stale non-terminal updates when the index points at a newer session', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const config = createConfig({ value: 1000 });
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const first = await service.create(createSessionInput());
|
||||
const second = await service.create(createSessionInput());
|
||||
const firstSessionKey = `napcat:webui:session:${first.sessionId}`;
|
||||
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
|
||||
|
||||
redis.values.set(
|
||||
firstSessionKey,
|
||||
JSON.stringify({
|
||||
...first,
|
||||
status: 'created',
|
||||
}),
|
||||
);
|
||||
redis.values.set(indexKey, second.sessionId);
|
||||
|
||||
await expect(
|
||||
store.update(first.sessionId, {
|
||||
activeAt: 2000,
|
||||
expiresAt: 62_000,
|
||||
lastSeenAt: 2000,
|
||||
status: 'active',
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(redis.values.get(indexKey)).toBe(second.sessionId);
|
||||
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject(
|
||||
{
|
||||
status: 'created',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-terminal updates when the user-account index is missing', 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-missing-index',
|
||||
status: 'created',
|
||||
};
|
||||
const sessionKey = `napcat:webui:session:${session.sessionId}`;
|
||||
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
|
||||
|
||||
await store.create(session);
|
||||
redis.values.delete(indexKey);
|
||||
redis.ttl.delete(indexKey);
|
||||
|
||||
await expect(
|
||||
store.update(session.sessionId, {
|
||||
activeAt: 2000,
|
||||
expiresAt: 62_000,
|
||||
lastSeenAt: 2000,
|
||||
status: 'active',
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
expect(redis.values.get(indexKey)).toBeUndefined();
|
||||
expect(JSON.parse(redis.values.get(sessionKey) || '{}')).toMatchObject({
|
||||
status: 'created',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps delayed old heartbeat and activation from reviving a replaced session', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const currentTime = { value: 1000 };
|
||||
const config = createConfig(currentTime);
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const first = await service.create(createSessionInput());
|
||||
const second = await service.create(createSessionInput());
|
||||
|
||||
currentTime.value = 2000;
|
||||
|
||||
await expect(service.markActive(first.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
await expect(
|
||||
service.heartbeat({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: first.sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
await expect(
|
||||
store.findActiveByUserAndAccount('admin-1', 'account-1'),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: second.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale replaced sessions from bootstrap and proxy loaders', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const config = createConfig({ value: 1000 });
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const first = await service.create(createSessionInput());
|
||||
const second = await service.create(createSessionInput());
|
||||
const firstSessionKey = `napcat:webui:session:${first.sessionId}`;
|
||||
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
|
||||
|
||||
redis.values.set(
|
||||
firstSessionKey,
|
||||
JSON.stringify({
|
||||
...first,
|
||||
status: 'created',
|
||||
}),
|
||||
);
|
||||
redis.values.set(indexKey, second.sessionId);
|
||||
|
||||
await expect(
|
||||
service.requireBootstrapSession(first.sessionId),
|
||||
).rejects.toThrow('Gateway session is not active');
|
||||
|
||||
redis.values.set(
|
||||
firstSessionKey,
|
||||
JSON.stringify({
|
||||
...first,
|
||||
activeAt: 2000,
|
||||
lastSeenAt: 2000,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.requireProxySession(first.sessionId)).rejects.toThrow(
|
||||
'Gateway session is not active',
|
||||
);
|
||||
});
|
||||
|
||||
it('merges delayed lifecycle patches without reducing monotonic timestamps', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const currentTime = { value: 1000 };
|
||||
const config = createConfig(currentTime);
|
||||
const store = new NapcatWebuiGatewayRedisStore(
|
||||
redis as never,
|
||||
config as never,
|
||||
);
|
||||
const service = new NapcatWebuiGatewaySessionService(
|
||||
store,
|
||||
config as never,
|
||||
);
|
||||
const session = await service.create(createSessionInput());
|
||||
|
||||
currentTime.value = 5000;
|
||||
await service.markActive(session.sessionId);
|
||||
|
||||
currentTime.value = 2000;
|
||||
const heartbeat = await service.heartbeat({
|
||||
adminUserId: 'admin-1',
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
const markActive = await service.markActive(session.sessionId);
|
||||
const stored = await store.find(session.sessionId);
|
||||
|
||||
expect(heartbeat.expiresAt).toBe(65_000);
|
||||
expect(markActive).toMatchObject({
|
||||
activeAt: 5000,
|
||||
expiresAt: 65_000,
|
||||
lastSeenAt: 5000,
|
||||
status: 'active',
|
||||
});
|
||||
expect(stored).toMatchObject({
|
||||
activeAt: 5000,
|
||||
expiresAt: 65_000,
|
||||
lastSeenAt: 5000,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
const ticketKey = `napcat:webui:ticket:${ticket}`;
|
||||
const ticketCalls = redis.calls.filter((call) => call.includes(ticketKey));
|
||||
expect(redis.values.get(ticketKey)).toBeUndefined();
|
||||
expect(ticketCalls).toEqual([
|
||||
`set:${ticketKey}:PX:60000`,
|
||||
`getdel:${ticketKey}`,
|
||||
`getdel:${ticketKey}`,
|
||||
]);
|
||||
expect(ticketCalls).not.toContain(`get:${ticketKey}`);
|
||||
expect(ticketCalls).not.toContain(`del:${ticketKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InternalSessionController', () => {
|
||||
let app: INestApplication;
|
||||
const credentialClient = {
|
||||
clear: jest.fn(),
|
||||
};
|
||||
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: NapcatWebuiCredentialClient,
|
||||
useValue: credentialClient,
|
||||
},
|
||||
{
|
||||
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: 'default_IORedisModuleConnectionToken',
|
||||
useValue: new FakeRedis(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
credentialClient.clear.mockReset();
|
||||
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({
|
||||
account: {
|
||||
accountId: 'account-1',
|
||||
selfId: '1914728559',
|
||||
},
|
||||
container: {
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
},
|
||||
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 all 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())
|
||||
.post('/internal/sessions/session-1/heartbeat')
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions/session-1/heartbeat')
|
||||
.set('x-kt-gateway-secret', 'wrong-secret')
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions/session-1/revoke')
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions/session-1/revoke')
|
||||
.set('x-kt-gateway-secret', 'wrong-secret')
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
await request(app.getHttpServer())
|
||||
.get('/internal/health')
|
||||
.expect(HttpStatus.OK);
|
||||
});
|
||||
|
||||
it('returns lifecycle HTTP errors for missing, revoked, and owner mismatch sessions', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions/missing-session/heartbeat')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.GONE);
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/internal/sessions')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send(createSessionInput())
|
||||
.expect(HttpStatus.CREATED);
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
await store.update(sessionId, {
|
||||
activeAt: 1000,
|
||||
lastSeenAt: 1000,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-2' })
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/revoke`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.CREATED);
|
||||
expect(credentialClient.clear).toHaveBeenCalledWith(sessionId);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.GONE);
|
||||
});
|
||||
|
||||
it('rejects blank lifecycle admin user ids with bad request status', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/internal/sessions')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send(createSessionInput())
|
||||
.expect(HttpStatus.CREATED);
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({})
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/revoke`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: ' ' })
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('rejects heartbeat for created sessions with gone status', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/internal/sessions')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send(createSessionInput())
|
||||
.expect(HttpStatus.CREATED);
|
||||
const sessionId = createResponse.body.sessionId;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send({ adminUserId: 'admin-1' })
|
||||
.expect(HttpStatus.GONE);
|
||||
expect(await store.find(sessionId)).toMatchObject({
|
||||
status: 'created',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid create-session payloads with bad request status', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send(createSessionInput({ adminUserId: ' ' }))
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
await request(app.getHttpServer())
|
||||
.post('/internal/sessions')
|
||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||
.send(createSessionInput({ upstreamBaseUrl: 'not-a-url' }))
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,988 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { HttpException, HttpStatus, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import * as request from 'supertest';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
QqbotNapcatWebuiGatewayService,
|
||||
NapcatWebuiGatewayAuditService,
|
||||
} from '../../../../src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service';
|
||||
import { QqbotNapcatWebuiGatewayController } from '../../../../src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller';
|
||||
import { QqbotNapcatWebuiGatewayClient } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client';
|
||||
import { NapcatWebuiGatewayAudit } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity';
|
||||
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import {
|
||||
NAPCAT_RUNTIME_DOMAIN_CONTRACT,
|
||||
NAPCAT_RUNTIME_ENTITIES,
|
||||
} from '../../../../src/modules/qqbot/napcat/infrastructure/persistence';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const repoRoot = resolve(__dirname, '../../../..');
|
||||
const requestMock = axios.request as jest.Mock;
|
||||
const EXPIRES_AT_FIXTURE = 1782268000000;
|
||||
const INTERNAL_SECRET_FIXTURE = ['internal', 'secret'].join('-');
|
||||
const WEBUI_PERMISSION = 'QqBot:Account:WebUI';
|
||||
const WEBUI_TOKEN_FIXTURE = ['webui', 'token', 'fixture'].join('-');
|
||||
|
||||
type TestAdminRole = {
|
||||
isDeleted?: boolean;
|
||||
menus?: Array<{
|
||||
authCode?: null | string;
|
||||
isDeleted?: boolean;
|
||||
status?: number;
|
||||
}>;
|
||||
roleCode: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
type TestAdminUser = {
|
||||
id: string;
|
||||
roles: TestAdminRole[];
|
||||
};
|
||||
|
||||
type MockRepository<T extends object> = {
|
||||
create: jest.Mock<T, [Partial<T>]>;
|
||||
rows: T[];
|
||||
save: jest.Mock<Promise<T>, [T]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a source or SQL file from the API repository root.
|
||||
* @param relativePath - Repository-relative file path.
|
||||
* @returns File text used by schema and contract assertions.
|
||||
*/
|
||||
const readSource = (relativePath: string) => {
|
||||
return readFileSync(resolve(repoRoot, relativePath), 'utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the minimal TypeORM repository mock needed by audit recording tests.
|
||||
* @returns Repository-like object that stores saved rows for assertions.
|
||||
*/
|
||||
const createRepository = <T extends object>() => {
|
||||
const rows: T[] = [];
|
||||
const repository: MockRepository<T> = {
|
||||
create: jest.fn((input: Partial<T>) => ({ ...input }) as T),
|
||||
rows,
|
||||
save: jest.fn(async (input: T) => {
|
||||
rows.push(input);
|
||||
return input;
|
||||
}),
|
||||
};
|
||||
|
||||
return repository;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a minimal Admin user shape consumed by `@CurrentAdminUser()`.
|
||||
* @param roles - Roles and menu auth codes to attach to the test user.
|
||||
* @returns Admin user-like object for controller tests.
|
||||
*/
|
||||
const createAdminUser = (roles: TestAdminRole[]): TestAdminUser => ({
|
||||
id: '3001',
|
||||
roles,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a reusable browser-safe session result for controller tests.
|
||||
* @returns Safe Gateway session payload returned by the mocked service.
|
||||
*/
|
||||
const createSafeSessionResponse = () => ({
|
||||
account: {
|
||||
id: '1001',
|
||||
name: '主机器人',
|
||||
selfId: '1914728559',
|
||||
},
|
||||
container: {
|
||||
webuiStatus: 'online',
|
||||
},
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl: '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1',
|
||||
sessionId: 'sess_1',
|
||||
});
|
||||
|
||||
/**
|
||||
* Extracts a readable message from Vben-style HTTP exceptions.
|
||||
* @param error - Error thrown by the service under test.
|
||||
* @returns Vben `msg` or regular error message.
|
||||
*/
|
||||
const getThrownMessage = (error: unknown) => {
|
||||
if (error instanceof HttpException) {
|
||||
const response = error.getResponse() as { msg?: string };
|
||||
return response.msg;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
};
|
||||
|
||||
describe('QqbotNapcatWebuiGatewayController', () => {
|
||||
let app: INestApplication;
|
||||
let currentAdminUser: TestAdminUser;
|
||||
const gatewayService = {
|
||||
createSession: jest.fn(),
|
||||
heartbeat: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [QqbotNapcatWebuiGatewayController],
|
||||
providers: [
|
||||
{
|
||||
provide: QqbotNapcatWebuiGatewayService,
|
||||
useValue: gatewayService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({
|
||||
/**
|
||||
* Supplies a request-scoped Admin user while bypassing JWT parsing.
|
||||
* @param context - Nest execution context for the current HTTP request.
|
||||
* @returns Always true so controller-level authorization can be tested.
|
||||
*/
|
||||
canActivate: (context) => {
|
||||
context.switchToHttp().getRequest().adminUser = currentAdminUser;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentAdminUser = createAdminUser([
|
||||
{
|
||||
menus: [{ authCode: WEBUI_PERMISSION, status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
]);
|
||||
gatewayService.createSession.mockResolvedValue(createSafeSessionResponse());
|
||||
gatewayService.heartbeat.mockResolvedValue({ ok: true, status: 'active' });
|
||||
gatewayService.revoke.mockResolvedValue({ ok: true, status: 'revoked' });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('denies non-super Admin users without WebUI permission', async () => {
|
||||
currentAdminUser = createAdminUser([
|
||||
{
|
||||
menus: [{ authCode: 'QqBot:Account:List', status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session')
|
||||
.send({ accountId: '1001' })
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
expect(gatewayService.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'deleted role',
|
||||
[
|
||||
{
|
||||
isDeleted: true,
|
||||
menus: [{ authCode: WEBUI_PERMISSION, status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'inactive role',
|
||||
[
|
||||
{
|
||||
menus: [{ authCode: WEBUI_PERMISSION, status: 1 }],
|
||||
roleCode: 'operator',
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'deleted menu',
|
||||
[
|
||||
{
|
||||
menus: [
|
||||
{
|
||||
authCode: WEBUI_PERMISSION,
|
||||
isDeleted: true,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'inactive menu',
|
||||
[
|
||||
{
|
||||
menus: [{ authCode: WEBUI_PERMISSION, status: 0 }],
|
||||
roleCode: 'operator',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
])('denies WebUI access for %s', async (_case, roles) => {
|
||||
currentAdminUser = createAdminUser(roles);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session')
|
||||
.send({ accountId: '1001' })
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
expect(gatewayService.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows Admin users with WebUI permission and passes create-session evidence', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session')
|
||||
.set('user-agent', 'controller-agent')
|
||||
.send({ accountId: '1001' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
expect(response.body.data).toEqual(createSafeSessionResponse());
|
||||
expect(gatewayService.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: expect.any(String),
|
||||
userAgent: 'controller-agent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows active super role as a WebUI permission bypass', async () => {
|
||||
currentAdminUser = createAdminUser([
|
||||
{
|
||||
menus: [],
|
||||
roleCode: 'super',
|
||||
status: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session')
|
||||
.send({ accountId: '1001' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
expect(gatewayService.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes heartbeat and revoke ownership evidence to the service', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session/sess_1/heartbeat')
|
||||
.set('user-agent', 'controller-agent')
|
||||
.expect(HttpStatus.OK);
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session/sess_1/revoke')
|
||||
.set('user-agent', 'controller-agent')
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
expect(gatewayService.heartbeat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminUserId: '3001',
|
||||
clientIp: expect.any(String),
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'controller-agent',
|
||||
}),
|
||||
);
|
||||
expect(gatewayService.revoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminUserId: '3001',
|
||||
clientIp: expect.any(String),
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'controller-agent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed account and session identifiers before service calls', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session')
|
||||
.send({ accountId: ' ' })
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
await request(app.getHttpServer())
|
||||
.post('/qqbot/napcat/webui/session/bad%20session/heartbeat')
|
||||
.expect(HttpStatus.BAD_REQUEST);
|
||||
|
||||
expect(gatewayService.createSession).not.toHaveBeenCalled();
|
||||
expect(gatewayService.heartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QqbotNapcatWebuiGatewayService', () => {
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset();
|
||||
});
|
||||
|
||||
it('creates a browser-safe Gateway session and records sanitized audit', async () => {
|
||||
const account = {
|
||||
id: '1001',
|
||||
name: '主机器人',
|
||||
selfId: '1914728559',
|
||||
};
|
||||
const runtime = {
|
||||
baseUrl: 'http://172.18.0.23:6099',
|
||||
id: '2001',
|
||||
name: 'kt-qqbot-napcat-1914728559',
|
||||
sourceContainerOnline: true,
|
||||
webuiPort: 6099,
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
};
|
||||
const gatewayResult = {
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1',
|
||||
sessionId: 'sess_1',
|
||||
};
|
||||
const accountService = {
|
||||
findById: jest.fn(async () => account),
|
||||
};
|
||||
const containerService = {
|
||||
findPrimaryContainerByAccountId: jest.fn(async () => runtime),
|
||||
};
|
||||
const client = {
|
||||
createSession: jest.fn(async () => gatewayResult),
|
||||
};
|
||||
const auditRepository = createRepository<NapcatWebuiGatewayAudit>();
|
||||
const audit = new NapcatWebuiGatewayAuditService(
|
||||
auditRepository as never,
|
||||
);
|
||||
const service = new QqbotNapcatWebuiGatewayService(
|
||||
accountService as never,
|
||||
containerService as never,
|
||||
client as unknown as QqbotNapcatWebuiGatewayClient,
|
||||
audit,
|
||||
);
|
||||
|
||||
const response = await service.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
userAgent: 'jest-agent',
|
||||
});
|
||||
const serialized = JSON.stringify(response);
|
||||
const savedAudit = auditRepository.rows[0];
|
||||
const serializedAudit = JSON.stringify(savedAudit);
|
||||
|
||||
expect(response).toEqual({
|
||||
account: {
|
||||
id: '1001',
|
||||
name: '主机器人',
|
||||
selfId: '1914728559',
|
||||
},
|
||||
container: {
|
||||
webuiStatus: 'online',
|
||||
},
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1',
|
||||
sessionId: 'sess_1',
|
||||
});
|
||||
expect(client.createSession).toHaveBeenCalledWith({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
});
|
||||
expect(serialized).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||
expect(serialized).not.toContain('Credential');
|
||||
expect(serialized).not.toContain('2001');
|
||||
expect(serialized).not.toContain('kt-qqbot-napcat-1914728559');
|
||||
expect(serialized).not.toContain('172.18.0.23');
|
||||
expect(serialized).not.toContain('6099');
|
||||
expect(serialized).not.toContain(INTERNAL_SECRET_FIXTURE);
|
||||
|
||||
expect(auditRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
eventType: 'session.create',
|
||||
selfId: '1914728559',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
);
|
||||
expect(savedAudit.detailJson).toEqual({
|
||||
accountName: '主机器人',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
webuiStatus: 'online',
|
||||
});
|
||||
expect(serializedAudit).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||
expect(serializedAudit).not.toContain('Credential');
|
||||
expect(serializedAudit).not.toContain('bootstrap-ticket-1');
|
||||
expect(serializedAudit).not.toContain('ticket');
|
||||
expect(serializedAudit).not.toContain('172.18.0.23');
|
||||
expect(serializedAudit).not.toContain('6099');
|
||||
expect(serializedAudit).not.toContain(INTERNAL_SECRET_FIXTURE);
|
||||
});
|
||||
|
||||
it('rejects an offline WebUI target before calling Gateway', async () => {
|
||||
const accountService = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: '1001',
|
||||
name: '主机器人',
|
||||
selfId: '1914728559',
|
||||
})),
|
||||
};
|
||||
const containerService = {
|
||||
findPrimaryContainerByAccountId: jest.fn(async () => ({
|
||||
baseUrl: 'http://172.18.0.23:6099',
|
||||
id: '2001',
|
||||
name: 'kt-qqbot-napcat-1914728559',
|
||||
sourceContainerOnline: false,
|
||||
webuiPort: 6099,
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
})),
|
||||
};
|
||||
const client = {
|
||||
createSession: jest.fn(),
|
||||
};
|
||||
const auditRepository = createRepository<NapcatWebuiGatewayAudit>();
|
||||
const service = new QqbotNapcatWebuiGatewayService(
|
||||
accountService as never,
|
||||
containerService as never,
|
||||
client as unknown as QqbotNapcatWebuiGatewayClient,
|
||||
new NapcatWebuiGatewayAuditService(auditRepository as never),
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await service.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(getThrownMessage(thrown)).toBe('NapCat WebUI 不在线');
|
||||
expect(client.createSession).not.toHaveBeenCalled();
|
||||
expect(auditRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed account and session ids before downstream calls', async () => {
|
||||
const accountService = {
|
||||
findById: jest.fn(),
|
||||
};
|
||||
const containerService = {
|
||||
findPrimaryContainerByAccountId: jest.fn(),
|
||||
};
|
||||
const client = {
|
||||
heartbeat: jest.fn(),
|
||||
};
|
||||
const service = new QqbotNapcatWebuiGatewayService(
|
||||
accountService as never,
|
||||
containerService as never,
|
||||
client as unknown as QqbotNapcatWebuiGatewayClient,
|
||||
new NapcatWebuiGatewayAuditService(
|
||||
createRepository<NapcatWebuiGatewayAudit>() as never,
|
||||
),
|
||||
);
|
||||
let createThrown: unknown;
|
||||
let heartbeatThrown: unknown;
|
||||
|
||||
try {
|
||||
await service.createSession({
|
||||
accountId: 'not-a-snowflake',
|
||||
adminUserId: '3001',
|
||||
});
|
||||
} catch (error) {
|
||||
createThrown = error;
|
||||
}
|
||||
try {
|
||||
await service.heartbeat({
|
||||
adminUserId: '3001',
|
||||
sessionId: 'bad session',
|
||||
});
|
||||
} catch (error) {
|
||||
heartbeatThrown = error;
|
||||
}
|
||||
|
||||
expect(getThrownMessage(createThrown)).toBe('QQBot 账号ID不合法');
|
||||
expect(getThrownMessage(heartbeatThrown)).toBe('Gateway 会话ID不合法');
|
||||
expect(accountService.findById).not.toHaveBeenCalled();
|
||||
expect(client.heartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards heartbeat and revoke ownership evidence to the Gateway client', async () => {
|
||||
const client = {
|
||||
heartbeat: jest.fn(async () => ({ ok: true, status: 'active' })),
|
||||
revoke: jest.fn(async () => ({ ok: true, status: 'revoked' })),
|
||||
};
|
||||
const service = new QqbotNapcatWebuiGatewayService(
|
||||
{ findById: jest.fn() } as never,
|
||||
{ findPrimaryContainerByAccountId: jest.fn() } as never,
|
||||
client as unknown as QqbotNapcatWebuiGatewayClient,
|
||||
new NapcatWebuiGatewayAuditService(
|
||||
createRepository<NapcatWebuiGatewayAudit>() as never,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.heartbeat({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 'active',
|
||||
});
|
||||
await expect(
|
||||
service.revoke({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 'revoked',
|
||||
});
|
||||
expect(client.heartbeat).toHaveBeenCalledWith({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
});
|
||||
expect(client.revoke).toHaveBeenCalledWith({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts audit key variants and unsafe string values', async () => {
|
||||
const auditRepository = createRepository<NapcatWebuiGatewayAudit>();
|
||||
const audit = new NapcatWebuiGatewayAuditService(
|
||||
auditRepository as never,
|
||||
);
|
||||
|
||||
await audit.record({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
detailJson: {
|
||||
['access' + 'Token']: 'plain-secret',
|
||||
apiToken: 'plain-secret',
|
||||
authorizationHeader: 'Basic abc',
|
||||
cookie: 'sid=abc',
|
||||
credentialHeader: 'Credential abc',
|
||||
docker_ip: '172.18.0.23',
|
||||
hostPort: '6099',
|
||||
nasRoute: 'nas.kwitsukasa.top:2202',
|
||||
nested: {
|
||||
display: 'visible',
|
||||
loginMessage: 'Bearer abc',
|
||||
refreshToken: 'plain-secret',
|
||||
redirectPath: '/napcat-webui/session/sess_1/bootstrap?ticket=abc',
|
||||
},
|
||||
refreshToken: 'plain-secret',
|
||||
safe: 'visible',
|
||||
setCookie: 'sid=abc; HttpOnly',
|
||||
unsafeList: [
|
||||
'plain text',
|
||||
'token=abc',
|
||||
'http://127.0.0.1:48086/internal/sessions',
|
||||
],
|
||||
upstreamUrl: 'http://172.18.0.23:6099',
|
||||
webui_token: WEBUI_TOKEN_FIXTURE,
|
||||
},
|
||||
eventType: 'session.create',
|
||||
selfId: '1914728559',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
});
|
||||
|
||||
const savedAudit = auditRepository.rows[0];
|
||||
|
||||
expect(savedAudit.detailJson).toEqual({
|
||||
nested: {
|
||||
display: 'visible',
|
||||
loginMessage: '[REDACTED]',
|
||||
redirectPath: '[REDACTED]',
|
||||
},
|
||||
safe: 'visible',
|
||||
unsafeList: ['plain text', '[REDACTED]', '[REDACTED]'],
|
||||
});
|
||||
expect(JSON.stringify(savedAudit.detailJson)).not.toContain(
|
||||
WEBUI_TOKEN_FIXTURE,
|
||||
);
|
||||
expect(JSON.stringify(savedAudit.detailJson)).not.toContain('Credential');
|
||||
expect(JSON.stringify(savedAudit.detailJson)).not.toContain('172.18.0.23');
|
||||
expect(JSON.stringify(savedAudit.detailJson)).not.toContain('6099');
|
||||
expect(JSON.stringify(savedAudit.detailJson)).not.toContain('ticket');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QqbotNapcatWebuiGatewayClient', () => {
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset();
|
||||
});
|
||||
|
||||
it('posts internal session requests with the secret header and unwraps Gateway data', async () => {
|
||||
requestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
},
|
||||
});
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
return {
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086',
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE,
|
||||
NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234',
|
||||
}[key];
|
||||
}),
|
||||
};
|
||||
const client = new QqbotNapcatWebuiGatewayClient(configService as never);
|
||||
|
||||
const result = await client.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
data: {
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
},
|
||||
headers: {
|
||||
'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE,
|
||||
},
|
||||
method: 'POST',
|
||||
timeout: 1234,
|
||||
url: 'http://127.0.0.1:48086/internal/sessions',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1',
|
||||
sessionId: 'sess_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the internal Gateway secret before calling axios', async () => {
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
return {
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086',
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: ' ',
|
||||
NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234',
|
||||
}[key];
|
||||
}),
|
||||
};
|
||||
const client = new QqbotNapcatWebuiGatewayClient(configService as never);
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await client.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
expect(getThrownMessage(thrown)).toBe(
|
||||
'NapCat WebUI Gateway 内部密钥未配置',
|
||||
);
|
||||
expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||
expect(JSON.stringify(thrown)).not.toContain('172.18.0.23');
|
||||
expect(JSON.stringify(thrown)).not.toContain('6099');
|
||||
});
|
||||
|
||||
it('posts heartbeat and revoke requests with Admin ownership evidence', async () => {
|
||||
requestMock.mockResolvedValueOnce({
|
||||
data: { ok: true, status: 'active' },
|
||||
});
|
||||
requestMock.mockResolvedValueOnce({
|
||||
data: { ok: true, status: 'revoked' },
|
||||
});
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
return {
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086',
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE,
|
||||
NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234',
|
||||
}[key];
|
||||
}),
|
||||
};
|
||||
const client = new QqbotNapcatWebuiGatewayClient(configService as never);
|
||||
|
||||
await expect(
|
||||
client.heartbeat({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, status: 'active' });
|
||||
await expect(
|
||||
client.revoke({
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
sessionId: 'sess_1',
|
||||
userAgent: 'jest-agent',
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, status: 'revoked' });
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
data: {
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
userAgent: 'jest-agent',
|
||||
},
|
||||
headers: {
|
||||
'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE,
|
||||
},
|
||||
method: 'POST',
|
||||
timeout: 1234,
|
||||
url: 'http://127.0.0.1:48086/internal/sessions/sess_1/heartbeat',
|
||||
});
|
||||
expect(requestMock).toHaveBeenNthCalledWith(2, {
|
||||
data: {
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
userAgent: 'jest-agent',
|
||||
},
|
||||
headers: {
|
||||
'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE,
|
||||
},
|
||||
method: 'POST',
|
||||
timeout: 1234,
|
||||
url: 'http://127.0.0.1:48086/internal/sessions/sess_1/revoke',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'non-number expiresAt',
|
||||
{
|
||||
expiresAt: '1782268000000',
|
||||
iframeUrl: '/napcat-webui/session/sess_1/',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'unsafe absolute iframeUrl',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'http://172.18.0.23:6099/napcat-webui/session/sess_1/bootstrap?ticket=abc',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'ticket outside bootstrap route',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl: '/napcat-webui/session/sess_1/ticket/abc',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'multiple query separators',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=abc?token=secret',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'duplicate ticket query',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=abc&ticket=def',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'encoded secret query evidence',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl:
|
||||
'/napcat-webui/session/sess_1/bootstrap?ticket=abc%3Ftoken%3Dsecret',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'non-bootstrap query',
|
||||
{
|
||||
expiresAt: EXPIRES_AT_FIXTURE,
|
||||
iframeUrl: '/napcat-webui/session/sess_1/?ticket=abc',
|
||||
sessionId: 'sess_1',
|
||||
},
|
||||
],
|
||||
])('rejects invalid Gateway create-session result: %s', async (_case, body) => {
|
||||
requestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: body,
|
||||
},
|
||||
});
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
return {
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086',
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE,
|
||||
NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234',
|
||||
}[key];
|
||||
}),
|
||||
};
|
||||
const client = new QqbotNapcatWebuiGatewayClient(configService as never);
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await client.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(getThrownMessage(thrown)).toBe(
|
||||
'NapCat WebUI Gateway 返回无效会话',
|
||||
);
|
||||
expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||
expect(JSON.stringify(thrown)).not.toContain(INTERNAL_SECRET_FIXTURE);
|
||||
expect(JSON.stringify(thrown)).not.toContain('172.18.0.23');
|
||||
expect(JSON.stringify(thrown)).not.toContain('6099');
|
||||
});
|
||||
|
||||
it('sanitizes Gateway client errors before returning them to Admin', async () => {
|
||||
requestMock.mockRejectedValue(
|
||||
new Error(
|
||||
`connect http://172.18.0.23:6099 token=${WEBUI_TOKEN_FIXTURE} secret=${INTERNAL_SECRET_FIXTURE} Credential=abc`,
|
||||
),
|
||||
);
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
return {
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086',
|
||||
NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE,
|
||||
NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234',
|
||||
}[key];
|
||||
}),
|
||||
};
|
||||
const client = new QqbotNapcatWebuiGatewayClient(configService as never);
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await client.createSession({
|
||||
accountId: '1001',
|
||||
adminUserId: '3001',
|
||||
clientIp: '127.0.0.1',
|
||||
containerId: '2001',
|
||||
containerName: 'kt-qqbot-napcat-1914728559',
|
||||
selfId: '1914728559',
|
||||
upstreamBaseUrl: 'http://172.18.0.23:6099',
|
||||
userAgent: 'jest-agent',
|
||||
webuiToken: WEBUI_TOKEN_FIXTURE,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
const message = getThrownMessage(thrown) || '';
|
||||
|
||||
expect(message).toBe('NapCat WebUI Gateway 请求失败');
|
||||
expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||
expect(JSON.stringify(thrown)).not.toContain(INTERNAL_SECRET_FIXTURE);
|
||||
expect(JSON.stringify(thrown)).not.toContain('172.18.0.23');
|
||||
expect(JSON.stringify(thrown)).not.toContain('6099');
|
||||
expect(JSON.stringify(thrown)).not.toContain('Credential');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NapCat WebUI Gateway audit schema contract', () => {
|
||||
it('keeps entity, domain contract, and SQL schema aligned', () => {
|
||||
const refactorSchema = readSource('sql/refactor-v3/00-full-schema.sql');
|
||||
const qqbotInitSql = readSource('sql/qqbot-init.sql');
|
||||
const tableName = getMetadataArgsStorage().tables.find(
|
||||
(table) => table.target === NapcatWebuiGatewayAudit,
|
||||
)?.name;
|
||||
|
||||
expect(tableName).toBe('qqbot_napcat_webui_gateway_audit');
|
||||
expect(NAPCAT_RUNTIME_ENTITIES).toEqual(
|
||||
expect.arrayContaining([NapcatWebuiGatewayAudit]),
|
||||
);
|
||||
expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual(
|
||||
expect.arrayContaining(['qqbot_napcat_webui_gateway_audit']),
|
||||
);
|
||||
expect(refactorSchema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS qqbot_napcat_webui_gateway_audit',
|
||||
);
|
||||
expect(qqbotInitSql).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `qqbot_napcat_webui_gateway_audit`',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '../../../..');
|
||||
|
||||
/**
|
||||
* 读取仓库根目录下的部署配置文件。
|
||||
* @param relativePath - 仓库根目录相对路径。
|
||||
* @returns 文件文本内容。
|
||||
*/
|
||||
function readRepoFile(relativePath: string): string {
|
||||
return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认仓库根目录下的部署配置文件存在。
|
||||
* @param relativePath - 仓库根目录相对路径。
|
||||
* @returns 绝对文件路径。
|
||||
*/
|
||||
function expectRepoFile(relativePath: string): string {
|
||||
const filePath = resolve(REPO_ROOT, relativePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe('NapCat WebUI Gateway deployment configuration', () => {
|
||||
it('packages the standalone gateway app in a dedicated runtime image', () => {
|
||||
expectRepoFile('dockerfile.gateway');
|
||||
|
||||
const dockerfile = readRepoFile('dockerfile.gateway');
|
||||
|
||||
expect(dockerfile).toContain('dist/apps/napcat-webui-gateway/main');
|
||||
expect(dockerfile).toContain('EXPOSE 48086');
|
||||
expect(dockerfile).toContain('LOG_APP_NAME=kt-napcat-webui-gateway');
|
||||
});
|
||||
|
||||
it('declares gateway API linkage and cluster runtime without literal secrets', () => {
|
||||
const manifest = readRepoFile('k8s/prod/api.yaml');
|
||||
|
||||
expect(manifest).toContain('kt-napcat-webui-gateway');
|
||||
expect(manifest).toContain('containerPort: 48086');
|
||||
expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_REDIS_HOST');
|
||||
expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET');
|
||||
expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL');
|
||||
expect(manifest).toContain('NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL');
|
||||
expect(manifest).toMatch(
|
||||
/name:\s*NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET[\s\S]*?valueFrom:[\s\S]*?secretKeyRef:/,
|
||||
);
|
||||
expect(manifest).not.toMatch(
|
||||
/name:\s*NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET[\s\S]*?value:\s*["']?[^"'\s]+/,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds, pushes and rolls out the gateway image through Jenkins', () => {
|
||||
const jenkinsfile = readRepoFile('Jenkinsfile');
|
||||
|
||||
expect(jenkinsfile).toContain('GATEWAY_IMAGE_NAME');
|
||||
expect(jenkinsfile).toContain('dockerfile.gateway');
|
||||
expect(jenkinsfile).toContain('kt-napcat-webui-gateway');
|
||||
expect(jenkinsfile).toContain('GATEWAY_DOCKER_IMAGE');
|
||||
expect(jenkinsfile).toMatch(/docker push \$\{env\.GATEWAY_DOCKER_IMAGE\}/);
|
||||
expect(jenkinsfile).toMatch(
|
||||
/rollout status [\s\S]*deployment\/kt-napcat-webui-gateway/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const repoRoot = resolve(__dirname, '../../../..');
|
||||
|
||||
/**
|
||||
* 读取仓库内契约文件内容。
|
||||
* @param path - 相对仓库根目录的文件路径。
|
||||
* @returns UTF-8 文本内容,用于结构契约断言。
|
||||
*/
|
||||
const readSource = (path: string) => {
|
||||
return readFileSync(resolve(repoRoot, path), 'utf8');
|
||||
};
|
||||
|
||||
describe('NapCat WebUI Gateway contract seeds', () => {
|
||||
it('registers a dedicated Admin permission for full NapCat WebUI access', () => {
|
||||
const coreSeed = readSource('sql/refactor-v3/01-seed-core.sql');
|
||||
const qqbotSeed = readSource('sql/qqbot-init.sql');
|
||||
|
||||
expect(coreSeed).toContain('QqBot:Account:WebUI');
|
||||
expect(coreSeed).toContain('QqBotAccountNapcatWebui');
|
||||
expect(qqbotSeed).toContain('QqBot:Account:WebUI');
|
||||
expect(qqbotSeed).toContain('QqBotAccountNapcatWebui');
|
||||
});
|
||||
|
||||
it('verifies the gateway audit table during full schema checks', () => {
|
||||
const verifySql = readSource('sql/refactor-v3/99-verify.sql');
|
||||
|
||||
expect(verifySql).toContain('qqbot_napcat_webui_gateway_audit');
|
||||
expect(verifySql).toContain('QqBot:Account:WebUI');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user