From 71b45852bf85ed121c4cb4faee7abe03e50f6308 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Wed, 4 Mar 2026 17:35:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 10 ++ Docs/BACKEND_README.md | 23 +++-- Docs/DEPLOY_MANUAL.md | 33 ++++--- Docs/DevLogs/Day32.md | 95 ++++++++++++++++++- Docs/FRONTEND_DEV.md | 5 + Docs/FRONTEND_README.md | 3 + Docs/task_complete.md | 7 +- README.md | 7 +- backend/.env.example | 2 +- backend/app/main.py | 14 +++ backend/app/modules/ai/router.py | 15 +-- .../app/modules/generated_audios/service.py | 48 +++++----- backend/app/modules/materials/router.py | 4 + backend/app/modules/materials/service.py | 8 ++ backend/app/modules/ref_audios/service.py | 2 + backend/app/modules/tools/router.py | 10 +- backend/app/modules/tools/service.py | 21 +++- backend/app/modules/videos/router.py | 5 + .../components/AccountSettingsDropdown.tsx | 2 +- .../src/features/home/ui/RefAudioPanel.tsx | 2 +- .../src/features/home/ui/RewriteModal.tsx | 2 +- .../home/ui/ScriptExtractionModal.tsx | 2 +- .../src/features/publish/ui/PublishPage.tsx | 2 +- 23 files changed, 250 insertions(+), 72 deletions(-) diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 2f8e6ea..8dae9a9 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -110,6 +110,8 @@ backend/ - 认证方式:**HttpOnly Cookie** (`access_token`)。 - `get_current_user` / `get_current_user_optional` 位于 `core/deps.py`。 - Session 单设备校验使用 `repositories/sessions.py`。 +- AI/Tools 等高成本接口必须强制鉴权(`Depends(get_current_user)`),禁止匿名调用消耗外部 API 配额。 +- 生产环境要求 `DEBUG=false` + 非默认 `JWT_SECRET_KEY`;默认密钥在生产模式下必须阻止服务启动。 --- @@ -127,6 +129,14 @@ backend/ - 需要重命名时使用 `move_file`,避免直接读写 Storage。 - `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。 - `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`。 +- 所有用户输入的文件路径/ID 必须做防御校验: + - `material_id` 拒绝 `..` 序列,避免路径穿越 + - `video_id` 等资源 ID 使用白名单(如 `^[A-Za-z0-9_-]+$`) +- 上传/下载链路必须有体积上限: + - 素材上传遵循 `MAX_UPLOAD_SIZE_MB` + - 参考音频上限 5MB + - 文案提取工具文件上传与 URL 下载结果均上限 500MB +- 面向前端的错误返回默认使用通用文案;内部堆栈只写服务端日志,避免泄露路径/实现细节。 ### Cookie 存储(用户隔离) diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 4d38063..f9ea36b 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -78,7 +78,7 @@ backend/ * `POST /api/materials`: 上传素材 * `GET /api/materials`: 获取素材列表 * `PUT /api/materials/{material_id}`: 重命名素材 - * `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint) + * `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint;服务端会拒绝 `..` 路径) 4. **社交发布 (Publish)** * `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书 @@ -104,8 +104,9 @@ backend/ * `POST /api/ref-audios/{id}/retranscribe`: 重新识别参考音频文字(Whisper 转写 + 超 10s 自动截取) 7. **AI 功能 (AI)** - * `POST /api/ai/generate-meta`: AI 生成标题和标签 - * `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言) + * `POST /api/ai/generate-meta`: AI 生成标题和标签(需登录) + * `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言,需登录) + * `POST /api/ai/rewrite`: AI 改写文案(需登录) 8. **预生成配音 (Generated Audios)** * `POST /api/generated-audios/generate`: 异步生成配音(返回 task_id) @@ -115,11 +116,11 @@ backend/ * `PUT /api/generated-audios/{audio_id}`: 重命名配音 9. **工具 (Tools)** - * `POST /api/tools/extract-script`: 从视频链接提取文案 + * `POST /api/tools/extract-script`: 从视频链接提取文案(需登录) 10. **健康检查** - * `GET /api/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值) - * `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态 + * `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值) + * `GET /api/videos/voiceclone/health`: CosyVoice 3.0 服务健康状态 11. **支付 (Payment)** * `POST /api/payment/create-order`: 创建支付宝电脑网站支付订单(需 payment_token) @@ -128,6 +129,16 @@ backend/ > 登录时若账号未激活或已过期,返回 403 + `payment_token`,前端跳转 `/pay` 页面完成付费。详见 [支付宝部署指南](ALIPAY_DEPLOY.md)。 +### 安全基线(生产环境) + +- `DEBUG` 必须设为 `false`:认证 Cookie 会带 `Secure`,仅在 HTTPS 下发送。 +- `JWT_SECRET_KEY` 必须是强随机值且不能使用默认值;当 `DEBUG=false` 且仍为默认值时,后端会在启动阶段直接拒绝启动。 +- 上传体积限制: + - `POST /api/materials`:受 `MAX_UPLOAD_SIZE_MB` 限制(默认 500MB) + - `POST /api/ref-audios`:5MB + - `POST /api/tools/extract-script`:文件上传与 URL 下载结果均限制 500MB +- `video_id` 在下载/删除接口使用白名单校验(`^[A-Za-z0-9_-]+$`),非法值直接返回 400。 + ### 统一响应结构 ```json diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index 9ff6f42..ba3934d 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -154,12 +154,12 @@ playwright install chromium --- -### 可选:AI 标题/标签生成 +### 可选:AI 标题/标签生成 > ✅ 如需启用“AI 标题/标签生成”功能,请确保后端可访问外网 API。 -- 需要可访问 `https://open.bigmodel.cn` -- API Key 配置在 `backend/app/services/glm_service.py`(建议替换为自己的密钥) +- 需要可访问 `https://open.bigmodel.cn` +- API Key 配置在 `backend/.env` 的 `GLM_API_KEY` --- @@ -214,10 +214,11 @@ cd /home/rongye/ProgramFiles/ViGent2/backend | `LATENTSYNC_USE_SERVER` | true | 设为 true 以启用常驻服务加速 | | `LATENTSYNC_INFERENCE_STEPS` | 30 | 推理步数 (16-50) | | `LATENTSYNC_GUIDANCE_SCALE` | 1.9 | 引导系数 (1.0-3.0) | -| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 | -| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) | -| `DEBUG` | true | 生产环境改为 false | -| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) | +| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 | +| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) | +| `DEBUG` | false | 生产环境必须为 false(仅开发环境可设 true) | +| `JWT_SECRET_KEY` | 强随机值 | 生产环境禁止默认值;默认值在 `DEBUG=false` 下会阻止后端启动 | +| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) | | `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) | | `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 | | `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) | @@ -258,7 +259,9 @@ cd /home/rongye/ProgramFiles/ViGent2/backend | `PAYMENT_AMOUNT` | `999.00` | 会员价格 (元) | | `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 | -> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。 +> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。 + +> 认证相关强约束:当 `DEBUG=false` 时,后端登录 Cookie 会带 `Secure`,前端必须通过 HTTPS 域名访问,HTTP 端口直连无法保持登录态。 --- @@ -316,11 +319,11 @@ cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk /home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py ``` -### 验证 - -1. 访问 http://服务器IP:3002 查看前端 -2. 访问 http://服务器IP:8006/docs 查看 API 文档 -3. 上传测试视频,生成口播视频 +### 验证 + +1. 访问 `https://你的前端域名` 查看前端(生产环境不要用 HTTP 端口直连) +2. 访问 `http://服务器IP:8006/docs` 查看 API 文档(仅内网/运维调试) +3. 上传测试视频,生成口播视频 --- @@ -540,8 +543,8 @@ server { GLM_API_KEY=your_zhipu_api_key ``` -3. **验证**: - 访问 `http://localhost:8006/docs`,测试 `/api/tools/extract-script` 接口。 +3. **验证**: + 访问 `http://localhost:8006/docs`,在已登录会话下测试 `/api/tools/extract-script`(该接口需认证)。 --- diff --git a/Docs/DevLogs/Day32.md b/Docs/DevLogs/Day32.md index 7c29de7..0fd8a52 100644 --- a/Docs/DevLogs/Day32.md +++ b/Docs/DevLogs/Day32.md @@ -1,11 +1,13 @@ -## 视频下载同源修复 + Day 日志拆分归档 (Day 32) +## 视频下载同源修复 + 安全漏洞第一批修复 (Day 32) ### 概述 -今天主要处理“下载行为不符合预期”的问题: +今天的工作聚焦四件事: 1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。 2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。 +3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。 +4. 统一弹窗关闭交互:默认支持点空白关闭,发布成功清理弹窗保持强制留存。 --- @@ -42,15 +44,87 @@ --- +## ✅ 3) 安全漏洞第一批修复(6 项,无功能风险) + +根据安全审计报告,实施第一批 6 项可直接修复的安全加固项。 + +### 3.1 JWT 默认密钥启动拦截 + +- **文件**:`backend/app/main.py` +- 新增 `check_jwt_secret` startup 事件(在 `init_admin` 之前) +- 当 `JWT_SECRET_KEY` 仍为默认值 `"your-secret-key-change-in-production"` 时: + - **生产环境**(`DEBUG=False`):`raise RuntimeError` 直接阻止服务启动 + - **开发环境**(`DEBUG=True`):输出 `CRITICAL` 级别日志告警,不阻止启动 + +### 3.2 AI / Tools 接口加认证 + +- **文件**:`backend/app/modules/ai/router.py`、`backend/app/modules/tools/router.py` +- AI 路由 3 个端点(`/translate`、`/generate-meta`、`/rewrite`)均增加 `Depends(get_current_user)` +- Tools 路由 1 个端点(`/extract-script`)增加 `Depends(get_current_user)` +- 前端 axios 已有 `withCredentials: true`,401 自动跳登录页,无需前端改动 + +### 3.3 素材路径穿越修复 + +- **文件**:`backend/app/modules/materials/router.py`、`backend/app/modules/materials/service.py` +- `stream`、`delete_material`、`rename_material` 三处在 `startswith(user_id)` 校验之前新增 `..` 拒绝 +- 含 `..` 的 `material_id` 直接返回 400 +- `delete_material` 路由补充 `except ValueError` → 400(原先仅 catch `PermissionError`,`ValueError` 会被 `Exception` 兜底返回 500) + +### 3.4 video_id 白名单校验 + +- **文件**:`backend/app/modules/videos/router.py` +- `download_generated` 和 `delete_generated` 两个端点在函数开头增加正则校验 +- 仅允许 `^[A-Za-z0-9_-]+$`,不符合直接返回 400 + +### 3.5 上传/下载大小限制 + +- **materials/service.py**(流式上传):在 chunk 累加后检查 `MAX_UPLOAD_SIZE_MB`(默认 500MB),超限抛 `ValueError` +- **ref_audios/service.py**(参考音频):`await file.read()` 后检查 5MB 上限 +- **tools/service.py**(文案提取文件上传):将 `shutil.copyfileobj` 替换为分块拷贝 + 500MB 限制 +- **tools/service.py**(URL 下载分支):`_download_video` 返回后检查文件体积,超 500MB 删除临时文件并拒绝 + +### 3.6 错误信息通用化 + +- **ai/router.py**:3 处 `detail=str(e)` 分别改为"翻译服务暂时不可用"、"生成标题标签失败"、"改写服务暂时不可用" +- **tools/router.py**:保留 "Fresh cookies" 特定分支提示,fallback 改为"文案提取失败,请稍后重试" +- **generated_audios/service.py**:任务失败 `error` 字段从 `traceback.format_exc()` 改为 `str(e)`,traceback 仅写入服务端日志 + +--- + +## ✅ 4) 弹窗关闭交互统一(UX) + +### 目标 + +- 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。 +- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。 + +### 调整内容 + +- 文案提取弹窗(`ScriptExtractionModal`)支持点击遮罩关闭。 +- AI 改写弹窗(`RewriteModal`)支持点击遮罩关闭。 +- 发布页扫码登录弹窗支持点击遮罩关闭。 +- 修改密码弹窗支持点击遮罩关闭。 +- 录音弹窗采用动态策略:`closeOnOverlay={!isRecording}` + - 未录音:允许遮罩关闭 + - 录音中:禁止遮罩关闭(防误触);`X` 关闭仍可用,且会先停止录音再关闭 +- 发布成功清理弹窗维持 `closeOnOverlay=false`,并且不提供 `onClose`(无右上角关闭按钮)。 + +--- + ## 📁 今日主要修改文件 | 文件 | 改动 | |------|------| -| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应 | +| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应;新增 `video_id` 白名单正则校验(`^[A-Za-z0-9_-]+$`) | | `frontend/src/features/publish/model/usePublishController.ts` | 发布成功后 `triggerCleanup()` 传 `video.id`(替换签名 URL) | | `frontend/src/shared/contexts/CleanupContext.tsx` | 下载字段改为 `videoId`;兼容旧 `videoDownloadUrl` 回填;下载按钮改同源路径 | | `frontend/src/features/home/ui/PreviewPanel.tsx` | 首页下载改为同源下载接口 | | `frontend/src/features/home/ui/HomePage.tsx` | 透传 `generatedVideoId` 给 `PreviewPanel` | +| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay`) | +| `frontend/src/features/home/ui/RewriteModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay`) | +| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录弹窗支持点击遮罩关闭 | +| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 | +| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) | | `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32) | | `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块,Day31 取消 Current | | `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 | @@ -58,7 +132,15 @@ | `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 | | `Docs/FRONTEND_DEV.md` | 补充 CleanupContext 下载策略规范 | | `Docs/PUBLISH_DEPLOY.md` | 补充发布成功后同源下载联动说明 | -| `README.md` | 补充“一键下载直达(同源 attachment)”能力描述 | +| `README.md` | 补充”一键下载直达(同源 attachment)”能力描述 | +| `backend/app/main.py` | `check_jwt_secret` startup 事件:生产环境(`DEBUG=False`)强拦截启动,开发环境 `CRITICAL` 告警 | +| `backend/app/modules/ai/router.py` | 3 个端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 | +| `backend/app/modules/tools/router.py` | `extract-script` 端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 | +| `backend/app/modules/materials/router.py` | `stream` 端点新增 `..` 路径穿越拒绝;`delete` 端点补充 `except ValueError` → 400 | +| `backend/app/modules/materials/service.py` | `delete_material` / `rename_material` 新增 `..` 路径穿越拒绝;流式上传增加 `MAX_UPLOAD_SIZE_MB` 大小限制 | +| `backend/app/modules/ref_audios/service.py` | 参考音频上传增加 5MB 大小限制 | +| `backend/app/modules/tools/service.py` | 文案提取文件上传替换为限大小分块拷贝(500MB);URL 下载分支增加下载后体积检查(500MB) | +| `backend/app/modules/generated_audios/service.py` | 任务失败错误字段从 `traceback.format_exc()` 改为 `str(e)`,避免泄露内部路径 | --- @@ -66,6 +148,11 @@ - `python -m py_compile backend/app/modules/videos/router.py` ✅ - `npm run build`(frontend)✅ +- `npm run build`(frontend,弹窗关闭策略调整后复验)✅ - `pm2 restart vigent2-frontend` ✅ - `pm2 restart vigent2-backend` ✅ - `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}` ✅ +- 安全修复第一批语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py backend/app/modules/ai/router.py backend/app/modules/tools/router.py backend/app/modules/materials/service.py backend/app/modules/ref_audios/service.py backend/app/modules/videos/router.py backend/app/modules/generated_audios/service.py` ✅ +- 未登录调用 `/api/ai/translate` → 返回 401 ✅ +- 未登录调用 `/api/tools/extract-script` → 返回 401 ✅ +- 收尾三刀语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py` ✅ diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index ad283a5..004a50a 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -219,6 +219,9 @@ body { - 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗 - 统一容器风格:`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影 - 统一关闭行为:支持 `ESC`;是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置 +- 默认策略:除关键流程外,`closeOnOverlay` 默认应为 `true`,并通过 `AppModalHeader onClose` 提供右上角 `X` 关闭入口 +- 关键流程例外:发布成功清理弹窗(`CleanupContext`)必须保持 `closeOnOverlay=false`,且不提供右上角关闭按钮 +- 录音弹窗例外:使用 `closeOnOverlay={!isRecording}`,录音中禁止遮罩关闭,避免误触中断 - 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动 - 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`) @@ -245,6 +248,7 @@ body { 所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置: - 自动携带 `credentials: include` - 遇到 401/403 时自动清除 cookie 并跳转登录页 +- AI/Tools 接口(如 `/api/ai/*`、`/api/tools/extract-script`)现为强制鉴权,禁止匿名 `fetch` 直调 **使用方式:** @@ -523,6 +527,7 @@ await api.post('/api/videos/generate', { - 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。 - 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。 - 关闭录音弹窗时如仍在录制,会先停止录音再关闭。 +- 录音中禁止点击遮罩关闭(`closeOnOverlay={!isRecording}`);未录音时允许遮罩关闭。 ```typescript // 录音需要用户授权麦克风 diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index 9a5d8fa..af0376f 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -47,6 +47,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **音色试听**: EdgeTTS 音色列表支持一键试听,按音色 locale 自动选择对应语言的固定示例文案。 - **参考音频管理**: 上传/列表/重命名/删除参考音频,上传后自动 Whisper 转写 ref_text + 超 10s 自动截取。 - **录音入口**: 参考音频区域底部右侧提供“上传音频 / 录音”入口;录音采用弹窗流程(录制 -> 试听 -> 使用/弃用)。 +- **录音防误触**: 录音中禁用遮罩关闭(避免误触中断);未录音时可点空白关闭。 - **重新识别**: 旧参考音频可重新转写并截取 (RotateCw 按钮)。 - **一键克隆**: 选择参考音频后自动调用 CosyVoice 3.0 服务。 - **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),统一下拉,选择持久化。 @@ -93,6 +94,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 ### 9. 文案提取助手 (`ScriptExtractionModal`) - **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。 - **AI 智能改写**: 集成 GLM-4.7-Flash,自动改写为口播文案。 +- **登录鉴权**: 依赖后端受保护接口(`/api/tools/extract-script`),未登录会触发全局 401 跳转登录。 - **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。 - **一键填入**: 提取结果直接填充至视频生成输入框。 - **智能交互**: 实时进度展示,防误触设计。 @@ -158,6 +160,7 @@ src/ - 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet);`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。 - 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。 +- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。 - 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。 - 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。 - 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`。 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 9f0d637..8173c71 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -1,7 +1,7 @@ # ViGent2 开发任务清单 (Task Log) **项目**: ViGent2 数字人口播视频生成系统 -**进度**: 100% (Day 32 - 视频下载同源修复 + 清理链路体验收敛) +**进度**: 100% (Day 32 - 视频下载同源修复 + 安全整改第一批) **更新时间**: 2026-03-04 --- @@ -10,12 +10,15 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 32: 视频下载同源修复 + Day 日志拆分归档 (Current) +### Day 32: 视频下载同源修复 + 安全整改第一批 + Day 日志拆分归档 (Current) - [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。 - [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`。 - [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。 - [x] **兼容旧持久化状态**: `CleanupContext` 对旧 `videoDownloadUrl` 做 `videoId` 解析回填,避免旧 pending 状态失效。 - [x] **文档拆分归档**: 将“下载修复开始后的今日内容”归档到 `Docs/DevLogs/Day32.md`,并从 `Day31.md` 移除对应章节与验证记录。 +- [x] **安全第一批修复**: JWT 默认密钥生产拦截、AI/Tools 接口强制鉴权、materials 路径穿越拦截、video_id 白名单、上传体积限制、错误信息通用化。 +- [x] **安全收尾三刀**: `delete_material` 的 `ValueError -> 400`、`tools` URL 下载分支 500MB 限制、`DEBUG=false` 下默认 JWT 密钥阻断启动。 +- [x] **弹窗关闭策略收敛**: 默认支持 `ESC/X/遮罩` 关闭;发布成功清理弹窗保持强制流程不允许遮罩关闭;录音弹窗录音中禁遮罩关闭(防误触)。 ### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复 - [x] **文档体系收敛**: README/DEV 职责边界明确,部署参数与代码对齐,Qwen3-TTS 文档归档至历史状态。 diff --git a/README.md b/README.md index 09b1a33..7874237 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ - 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。 - ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。 - 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。 -- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。 -- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。 -- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。 +- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。 +- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。 +- 🛡️ **安全基线** - AI/Tools 接口强制登录鉴权、关键上传链路体积限制、生产环境默认密钥启动拦截。 +- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。 - 🚀 **性能优化** - 编码流水线从 5-6 次有损编码精简至 3 次(prepare_segment → 模型输出 → Remotion)、compose 流复制免重编码、同分辨率跳过 scale、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk rawvideo 管道直编码(消除中间有损文件)、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理、workflow 阻塞调用线程池化。 --- diff --git a/backend/.env.example b/backend/.env.example index 5e5c5fc..a930669 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,7 @@ # 复制此文件为 .env 并填入实际值 # 调试模式 -DEBUG=true +DEBUG=false # Redis 配置 (Celery 任务队列) REDIS_URL=redis://localhost:6379/0 diff --git a/backend/app/main.py b/backend/app/main.py index 570fe54..fa9bcf1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -130,6 +130,20 @@ app.include_router(generated_audios_router, prefix="/api/generated-audios", tags app.include_router(payment_router) # /api/payment +@app.on_event("startup") +async def check_jwt_secret(): + if settings.JWT_SECRET_KEY == "your-secret-key-change-in-production": + if not settings.DEBUG: + raise RuntimeError( + "JWT_SECRET_KEY is still the default value! " + "Set a strong random secret in .env before running in production (DEBUG=False)." + ) + logger.critical( + "JWT_SECRET_KEY is still the default value! " + "Set a strong random secret in .env for production." + ) + + @app.on_event("startup") async def init_admin(): """ diff --git a/backend/app/modules/ai/router.py b/backend/app/modules/ai/router.py index 09b14bf..ff70d53 100644 --- a/backend/app/modules/ai/router.py +++ b/backend/app/modules/ai/router.py @@ -4,11 +4,12 @@ AI 相关 API 路由 from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from loguru import logger from app.services.glm_service import glm_service +from app.core.deps import get_current_user from app.core.response import success_response @@ -40,7 +41,7 @@ class TranslateRequest(BaseModel): @router.post("/translate") -async def translate_text(req: TranslateRequest): +async def translate_text(req: TranslateRequest, current_user: dict = Depends(get_current_user)): """ AI 翻译文案 @@ -57,11 +58,11 @@ async def translate_text(req: TranslateRequest): return success_response({"translated_text": translated}) except Exception as e: logger.error(f"Translate failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="翻译服务暂时不可用,请稍后重试") @router.post("/generate-meta") -async def generate_meta(req: GenerateMetaRequest): +async def generate_meta(req: GenerateMetaRequest, current_user: dict = Depends(get_current_user)): """ AI 生成视频标题和标签 @@ -80,11 +81,11 @@ async def generate_meta(req: GenerateMetaRequest): ).model_dump()) except Exception as e: logger.error(f"Generate meta failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="生成标题标签失败,请稍后重试") @router.post("/rewrite") -async def rewrite_script(req: RewriteRequest): +async def rewrite_script(req: RewriteRequest, current_user: dict = Depends(get_current_user)): """AI 改写文案""" if not req.text or not req.text.strip(): raise HTTPException(status_code=400, detail="文案不能为空") @@ -95,4 +96,4 @@ async def rewrite_script(req: RewriteRequest): return success_response({"rewritten_text": rewritten}) except Exception as e: logger.error(f"Rewrite failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail="改写服务暂时不可用,请稍后重试") diff --git a/backend/app/modules/generated_audios/service.py b/backend/app/modules/generated_audios/service.py index 20ca8bd..9a8aa76 100644 --- a/backend/app/modules/generated_audios/service.py +++ b/backend/app/modules/generated_audios/service.py @@ -152,9 +152,9 @@ async def generate_audio_task(task_id: str, req: GenerateAudioRequest, user_id: task_store.update(task_id, { "status": "failed", "message": f"配音生成失败: {str(e)}", - "error": traceback.format_exc(), + "error": str(e), }) - logger.error(f"Generate audio failed: {e}") + logger.error(f"Generate audio failed: {e}\n{traceback.format_exc()}") async def list_generated_audios(user_id: str) -> dict: @@ -215,28 +215,28 @@ async def list_generated_audios(user_id: str) -> dict: return GeneratedAudioListResponse(items=items).model_dump() -async def delete_all_generated_audios(user_id: str) -> tuple[int, int]: - """删除用户所有生成的配音(.wav + .json),返回 (删除数量, 失败数量)""" - try: - files = await storage_service.list_files(BUCKET, user_id, strict=True) - deleted_count = 0 - failed_count = 0 - for f in files: - name = f.get("name", "") - if not name or name == ".emptyFolderPlaceholder": - continue - if name.endswith("_audio.wav") or name.endswith("_audio.json"): - full_path = f"{user_id}/{name}" - try: - await storage_service.delete_file(BUCKET, full_path) - deleted_count += 1 - except Exception as e: - failed_count += 1 - logger.warning(f"Delete audio file failed: {full_path}, {e}") - return deleted_count, failed_count - except Exception as e: - logger.error(f"Delete all generated audios failed: {e}") - return 0, 1 +async def delete_all_generated_audios(user_id: str) -> tuple[int, int]: + """删除用户所有生成的配音(.wav + .json),返回 (删除数量, 失败数量)""" + try: + files = await storage_service.list_files(BUCKET, user_id, strict=True) + deleted_count = 0 + failed_count = 0 + for f in files: + name = f.get("name", "") + if not name or name == ".emptyFolderPlaceholder": + continue + if name.endswith("_audio.wav") or name.endswith("_audio.json"): + full_path = f"{user_id}/{name}" + try: + await storage_service.delete_file(BUCKET, full_path) + deleted_count += 1 + except Exception as e: + failed_count += 1 + logger.warning(f"Delete audio file failed: {full_path}, {e}") + return deleted_count, failed_count + except Exception as e: + logger.error(f"Delete all generated audios failed: {e}") + return 0, 1 async def delete_generated_audio(audio_id: str, user_id: str) -> None: diff --git a/backend/app/modules/materials/router.py b/backend/app/modules/materials/router.py index a7ce46e..6c3ac90 100644 --- a/backend/app/modules/materials/router.py +++ b/backend/app/modules/materials/router.py @@ -14,6 +14,8 @@ router = APIRouter() @router.get("/stream/{material_id:path}") async def stream_material(material_id: str, current_user: dict = Depends(get_current_user)): """直接流式返回素材文件(同源,避免 CORS canvas taint)""" + if ".." in material_id: + raise HTTPException(400, "非法素材ID") user_id = current_user["id"] if not material_id.startswith(f"{user_id}/"): raise HTTPException(403, "无权访问此素材") @@ -52,6 +54,8 @@ async def delete_material(material_id: str, current_user: dict = Depends(get_cur try: await service.delete_material(material_id, user_id) return success_response(message="素材已删除") + except ValueError as e: + raise HTTPException(400, str(e)) except PermissionError as e: raise HTTPException(403, str(e)) except Exception as e: diff --git a/backend/app/modules/materials/service.py b/backend/app/modules/materials/service.py index 8180caa..3e30231 100644 --- a/backend/app/modules/materials/service.py +++ b/backend/app/modules/materials/service.py @@ -7,6 +7,7 @@ import aiofiles from pathlib import Path from loguru import logger +from app.core.config import settings as app_settings from app.services.storage import storage_service @@ -123,6 +124,9 @@ async def upload_material(request, user_id: str) -> dict: async for chunk in request.stream(): await f.write(chunk) total_size += len(chunk) + max_bytes = app_settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + if total_size > max_bytes: + raise ValueError(f"文件大小超过限制 ({app_settings.MAX_UPLOAD_SIZE_MB}MB)") if total_size - last_log > 20 * 1024 * 1024: logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB") @@ -239,6 +243,8 @@ async def list_materials(user_id: str) -> list[dict]: async def delete_material(material_id: str, user_id: str) -> None: """删除素材""" + if ".." in material_id: + raise ValueError("非法素材ID") if not material_id.startswith(f"{user_id}/"): raise PermissionError("无权删除此素材") await storage_service.delete_file( @@ -249,6 +255,8 @@ async def delete_material(material_id: str, user_id: str) -> None: async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict: """重命名素材,返回更新后的素材信息""" + if ".." in material_id: + raise ValueError("非法素材ID") if not material_id.startswith(f"{user_id}/"): raise PermissionError("无权重命名此素材") diff --git a/backend/app/modules/ref_audios/service.py b/backend/app/modules/ref_audios/service.py index a5a36b1..e4b9b9b 100644 --- a/backend/app/modules/ref_audios/service.py +++ b/backend/app/modules/ref_audios/service.py @@ -104,6 +104,8 @@ async def upload_ref_audio(file, ref_text: str, user_id: str) -> dict: # 创建临时文件 with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input: content = await file.read() + if len(content) > 5 * 1024 * 1024: + raise ValueError("参考音频文件大小不能超过 5MB") tmp_input.write(content) tmp_input_path = tmp_input.name diff --git a/backend/app/modules/tools/router.py b/backend/app/modules/tools/router.py index b265dd6..f2de2db 100644 --- a/backend/app/modules/tools/router.py +++ b/backend/app/modules/tools/router.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from typing import Optional import traceback from loguru import logger +from app.core.deps import get_current_user from app.core.response import success_response from app.modules.tools import service @@ -14,7 +15,8 @@ async def extract_script_tool( file: Optional[UploadFile] = File(None), url: Optional[str] = Form(None), rewrite: bool = Form(True), - custom_prompt: Optional[str] = Form(None) + custom_prompt: Optional[str] = Form(None), + current_user: dict = Depends(get_current_user), ): """独立文案提取工具""" try: @@ -29,5 +31,5 @@ async def extract_script_tool( logger.error(traceback.format_exc()) msg = str(e) if "Fresh cookies" in msg: - msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。" - raise HTTPException(500, f"提取失败: {msg}") + raise HTTPException(500, "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。") + raise HTTPException(500, "文案提取失败,请稍后重试") diff --git a/backend/app/modules/tools/service.py b/backend/app/modules/tools/service.py index 836a230..f755cb6 100644 --- a/backend/app/modules/tools/service.py +++ b/backend/app/modules/tools/service.py @@ -41,7 +41,19 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T raise ValueError("文件名无效") safe_filename = Path(filename).name.replace(" ", "_") temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}" - await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb"))) + max_bytes = 500 * 1024 * 1024 # 500MB + total_written = 0 + with open(temp_path, "wb") as dst: + while True: + chunk = file.file.read(1024 * 1024) + if not chunk: + break + total_written += len(chunk) + if total_written > max_bytes: + dst.close() + os.remove(temp_path) + raise ValueError("上传文件大小不能超过 500MB") + dst.write(chunk) logger.info(f"Tool processing upload file: {temp_path}") else: temp_path = await _download_video(url, temp_dir, timestamp) @@ -49,6 +61,13 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T if not temp_path or not temp_path.exists(): raise ValueError("文件获取失败") + # 下载文件体积检查(500MB 上限) + max_download_bytes = 500 * 1024 * 1024 + file_size = temp_path.stat().st_size + if file_size > max_download_bytes: + os.remove(temp_path) + raise ValueError(f"下载的文件过大({file_size / (1024*1024):.0f}MB),上限 500MB") + # 1.5 安全转换: 强制转为 WAV (16k) audio_path = temp_dir / f"extract_audio_{timestamp}.wav" try: diff --git a/backend/app/modules/videos/router.py b/backend/app/modules/videos/router.py index d47e98b..cfa3f4c 100644 --- a/backend/app/modules/videos/router.py +++ b/backend/app/modules/videos/router.py @@ -1,4 +1,5 @@ import os +import re import tempfile import uuid @@ -144,6 +145,8 @@ async def list_generated(current_user: dict = Depends(get_current_user)): @router.get("/generated/{video_id}/download") async def download_generated(video_id: str, current_user: dict = Depends(get_current_user)): + if not re.match(r'^[A-Za-z0-9_-]+$', video_id): + raise HTTPException(status_code=400, detail="非法 video_id") user_id = current_user["id"] storage_path = f"{user_id}/{video_id}.mp4" local_path = storage_service.get_local_file_path( @@ -162,6 +165,8 @@ async def download_generated(video_id: str, current_user: dict = Depends(get_cur @router.delete("/generated/{video_id}") async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)): + if not re.match(r'^[A-Za-z0-9_-]+$', video_id): + raise HTTPException(status_code=400, detail="非法 video_id") result = await delete_generated_video(current_user["id"], video_id) return success_response(result, message="视频已删除") diff --git a/frontend/src/components/AccountSettingsDropdown.tsx b/frontend/src/components/AccountSettingsDropdown.tsx index 61807c4..0ba7306 100644 --- a/frontend/src/components/AccountSettingsDropdown.tsx +++ b/frontend/src/components/AccountSettingsDropdown.tsx @@ -152,7 +152,7 @@ export default function AccountSettingsDropdown() { onClose={closePasswordModal} zIndexClassName="z-[200]" panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden" - closeOnOverlay={false} + closeOnOverlay > diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx index 536d09f..244ce74 100644 --- a/frontend/src/features/publish/ui/PublishPage.tsx +++ b/frontend/src/features/publish/ui/PublishPage.tsx @@ -67,7 +67,7 @@ export function PublishPage() { isOpen={Boolean(qrPlatform)} onClose={closeQrModal} panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden" - closeOnOverlay={false} + closeOnOverlay >