Compare commits

...

21 Commits

Author SHA1 Message Date
a8a1c3503f fix: 加固NapCat WebUI网关代理边界 2026-06-24 16:30:12 +08:00
eb162ddba6 feat: 增加 NapCat WebUI Gateway 部署配置 2026-06-24 15:13:02 +08:00
d0fbc13763 feat: 实现NapCat WebUI Gateway代理 2026-06-24 15:04:00 +08:00
52d96294ed fix: 补全NapCat WebUI会话展示元数据 2026-06-24 14:23:51 +08:00
f16dd0050c fix: 拒绝NapCat WebUI缺失索引会话更新 2026-06-24 14:17:57 +08:00
0375fd4661 fix: 禁止NapCat WebUI心跳激活未引导会话 2026-06-24 14:10:40 +08:00
c9cc0d5e3a fix: 收紧NapCat WebUI代理会话边界 2026-06-24 14:01:00 +08:00
43b2c4c32e fix: 防止NapCat WebUI旧会话抢占索引 2026-06-24 13:46:02 +08:00
d174d9c9c2 fix: 加固NapCat WebUI Gateway会话一致性 2026-06-24 13:31:34 +08:00
87b0368b9a test: 覆盖NapCat WebUI会话密钥校验 2026-06-24 13:17:59 +08:00
8ee7866afe fix: 原子化NapCat WebUI票据兑换 2026-06-24 13:11:17 +08:00
6f23db64be feat: 增加NapCat WebUI Gateway会话服务 2026-06-24 13:04:35 +08:00
2b1c8e9b3e fix: 收紧NapCat WebUI会话脱敏校验 2026-06-24 12:43:54 +08:00
99ecb4a35e fix: 加固NapCat WebUI会话安全 2026-06-24 12:24:01 +08:00
156ba8147e fix: 对齐NapCat WebUI会话契约 2026-06-24 11:55:50 +08:00
06eac5a01d feat: 增加NapCat WebUI会话接口 2026-06-24 11:39:03 +08:00
b9ef48e5dd feat: 增加NapCat WebUI权限契约 2026-06-24 11:12:54 +08:00
71b37e6ffb docs: 修正NapCat WebUI权限种子ID 2026-06-24 11:07:42 +08:00
788761698f docs: 补充NapCat WebUI Gateway成熟库方案 2026-06-24 10:57:45 +08:00
571db8ecea docs: 编写NapCat WebUI Gateway实施计划 2026-06-24 10:38:22 +08:00
cdded5b754 docs: 设计NapCat WebUI Gateway 2026-06-24 10:05:58 +08:00
39 changed files with 8828 additions and 12 deletions

8
API.md
View File

@ -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
View File

@ -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_NUMBERPR 使用源分支名')
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
}
}
}

View File

@ -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/MACNapCat/OneBot 配置 hashKT `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
View 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"]

View File

@ -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 创建 sessionmounted 期间 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` wrapperAdmin 已有该依赖:<https://vueuse.org/shared/useintervalfn/>
## 范围检查
这是一条完整可交付链路,虽然跨 API、Gateway、Admin 和部署但不能拆成互不依赖的计划。Admin 页面没有 API session 不能加载API session 没有 Gateway 不能 smokeGateway 没有 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
```
期望:测试 PASStypecheck 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
```
期望:测试 PASStypecheck 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
```
期望:测试 PASStypecheck 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
```
期望:测试 PASStypecheck PASSbuild 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 sessioniframe 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()` 创建 sessionready 后 `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
```
期望:测试 PASStypecheck 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"'
```
期望:两个仓库都是 GitAPI 使用 pnpm 9.15.9Admin 使用 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
```
期望:测试 PASStypecheck PASSbuild 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
```
期望:测试 PASStypecheck 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
```
期望:二级页面渲染,创建 sessioniframe 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 PASSglobal-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/ReadyrestartCount 0Gateway 镜像 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 执行,按批次检查。
你选哪个?

View File

@ -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.

View File

@ -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 URLGateway 兑换一次性 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 WebUIsession failed展示脱敏错误并写审计。
- session 过期或撤销iframe 收到 410Admin 页面提供重新打开按钮。
- 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 证据齐全后才算完成。

View File

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

View File

@ -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",

View File

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

View File

@ -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),

View File

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

View File

@ -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),

View File

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

View File

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

View File

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

View File

@ -0,0 +1,62 @@
export type NapcatWebuiGatewaySessionStatus =
| 'active'
| 'created'
| 'expired'
| 'failed'
| 'revoked';
export interface NapcatWebuiGatewaySession {
accountId: string;
activeAt?: number;
adminUserId: string;
clientIp?: string;
containerId: string;
containerName: string;
createdAt: number;
expiresAt: number;
lastSeenAt?: number;
revokedAt?: number;
selfId: string;
sessionId: string;
status: NapcatWebuiGatewaySessionStatus;
upstreamBaseUrl: string;
userAgent?: string;
webuiToken: string;
}
export interface NapcatWebuiGatewaySessionStore {
create(
session: NapcatWebuiGatewaySession,
): Promise<NapcatWebuiGatewaySession>;
find(sessionId: string): Promise<NapcatWebuiGatewaySession | undefined>;
findActiveByUserAndAccount(
adminUserId: string,
accountId: string,
): Promise<NapcatWebuiGatewaySession | undefined>;
update(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
): Promise<NapcatWebuiGatewaySession>;
}
export type NapcatWebuiGatewayCreateSessionInput = {
accountId: string;
adminUserId: string;
clientIp?: string;
containerId: string;
containerName: string;
selfId: string;
upstreamBaseUrl: string;
userAgent?: string;
webuiToken: string;
};
export type NapcatWebuiGatewayLifecycleInput = {
adminUserId: string;
clientIp?: string;
sessionId: string;
userAgent?: string;
};
export const NAPCAT_WEBUI_GATEWAY_SESSION_STORE =
'NAPCAT_WEBUI_GATEWAY_SESSION_STORE';

View File

@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -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 IDNapCat

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
});
});

View 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);
});
});

View File

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

View File

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

View File

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