diff --git a/.env.example b/.env.example index 049db21..1d3caae 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ CORS_ORIGIN="http://localhost:3001,http://localhost:4002,https://localhost:4443" DATABASE_URL="postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public" SESSION_SECRET="change-me-in-production" SESSION_COOKIE_SECURE="false" +TRUST_PROXY="false" FILE_STORAGE_DIR="./uploads" RUN_DB_MIGRATIONS="true" RUN_DB_SEED="true" diff --git a/README.md b/README.md index 87f78d1..144aba5 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ cp .env.example .env.local - `DATABASE_URL`:PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`。 - `SESSION_SECRET`:后端 Session Cookie 签名密钥,生产环境必须替换。 - `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`。 +- `TRUST_PROXY`:是否信任反向代理传入的 `X-Forwarded-*` 头。`# XXX` 公网 HTTPS 经过 Nginx Proxy Manager、frpc/frps 或其他反向代理转发时建议设为 `true`,否则 `SESSION_COOKIE_SECURE=true` 时登录 Cookie 可能无法正确写入。 - `FILE_STORAGE_DIR`:后端文件存储目录。Docker Compose 默认挂载到 `/app/uploads`。 - `VITE_API_PROXY_TARGET`:Vite 开发服务器的 `/api` 代理目标。直接运行 `npm run server:dev` 时用 `http://localhost:3100`;连接 Docker Compose API 时用 `http://localhost:3002`。 - `VITE_ENABLE_LOCAL_FALLBACK`:是否允许生产构建继续使用浏览器本地兼容回退。开发模式默认启用,生产默认关闭。 @@ -248,6 +249,84 @@ docker-compose down - `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。 完整 Docker 说明见 [docs/docker.md](./docs/docker.md)。 +## 公网反向代理部署 + + + +推荐公网链路: + +```text +浏览器 https://sstwbg.example.com + -> 公网服务器 Nginx Proxy Manager + -> frps/frpc 映射端口 + -> 本机 Docker web:4002 + -> 容器 Nginx /api 代理 + -> api:3100 +``` + +本机部署并确认 Docker 服务正常: + +```bash +# XXX 在部署机器上使用当前最新版分支。 +git fetch origin +git checkout surclaw-system-backendized-20260502 +git pull --ff-only origin surclaw-system-backendized-20260502 + +# XXX 公网 HTTPS 部署建议替换 Session 密钥,并信任外层反向代理。 +export SESSION_SECRET="替换为足够长的随机字符串" +export SESSION_COOKIE_SECURE="true" +export TRUST_PROXY="true" + +docker-compose up -d --build +curl http://127.0.0.1:4002/api/health +``` + +如果使用 frpc 把本机 `4002` 映射到公网服务器,示例配置如下,端口号按你的 frps 实际开放端口调整: + +```toml +# XXX frpc 示例:把本机 Docker 前端入口 4002 暴露到公网服务器的一个内网监听端口。 +[[proxies]] +name = "surclaw-web" +type = "tcp" +localIP = "127.0.0.1" +localPort = 4002 +remotePort = 4002 +``` + +Nginx Proxy Manager 中为 `sstwbg.example.com` 新建 Proxy Host: + +- `Domain Names`:`sstwbg.example.com` +- `Scheme`:`http` +- `Forward Hostname / IP`:frps 可访问到的映射地址,通常是 `127.0.0.1`、公网服务器内网 IP 或 frps 指定监听地址。 +- `Forward Port`:frpc 暴露出来的 `remotePort`,例如 `4002`。 +- `Websockets Support`:开启。`# XXX` 语音识别使用 `/api/speech/iat` WebSocket,必须透传 Upgrade。 +- `Block Common Exploits`:开启。 +- `SSL`:申请或绑定 `sstwbg.example.com` 证书,开启 `Force SSL`。`# XXX` 浏览器麦克风权限要求 HTTPS,普通公网 HTTP 下语音识别不可用。 + +Nginx Proxy Manager 的 `Advanced` 可加入: + +```nginx +# XXX 图文报告、关键帧和模板图片可能较大,公网代理请求体上限需与容器 Nginx/API 保持一致。 +client_max_body_size 100m; + +# XXX WebSocket 语音听写需要较长连接时间。 +proxy_read_timeout 3600s; +proxy_send_timeout 3600s; +``` + +公网验收顺序: + +```text +1. 打开 https://sstwbg.example.com/api/health,确认 API 健康检查可访问。 +2. 打开 https://sstwbg.example.com 登录 admin / 123456。 +3. 进入系统设置,确认 AI Provider 和讯飞语音配置有效。 +4. 进入报告编辑页,测试上传视频、自动抽帧、报告保存、AI 对话和语音听写。 +5. 浏览器控制台执行 window.isSecureContext,应返回 true;否则语音麦克风权限不会开放。 +``` + +如果登录失败或刷新后掉登录,优先检查 `SESSION_SECRET` 是否稳定、`SESSION_COOKIE_SECURE=true` 时是否同时设置了 `TRUST_PROXY=true`,以及外层 NPM 是否把 `X-Forwarded-Proto: https` 传给后端链路。 +如果只有语音识别失败,优先检查 NPM 的 `Websockets Support`、HTTPS 证书、浏览器麦克风权限、系统设置中的讯飞 APPID/APIKey/APISecret,以及后端容器是否能访问讯飞 `wss://iat-api.xfyun.cn/v2/iat`。 + ## 当前限制 - 前端登录、工作台统计、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入真实后端 Session/API/代理。 diff --git a/docker-compose.yaml b/docker-compose.yaml index 8811f72..d8b3199 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -28,8 +28,10 @@ services: API_BODY_LIMIT: 100mb CORS_ORIGIN: http://localhost:4002,https://localhost:4443,http://localhost:3001 DATABASE_URL: postgresql://surclaw:surclaw_dev_password@db:5432/surclaw?schema=public - SESSION_SECRET: change-me-in-production - SESSION_COOKIE_SECURE: "false" + # XXX Public HTTPS reverse-proxy deployments can override these from the shell or .env. + SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + TRUST_PROXY: ${TRUST_PROXY:-false} FILE_STORAGE_DIR: /app/uploads RUN_DB_MIGRATIONS: "true" RUN_DB_SEED: "true" diff --git a/docs/deployment.md b/docs/deployment.md index 9c41789..8765ed5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,6 +64,7 @@ AI 和语音密钥由后端 Settings API 保存并由代理使用,前端不再 - `DATABASE_URL`:PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`。 - `SESSION_SECRET`:Session Cookie 签名密钥。 - `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`,生产 HTTPS 应设为 `true`。 +- `TRUST_PROXY`:是否信任反向代理传入的 `X-Forwarded-*` 头。`# XXX` 公网 HTTPS 经过 Nginx Proxy Manager、frpc/frps 或其他反向代理转发时建议设为 `true`。 - `FILE_STORAGE_DIR`:后端文件目录。Docker Compose 默认 `/app/uploads`,并挂载到 `uploads_data` volume。 - `RUN_DB_MIGRATIONS`:Docker API 容器启动时是否执行 `prisma migrate deploy`,默认 `true`。 - `RUN_DB_SEED`:Docker API 容器启动时是否执行 `prisma db seed`,默认 `true`。 @@ -97,6 +98,38 @@ docker-compose up -d --build 更完整的 Docker 说明、生产变量、证书和备份恢复见 [Docker 化部署](./docker.md)。 +## 公网反向代理 + + + +推荐链路: + +```text +浏览器 https://sstwbg.example.com + -> 公网服务器 Nginx Proxy Manager + -> frps/frpc 映射端口 + -> 本机 Docker web:4002 + -> 容器 Nginx /api + -> api:3100 +``` + +公网部署建议变量: + +```bash +# XXX HTTPS 生产入口建议开启安全 Cookie,并让后端信任外层代理协议头。 +export SESSION_SECRET="替换为足够长的随机字符串" +export SESSION_COOKIE_SECURE="true" +export TRUST_PROXY="true" +docker-compose up -d --build +``` + +Nginx Proxy Manager 代理 `sstwbg.example.com` 时: + +- 代理目标指向 frpc 暴露的 `4002` 映射端口。 +- 开启 `Websockets Support`,否则 `/api/speech/iat` 语音 WebSocket 会失败。 +- 绑定 SSL 证书并开启 `Force SSL`,否则浏览器不会开放公网麦克风权限。 +- Advanced 中建议设置 `client_max_body_size 100m;`、`proxy_read_timeout 3600s;`、`proxy_send_timeout 3600s;`。 + ## 麦克风访问 浏览器不允许普通局域网 HTTP 页面调用麦克风,代码无法绕过这个限制。Docker 演示环境建议使用: diff --git a/docs/docker.md b/docs/docker.md index 92b975c..587e222 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -110,8 +110,34 @@ https://localhost:4443 - 使用医院内网域名。 - 通过可信 CA 或内网 CA 签发证书。 - 把 `SESSION_COOKIE_SECURE` 设为 `true`。 +- 把 `TRUST_PROXY` 设为 `true`。`# XXX` 如果 HTTPS 在 Nginx Proxy Manager、frpc/frps 等外层代理终止,后端需要信任 `X-Forwarded-Proto` 才能正确写入安全 Cookie。 - 把 `CORS_ORIGIN` 改成真实前端 HTTPS 来源。 +## 公网域名反向代理 + + + +如果使用 `本机 Docker 4002 -> frpc -> 公网服务器 Nginx Proxy Manager -> sstwbg.example.com`,推荐流程: + +```bash +# XXX 使用公网 HTTPS 入口时,compose 变量可从 shell 或 .env 覆盖。 +export SESSION_SECRET="替换为足够长的随机字符串" +export SESSION_COOKIE_SECURE="true" +export TRUST_PROXY="true" +docker-compose up -d --build +``` + +Nginx Proxy Manager 配置要点: + +- `Domain Names` 使用 `sstwbg.example.com`。 +- `Forward Hostname / IP` 指向 frpc 在公网服务器暴露的地址。 +- `Forward Port` 填 frpc 暴露的端口,例如 `4002`。 +- 开启 `Websockets Support`,保证 `/api/speech/iat` 语音听写 WebSocket 可升级。 +- SSL 页签绑定证书并开启 `Force SSL`,保证浏览器开放麦克风权限。 +- Advanced 中建议设置 `client_max_body_size 100m;`、`proxy_read_timeout 3600s;`、`proxy_send_timeout 3600s;`。 + +公网验收先访问 `https://sstwbg.example.com/api/health`,再登录并测试报告保存、视频抽帧、AI 对话和语音听写。 + ## 生产部署前必须修改 `docker-compose.yaml` 当前适合演示和院内试运行,生产前至少修改: @@ -121,6 +147,7 @@ https://localhost:4443 - `SESSION_SECRET`:替换为高强度随机值。 - `CORS_ORIGIN`:只保留真实前端来源。 - `SESSION_COOKIE_SECURE`:HTTPS 部署时设为 `true`。 +- `TRUST_PROXY`:经反向代理提供 HTTPS 时设为 `true`。 - `RUN_DB_SEED`:不需要每次启动 seed 时可设为 `false`。 - AI/讯飞演示凭据:通过系统设置替换为正式凭据;已经暴露过的密钥应轮换。 - HTTPS 证书:替换自签名本机证书。 diff --git a/nginx.conf b/nginx.conf index be96f48..2d34635 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,6 +3,12 @@ map $http_upgrade $connection_upgrade { '' close; } +# XXX Preserve the public HTTPS scheme when an outer reverse proxy terminates TLS. +map $http_x_forwarded_proto $proxy_x_forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + server { listen 80; server_name localhost; @@ -25,7 +31,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { @@ -62,7 +68,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { diff --git a/server/src/main.ts b/server/src/main.ts index 46bed37..9636f9c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -15,6 +15,16 @@ const bootstrap = async () => { const app = await NestFactory.create(AppModule, { bodyParser: false }); const port = Number(process.env.API_PORT ?? 3100); const bodyLimit = process.env.API_BODY_LIMIT ?? '100mb'; + const trustProxy = process.env.TRUST_PROXY; + + if (trustProxy && trustProxy !== 'false') { + // # XXX Public reverse-proxy deployments need Express to trust X-Forwarded-* headers. + const numericTrustProxy = Number(trustProxy); + app + .getHttpAdapter() + .getInstance() + .set('trust proxy', trustProxy === 'true' ? 1 : Number.isNaN(numericTrustProxy) ? trustProxy : numericTrustProxy); + } app.setGlobalPrefix('api'); app.use(json({ limit: bodyLimit }));