diff --git a/.agents/design/api/s3-object-key-authorization.md b/.agents/design/api/s3-object-key-authorization.md index ea9d324530bd..9aa820616d2a 100644 --- a/.agents/design/api/s3-object-key-authorization.md +++ b/.agents/design/api/s3-object-key-authorization.md @@ -50,7 +50,7 @@ FastGPT 的私有对象存储 key 是 bucket 内的全局路径字符串,例 2. 先完成业务资源鉴权,拿到可信的 `teamId`、`appId`、`datasetId`、`uid` 或 `userId`。 3. 使用对应 `isAuthorized*FileS3Key` helper 绑定 key 与可信上下文。 4. 绑定失败时返回通用未授权错误,不暴露 key 是否存在。 -5. 只有通过绑定后,才允许调用 `createExternalUrl`、`createGet*URL`、`jwtSignS3DownloadToken`、`downloadObject`、`getDatasetFileRawText`、`isObjectExists` 等存储层能力。 +5. 只有通过绑定后,才允许调用 `createExternalUrl`、`createGet*URL`、`createS3DownloadAccessUrl`、`downloadObject`、`getDatasetFileRawText`、`isObjectExists` 等存储层能力。 ## 底层防线 diff --git a/.agents/design/common/s3/s3-storage-refactor.md b/.agents/design/common/s3/s3-storage-refactor.md new file mode 100644 index 000000000000..1aa28faee247 --- /dev/null +++ b/.agents/design/common/s3/s3-storage-refactor.md @@ -0,0 +1,240 @@ +# S3 文件访问重构设计 + +## 1. 状态与范围 + +本设计记录当前已经落地的 S3 文件访问方案,覆盖以下能力: + +1. 用短链替代模型可见的长 JWT 上传、下载链接。 +2. 将上传校验拆成 `policy + hint + evidence`,支持无后缀文件和显式自定义扩展名。 +3. ChatBox 删除上传占位时真正中止预签和 PUT 请求,并阻止异步结果回写。 +4. 支持 `short-proxy`、`short-redirect`、`presigned` 三种下载模式。 +5. 抽取 `@fastgpt-sdk/storage/access-link`,让协议状态机与 FastGPT/Mongo 适配解耦。 +6. 保持文件对象、临时 TTL、聊天删除和短链记录的原有清理语义。 + +不在本次范围内:重做所有文件资产的事务模型、让 Plugin Server 承接主项目文件下载、把业务资源权限下沉到匿名短链路由。 + +## 2. 总体架构 + +```mermaid +flowchart LR + C["Chat / Workflow / Admin"] --> B["S3BaseBucket"] + B --> A["FastGPT access-link adapter"] + A --> S["storage SDK access-link core"] + S --> M["Mongo stores"] + A --> U["/api/system/file/u/:token"] + A --> D["/api/system/file/d/:signedAlias"] + U --> P["上传 policy 校验"] + P --> O["Object Storage"] + D -->|short-proxy| O + D -->|short-redirect| R["302 临时 presigned URL"] + R --> O +``` + +分层职责: + +| 层 | 职责 | +| --- | --- | +| SDK core | HMAC、过期分桶、alias 去重、token hash、verify/revoke 状态机和 store port | +| Service adapter | Mongo schema/store、环境变量、URL builder、日志和 FastGPT 错误映射 | +| App route | HTTP 方法、Zod 入参、上传/下载流、302、状态码和客户端断开处理 | +| 业务入口 | 在签发前完成 team/app/chat/dataset 等资源鉴权,短链路由不重复业务鉴权 | + +## 3. 短链协议 + +### 3.1 下载 + +默认 URL: + +```text +/api/system/file/d/.. +``` + +- `aliasId`:16 位随机 Base64URL ID,用于查询对象映射。 +- `expMinute36`:Unix 分钟的 36 进制编码,过期时间直接参与签名。 +- `sig`:`HMAC-SHA256(FILE_TOKEN_KEY, version + aliasId + expiry)` 的前 22 位 Base64URL。 +- `.` 是 URL path-safe 分隔符,三段长度固定且易于严格解析。 + +Mongo 集合 `s3_download_aliases` 只保存对象映射,不保存可直接使用的 bearer URL: + +```text +aliasId, aliasKey, bucketName, objectKey, +filename?, responseContentType?, +lastIssuedAt, purgeAt, disabledAt? +``` + +`aliasKey` 是 `bucketName + objectKey + filename + responseContentType` 的 HMAC。同一对象变体重复签发会复用 alias,只有过期时间和签名变化,因此文档数量接近对象变体数,不随页面刷新次数线性增长。 + +签发时按 TTL 向上分桶:短期 15 分钟、中期 1 小时、长期 24 小时。批量签发先对 aliasKey 去重,再使用 `$in` 查询和批量创建;只有 `purgeAt` 接近新链接有效期边界时才低频续租,避免每次签发产生 Mongo 写入。 + +校验顺序:格式 -> 过期 -> 常量时间 HMAC -> alias 查询 -> revoke 状态 -> payload。任何失败都不能暴露 bucket、objectKey 或 alias 是否存在等内部细节。 + +### 3.2 上传 + +URL: + +```text +/api/system/file/u/ +``` + +- token 为 22 位随机 Base64URL;Mongo 只保存 HMAC hash,不保存明文。 +- `s3_upload_sessions` 保存 bucket/object、大小限制、policy、hint、metadata、`expiresAt/usedAt/revokedAt`。 +- 当前使用策略为 `mark-used`:验证成功时记录 `usedAt`;过期记录由 Mongo TTL 索引删除。 +- 上传仍由 App proxy 承接,以便执行大小、内容类型、metadata 和中止处理;不能把公开下载前缀用于上传。 + +### 3.3 索引与清理 + +| 集合 | 关键索引 | 清理 | +| --- | --- | --- | +| `s3_download_aliases` | `aliasId unique`、`aliasKey unique`、`purgeAt TTL`、`bucketName + objectKey` | 链接到期后加 24 小时 grace 自动删除;对象删除时按 key 主动删除 alias | +| `s3_upload_sessions` | `tokenHash unique`、`expiresAt TTL`、`bucketName + objectKey` | session 到期后自动删除 | + +Mongo 是跨实例一致性的必要状态,不使用进程内缓存。性能优化优先采用批量查询、alias 复用、索引和低频续租;DBG 日志分别记录 HMAC、find/create/touch lease 和总耗时。 + +## 4. 下载模式与域名 + +`STORAGE_DOWNLOAD_URL_MODE`: + +| 模式 | 对外 URL | 文件字节链路 | 适用场景 | +| --- | --- | --- | --- | +| `short-proxy` | FastGPT 短链 | App 代理对象流 | 默认;私有 S3、最稳定 | +| `short-redirect` | FastGPT 短链 | App 校验后 302,客户端直连 S3/CDN | 有外部 endpoint,降低 App 带宽 | +| `presigned` | S3 长链接 | 客户端直连 S3/CDN | 仅显式兼容旧行为 | + +`short-redirect` 必须先完成短链校验,再生成不超过短链剩余寿命的临时 presigned URL。已经 302 给客户端的 URL 在其短 TTL 内无法被 alias revoke 立即收回,这是该模式的明确代价。 + +环境变量: + +| 变量 | 说明 | +| --- | --- | +| `FILE_TOKEN_KEY` | 上传 token hash 与下载 HMAC 密钥;生产环境必须稳定且足够随机,轮换会使存量链接失效 | +| `STORAGE_DOWNLOAD_URL_MODE` | 默认 `short-proxy` | +| `STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS` | 302 临时 S3 URL TTL,默认 300 秒 | +| `FILE_DOMAIN` | 完整 FastGPT 文件 API 域名,同时影响上传和下载 URL | +| `FILE_DOWNLOAD_PUBLIC_URL_PREFIX` | 可选下载短链公开前缀,只影响下载 | +| `STORAGE_EXTERNAL_ENDPOINT` / `STORAGE_S3_CDN_ENDPOINT` | redirect/presigned 使用的客户端可访问地址 | + +使用 nginx 缩短公开下载路径时,可配置: + +```nginx +location ~ ^/([A-Za-z0-9_-]{12,32}\.[0-9a-z]{1,8}\.[A-Za-z0-9_-]{16,64})$ { + proxy_pass http://host.docker.internal:3000/api/system/file/d/$1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +同时设置 `FILE_DOWNLOAD_PUBLIC_URL_PREFIX=http://localhost:8088`。Plugin、Pro 和浏览器拿到的仍是主 App 生成的 URL,不需要各自配置或实现文件下载 public API。 + +## 5. 上传策略与内容校验 + +### 5.1 三类输入 + +| 输入 | 含义 | 信任级别 | +| --- | --- | --- | +| `UploadPolicy` | 服务端允许的扩展名、MIME、fallback 和验证方式 | 权威规则 | +| `UploadFileHint` | 文件名、声明后缀、Content-Type、来源和大小 | 线索,不是安全事实 | +| `UploadFileEvidence` | magic bytes、Office ZIP marker、文本特征或 unknown | 内容证据 | + +预签阶段只构建 policy,并拒绝“明确存在且不在白名单”的后缀;缺少文件名或后缀不会在预签阶段直接失败。上传阶段读取有限前缀生成 evidence,再由 `resolveUploadFile` 做最终裁决。 + +### 5.2 扩展名规则 + +每个扩展名对应一种验证方式: + +| verification | 规则 | +| --- | --- | +| `content` | 必须由 magic/Office 内容证明;显式后缀与检测内容不一致时拒绝 | +| `text` | 允许文本特征作为弱证据;无后缀文本可落到 policy 指定的文本 fallback | +| `opaque` | 用于 `.dat/.bin/.exe` 等无法稳定由 magic 区分的自定义类型;必须显式声明允许的扩展名,最终使用 `application/octet-stream` | + +因此: + +- 将可检测的 EXE 改名为 `.png` 时,magic 与显式后缀不匹配,必须拒绝。 +- 业务明确允许 `.exe/.dat` 时,可以将其配置为 custom opaque 上传;这是业务准入,不代表系统证明了文件安全。 +- 无后缀文件若能检测出 magic 或文本,可在 policy 内推断;无后缀 unknown binary 且存在 allow-list 时拒绝,调用方应提供可信的声明后缀。 +- 第三方 URL 的 Content-Type 可以作为 hint,不能单独覆盖内容证据;调用方已知业务类型时应显式传 `declaredExtension/declaredFilename`。 + +Chat 正式上传接口必须读取服务端已发布配置,不能信任客户端 `fileSelectConfig`。只有编辑态测试使用独立 draft 接口并要求写权限: + +| 接口 | 策略来源 | +| --- | --- | +| `presignChatFilePostUrl` | App 最新发布版本、Home Chat 固定配置或 Helper 固定配置 | +| `presignDraftChatFilePostUrl` | 客户端临时配置,仅 App/Skill 编辑态且需写权限 | + +文件选择器和拖拽入口共用同一允许规则,避免音视频只可点击上传、不可拖拽的问题。 + +## 6. ChatBox 上传中止 + +每个本地文件创建稳定 `uploadId` 和独立 `AbortController`,任务保存在 `Map`: + +1. 删除占位时先标记 `canceled`,再 `abort()` 预签或 PUT,最后从 field array 删除。 +2. 进度、成功和失败回调每次写回前,必须按 `uploadId` 重新定位并检查任务未取消。 +3. 取消错误不显示上传失败 toast。 +4. 清空文件或组件卸载时中止全部任务并清空 Map。 +5. UI 排序后的 index 不可用于删除真实 field array,必须使用 `uploadId`。 + +中止是客户端到 App、App 到对象存储的协作行为;若对象存储已完整接收数据,不能承诺物理回滚,后续仍由既有临时 TTL/业务清理兜底。下载 proxy 同样将客户端断开转换为 abort signal,并在完成、错误或 close 时释放上游流。 + +## 7. 文件生命周期边界 + +短链只改变访问凭证,不改变对象所有权: + +- Chat 文件 key 为 `chat/////`;旧 App key 继续兼容读取和删除。 +- 工具、Sandbox 生成的临时文件在对话保存时由 `persistChatFiles` 校验 source/chat 归属并取消临时 TTL,使其随对话保留。 +- 删除对话或 source 时,仍按对应 Chat prefix 删除私有/公开 bucket 文件;对象删除任务同时清理相关 download alias。 +- 未被对话认领的临时文件继续由 `s3_ttls` 清理。 +- alias/session 的 Mongo TTL 只清理访问凭证记录,不删除 S3 对象;对象生命周期不能由短链 TTL 推导。 + +本设计不引入新的全局资产表或跨 Mongo/S3 事务。后续若要治理历史孤儿文件、软删除 GC 或可靠删除 intent,应作为独立生命周期项目实施。 + +## 8. 错误边界 + +| 场景 | HTTP | 对外语义 | +| --- | --- | --- | +| token/alias 格式错误、过期、签名错误、missing、revoked | 403 | 无文件访问权限,不泄露内部原因 | +| 对象不存在 | 404 | 文件不存在 | +| 文件过大、扩展名不允许、内容不匹配 | 400 | 稳定业务错误,可由上游展示 | +| 存储、Mongo 或配置异常 | 500 | 通用内部错误,详细信息只写服务端日志 | +| 不支持的 HTTP 方法 | 405 | Method not allowed | + +Route 必须捕获可预期文件代理错误并映射状态码,不能全部落入 `NextAPI` 默认 500。SDK 只抛稳定的 access-link error code,不依赖 FastGPT 全局错误枚举;上传 policy 错误继续由 FastGPT S3 业务错误处理。 + +## 9. 代码组织 + +| 路径 | 主要职责 | +| --- | --- | +| `sdk/storage/src/access-link/` | 短链协议、crypto、日期分桶、store ports、状态机 | +| `packages/service/common/s3/accessLink/` | Mongo adapter、schema、URL、日志和 service 实例 | +| `packages/service/common/s3/uploadPolicy/` | policy/hint/evidence 类型与最终裁决 | +| `packages/service/common/s3/buckets/base.ts` | 统一签发上传短链和三种下载 URL | +| `projects/app/src/pages/api/system/file/u/` | 新上传短链入口 | +| `projects/app/src/pages/api/system/file/d/` | 新下载短链入口和 proxy/redirect 分流 | +| `projects/app/src/service/common/s3/proxy.ts` | 上传/下载代理、流释放和错误响应 | +| `projects/app/.../ChatBox/hooks/useFileUpload.tsx` | 上传任务、取消和 UI 写回 | + +旧 `/api/system/file/upload/:token`、`download/:token` 在兼容期保留;新代码统一使用 `/u`、`/d`。 + +## 10. P0 验收清单 + +- [ ] 同一对象变体重复签发复用 alias;批量签发只进行聚合 Mongo 操作。 +- [ ] 下载短链正常、过期、篡改、撤销、对象不存在的状态码正确。 +- [ ] `short-proxy` 支持 GET/HEAD、range、客户端断开且不泄漏流。 +- [ ] `short-redirect` 先验签再 302,Location TTL 不超过短链剩余寿命。 +- [ ] `presigned` 仅在显式模式下返回长 S3 URL。 +- [ ] 上传 token 过期、撤销、重复使用、大小限制和 metadata 行为正确。 +- [ ] 无后缀 magic/text 文件按 policy 处理;unknown binary 无安全 fallback 时拒绝。 +- [ ] `.exe` 改名 `.png` 被拒绝;显式允许的 opaque 自定义扩展名可以上传。 +- [ ] 正式聊天不能篡改 `fileSelectConfig`;draft 上传要求资源写权限。 +- [ ] 点击删除、清空或卸载会 abort 请求,迟到回调不会恢复文件占位。 +- [ ] 点击选择、拖拽和粘贴对图片、音频、视频、自定义扩展名行为一致。 +- [ ] 对话保存后工具/Sandbox 文件不再按临时 TTL 误删;删除对话仍清理文件。 +- [ ] App、Pro Admin、Plugin invoke 返回的主项目文件链接 host 和下载模式符合环境配置。 +- [ ] Mongo TTL 索引存在,alias 数量不随重复签发线性增长,DBG timing 无异常写放大。 + +## 11. 升级与兼容 + +1. 上线前固定 `FILE_TOKEN_KEY`,确认 Mongo 用户具备创建 TTL/unique 索引权限。 +2. 默认使用 `short-proxy`;外部 S3/CDN 部署可切换 `short-redirect`;仅兼容旧消费者时使用 `presigned`。 +3. nginx 根路径短链是可选展示层,App 的 `/api/system/file/d/*` 必须始终可达。 +4. 历史 JWT 路由和历史 Chat key 在兼容期继续工作;新写入统一使用短链和 source-aware key。 +5. 回滚前必须评估已写入消息、dataset 或工具结果的新 `/d` 链接;直接移除新路由会使历史内容不可访问。 diff --git a/.agents/issue/s3-refactor-analysis.md b/.agents/issue/s3-refactor-analysis.md new file mode 100644 index 000000000000..7b2104da958c --- /dev/null +++ b/.agents/issue/s3-refactor-analysis.md @@ -0,0 +1,178 @@ +# S3 重构问题分析 + +## 0. 文档标识 + +- 任务前缀:`s3-refactor` +- 文档文件名:`s3-refactor-analysis.md` +- 更新时间:2026-07-03 +- 文档定位:梳理 S3 短链、上传类型校验、ChatBox 上传取消三个需求的现状、根因和影响域 + +## 1. 需求背景 + +本次需求集中在 S3 文件链路的三个相邻问题: + +1. 代理上传/下载链接把 JWT 放在 URL path 中,链接很长。AI 大模型在引用这些链接时容易改写 JWT 的少量字符,导致预览或下载失败。 +2. 上传策略在文件类型校验上依赖文件名后缀。用户提供的上传文件名或链接不带后缀时,即使真实文件内容可识别,也会在预签名或上传校验阶段被拒绝。 +3. ChatBox 输入框里的文件上传占位可以前端移除,但底层上传请求没有被 abort。上传完成后,异步任务仍可能把已删除文件重新写回列表。 + +三个问题的共同本质是:当前 S3 链路把“访问授权”“对象命名”“文件类型判定”“前端上传任务状态”分别塞进了 URL、文件名、扩展名和表单数组 index 中,缺少稳定的领域对象承载这些语义。 + +## 2. 当前代码事实基线 + +| 能力项 | 现有实现位置 | 现状说明 | 结论 | +|---|---|---|---| +| 代理下载 URL 签发 | `packages/service/common/s3/security/token.ts` | `jwtSignS3DownloadToken` 把 `objectKey`、`bucketName`、`type` 放入 JWT,并生成 `/api/system/file/download/?filename=...` | URL 长度与 payload、签名长度强绑定 | +| 代理上传 URL 签发 | `packages/service/common/s3/security/token.ts` | `jwtSignS3UploadToken` 把 `objectKey`、`bucketName`、`maxSize`、`uploadConstraints`、`metadata` 放入 JWT | 上传 token 比下载 token 更长,且包含策略细节 | +| 下载代理 | `projects/app/src/pages/api/system/file/download/[token].ts` | 只校验 JWT,再按 `bucketName/objectKey` 读取 S3;业务授权依赖签发前完成 | 可替换 token 解析来源,代理职责可复用 | +| 上传代理 | `projects/app/src/pages/api/system/file/upload/[token].ts` | 只校验 JWT,再用 token 内策略做流式大小与类型校验并上传到 S3 | 可替换 token 解析来源,校验职责可复用 | +| 预签上传入口 | `S3BaseBucket.createPresignedPutUrl` | 生成 TTL、previewUrl、代理上传 URL;调用 `createUploadConstraints` 生成上传约束 | 签发阶段已经依赖 filename 后缀 | +| 上传约束构建 | `packages/service/common/s3/utils/uploadConstraints.ts` | `createUploadConstraints` 会在 allowedExtensions 存在时要求 `filename` 必须带允许后缀 | 无后缀会在预签名阶段被拒绝 | +| 上传内容校验 | `packages/service/common/s3/validation/upload.ts` | `validateUploadFile` 先检查 filename 后缀是否在 allowedExtensions 内,再读取 buffer 用 file-type 检测 MIME | 无后缀会在内容检测前被拒绝 | +| Chat 文件预签 API | `projects/app/src/pages/api/core/chat/file/presignChatFilePostUrl.ts` | 从 `fileSelectConfig` 派生 allowedExtensions,传给 `createUploadChatFileURL` | Chat 上传严格绑定配置扩展名 | +| Dataset 文件预签 API | `projects/app/src/pages/api/core/dataset/file/presignDatasetFilePostUrl.ts` | 使用 `datasetAllowedExtensions` 固定文档扩展名 | Dataset 上传同样绑定扩展名 | +| ChatBox 文件上传 hook | `projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useFileUpload.tsx` | `uploadFiles` 对 status=0 的文件并发预签名并 `putFileToS3`;完成后按闭包中的 index 调 `updateFiles` | 移除 UI 项不影响进行中的 Promise/axios 请求 | +| 文件预览移除按钮 | `projects/app/src/components/core/chat/ChatContainer/components/FilePreview.tsx` | close 按钮调用 `removeFiles(index)` | 只操作前端表单数组 | +| 上传工具函数 | `packages/web/common/file/utils.ts` | `putFileToS3` 用 `axios.put` 上传,当前不接收 `AbortSignal` | 需要扩展为可取消上传 | + +## 3. 问题一:JWT 链接过长 + +### 3.1 直接原因 + +下载链接的 token 至少包含: + +- `objectKey` +- `bucketName` +- `type` +- `iat/exp` +- JWT header 与签名 + +上传链接还额外包含: + +- `maxSize` +- `uploadConstraints` +- `metadata` + +JWT 是自包含授权,因此 URL 长度随 payload 增长。当前上传代理 URL 中真正被用户或模型看到的是完整 JWT,而不是短 ID。 + +### 3.2 深层原因 + +当前实现追求“无状态 token”,但这个场景的主要消费者包含大模型。大模型不是可靠的逐字符复制器,尤其对长 base64url/JWT 字符串容易发生字符替换、截断或重新编码。 + +从第一性原理看,模型可引用链接应该满足: + +1. 字符数短。 +2. 字符集简单。 +3. 不包含高熵长片段。 +4. 服务端可以根据短 ID 恢复授权上下文。 +5. 过期、撤销、用途隔离仍可控。 + +JWT 满足无状态和防篡改,但不满足短链接与模型可复制性。 + +### 3.3 影响域 + +| 影响点 | 说明 | +|---|---| +| Chat/Workflow 输出中的文件预览 | `presignVariablesFileUrls`、chat 文件下载等最终会生成可被模型/前端引用的 URL | +| Dataset 引用图片预览 | `replaceS3KeyToPreviewUrl` 生成 proxy 下载 URL 时会把 JWT 放入 markdown | +| proxy 上传 URL | 前端上传使用,也会受 URL 长度影响,但主要是浏览器使用,不是模型引用 | +| 旧链接兼容 | 已签发 JWT 在过期前需要继续可用,不能直接删除旧路由 | + +## 4. 问题二:文件类型校验依赖后缀 + +### 4.1 直接原因 + +`createUploadConstraints` 在预签名阶段执行: + +```ts +if (allowedExtensions.length > 0 && (!fileExtension || !allowedExtensions.includes(fileExtension))) { + throw new Error(S3ErrEnum.invalidUploadFileType); +} +``` + +`validateUploadFile` 在上传代理阶段也先执行同类判断: + +```ts +if (allowedExtensions.length > 0 && (!extension || !allowedExtensions.includes(extension))) { + throw new Error(S3ErrEnum.invalidUploadFileType); +} +``` + +因此无后缀文件不会进入 `fileTypeFromBuffer` 检测逻辑。 + +### 4.2 深层原因 + +当前把“文件名后缀”同时当成了: + +1. 对象 key 命名依据。 +2. 默认 Content-Type 推导依据。 +3. allowedExtensions 白名单判断依据。 +4. 上传后 metadata `originFilename` 的展示依据。 +5. Dataset 解析时的 extension 来源。 + +这几个职责并不等价。后缀是用户提供的提示,不是安全事实。安全事实应来自真实内容检测、可信 Content-Type hint 和业务策略。 + +### 4.3 需要保留的约束 + +文件类型校验不能简单放宽为“无后缀都允许”,原因: + +1. 文本类文件很难只靠魔数区分 `.txt`、`.md`、`.csv`、`.json`。 +2. 有些格式是容器格式,例如 docx/xlsx/pptx 都是 zip,需要专门检测内部 marker。 +3. 如果 allowedExtensions 只允许图片,不能接受任意纯文本。 +4. `SKIP_FILE_TYPE_CHECK` 已有跳过入口,但不能作为正常架构方案。 + +因此更合理的是把上传策略拆成“预签名阶段只做明显拒绝”和“上传流阶段基于内容做最终裁决”。 + +## 5. 问题三:ChatBox 移除文件没有 abort 上传 + +### 5.1 直接原因 + +`FilePreview` 的关闭按钮只调用 `removeFiles(index)`,该方法来自 `react-hook-form` 的 `useFieldArray`。 + +`useFileUpload.uploadFiles` 已经启动的异步流程仍在继续: + +1. 调 `getUploadChatFilePresignedUrl`。 +2. 调 `putFileToS3`。 +3. 上传完成后设置 `copyFile.url/key`。 +4. 调 `updateFiles(fileIndex, copyFile)`。 + +因为没有取消信号、没有上传任务注册表、没有完成前检查“该文件是否已取消”,所以已移除的文件仍可能被异步任务写回。 + +### 5.2 额外风险 + +当前还有两个相邻风险: + +1. `useFileUpload` 返回给 UI 的 `fileList` 是排序后的 clone,但 `removeFiles(index)` 操作的是原始 field array。只要排序改变,index 就可能对应错文件。 +2. `UserInputFileItemType` 使用 `id` 字段,同时 `useFieldArray` 默认也用 `id` 作为内部 key。业务上传任务最好使用独立 `uploadId/localId`,不要复用 field array 的内部 id。 + +这两个问题不是用户描述的核心 bug,但如果只在现有 index/id 上补 abort,仍然容易留下竞态。 + +## 6. 现有测试基线 + +| 测试文件 | 已覆盖内容 | 后续可扩展点 | +|---|---|---| +| `packages/service/test/common/s3/token.test.ts` | upload/download JWT 类型隔离、endpoint 拼接 | 增加短票据 URL、旧 JWT 兼容 | +| `packages/service/test/common/s3/uploadConstraints.test.ts` | 扩展名标准化、预签约束构建 | 改为缺后缀不在预签阶段拒绝,并验证显式非法后缀策略 | +| `packages/service/test/common/s3/uploadValidation.test.ts` | MIME 检测、OOXML、MIME 等价组、错误类型 | 增加无后缀但 MIME 可识别、无后缀文本类、allowed MIME 集合 | +| `projects/app/test/pages/api/core/chat/file/presignChatFilePostUrl.test.ts` | Chat 上传 allowedExtensions 传递、禁用上传 | 增加 `contentType/fileSize` 等 hint 传递 | +| `projects/app/test/api/system/file/sourceContentType.test.ts` | proxy 下载 content-type/charset | 增加短票据下载代理 | +| `projects/app/test/components/core/chat/ChatContainer/ChatBox/file.test.ts` | Chat 上传文件类型 UI helper | 增加上传任务状态纯函数测试 | + +## 7. 总体结论 + +推荐把三个问题拆成三个可独立交付但共享语义的改造: + +1. 新增 DB-backed S3 文件访问票据,用短 ID 代替 URL 中的 JWT。旧 JWT 路由保留兼容。 +2. 重构上传策略为 `UploadPolicy + FileTypeResolver`:预签名不因缺后缀直接拒绝,最终由上传代理根据内容检测和策略判定。 +3. 重构 ChatBox 上传任务状态:以稳定 `uploadId` 管理任务、AbortController 和 UI 项,移除时真正 abort 并阻止异步写回。 + +三个需求不建议合成一个超大 PR。最稳妥的执行顺序是: + +1. 先做短链票据,因为它可以复用当前上传/下载代理,不必同时重写校验逻辑。 +2. 再做文件类型校验,因为它会改变上传策略和测试基线。 +3. 最后做 ChatBox abort,因为它主要在前端,但可顺带使用新的短上传 URL 与更清晰的上传错误语义。 + +## 8. 待用户确认的问题 + +1. 短链是否只用于 `storageDownloadMode=proxy` 的代理链接,还是所有模型可见的文件预览链接都强制走短代理,即使环境配置了外部 S3 presigned URL? +2. 无后缀纯文本文件在 allowedExtensions 包含多个文本类型时,是否允许按 `text/plain` 接受,还是必须要求前端提供可信 `contentType` hint? +3. ChatBox 用户取消上传后,如果 S3 实际已经完成写入,是否需要立即投递 S3 删除任务,还是只保证不会进入本轮 chat 文件列表并依赖 TTL 清理? diff --git a/packages/global/common/string/tools.ts b/packages/global/common/string/tools.ts index a9df795458c5..083943ebed9f 100644 --- a/packages/global/common/string/tools.ts +++ b/packages/global/common/string/tools.ts @@ -109,7 +109,7 @@ export const sliceStrStartEnd = (str: string | null = '', start: number, end: nu */ export const parseFileExtensionFromUrl = (url = '') => { // Prefer explicit filename in query params for proxy links: - // e.g. /api/system/file/download/?filename=image.jpg + // e.g. /api/system/file/d/, or a legacy proxy URL carrying filename in query. try { const parsedUrl = new URL(url, 'http://localhost'); const queryFilename = diff --git a/packages/global/core/ai/sandbox/type.ts b/packages/global/core/ai/sandbox/type.ts index 339e475b64e6..038077284855 100644 --- a/packages/global/core/ai/sandbox/type.ts +++ b/packages/global/core/ai/sandbox/type.ts @@ -5,3 +5,16 @@ export const SandboxImageConfigSchema = z.object({ tag: z.string().optional() }); export type SandboxImageConfigType = z.infer; + +/** + * Sandbox 生成文件的服务端持久化引用。 + * + * url 只用于在历史读取时定位并替换旧签名链接;key 才是文件的稳定身份。 + * 该结构不会作为工具响应发送给模型或通过 SSE 暴露给前端。 + */ +export const SandboxFileRefSchema = z.object({ + key: z.string(), + filename: z.string(), + url: z.string() +}); +export type SandboxFileRef = z.infer; diff --git a/packages/global/core/chat/setting/constants.ts b/packages/global/core/chat/setting/constants.ts new file mode 100644 index 000000000000..23d7ad895f4e --- /dev/null +++ b/packages/global/core/chat/setting/constants.ts @@ -0,0 +1,16 @@ +import type { AppFileSelectConfigType } from '../../app/type/config.schema'; + +/** + * Home Chat 的服务端上传白名单。 + * + * 前端可以根据当前模型能力收窄图片入口,但不能扩大这里定义的文件类型范围。 + */ +export const homeChatFileSelectConfig: AppFileSelectConfigType = { + maxFiles: 20, + canSelectFile: true, + canSelectImg: true, + canSelectVideo: false, + canSelectAudio: false, + canSelectCustomFileExtension: false, + customFileExtensionList: [] +}; diff --git a/packages/global/core/chat/type.ts b/packages/global/core/chat/type.ts index 1df2f64d6371..c072eb5e45cf 100644 --- a/packages/global/core/chat/type.ts +++ b/packages/global/core/chat/type.ts @@ -20,6 +20,7 @@ import { AgentPlanStatusSchema } from '../ai/agent/type'; import { ObjectIdSchema } from '../../common/type/mongo'; +import { SandboxFileRefSchema } from '../ai/sandbox/type'; export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({ nodeId: z.string(), @@ -40,7 +41,8 @@ export const ToolModuleResponseItemSchema = z.object({ toolAvatar: z.string(), params: z.string(), response: z.string().nullish(), - functionName: z.string() + functionName: z.string(), + fileRefs: z.array(SandboxFileRefSchema).optional() }); export type ToolModuleResponseItemType = z.infer; diff --git a/packages/global/openapi/core/chat/file/api.ts b/packages/global/openapi/core/chat/file/api.ts index ed11f0e44d94..93182b472dd5 100644 --- a/packages/global/openapi/core/chat/file/api.ts +++ b/packages/global/openapi/core/chat/file/api.ts @@ -1,16 +1,28 @@ import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { AppFileSelectConfigTypeSchema } from '../../../../core/app/type/config.schema'; import z from 'zod'; -import { createOutLinkChatTargetInputSchema, transformChatAuthTargetInput } from '../api'; +import { + createChatTargetInputSchema, + createOutLinkChatTargetInputSchema, + transformChatAuthTargetInput, + transformChatTargetInput +} from '../api'; +import { IntSchema } from '../../../../common/zod'; /* ============ chat file ============ */ const withChatFileTarget = (shape: T) => createOutLinkChatTargetInputSchema(shape).transform(transformChatAuthTargetInput); +const withInternalChatFileTarget = (shape: T) => + createChatTargetInputSchema(shape).transform(transformChatTargetInput); +const ChatFileDownloadModeSchema = z + .enum(['short-proxy', 'short-redirect', 'presigned']) + .optional() + .describe('下载链接模式'); export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema({ key: z.string().min(1).describe('文件key'), chatId: z.string().min(1).describe('对话ID'), - mode: z.enum(['proxy', 'presigned']).optional().describe('下载方式'), + mode: ChatFileDownloadModeSchema, outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') }).meta({ example: { @@ -25,25 +37,28 @@ export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema export const PresignChatFileGetUrlSchema = withChatFileTarget({ key: z.string().min(1).describe('文件key'), chatId: z.string().min(1).describe('对话ID'), - mode: z.enum(['proxy', 'presigned']).optional().describe('下载方式'), + mode: ChatFileDownloadModeSchema, outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') }); export type PresignChatFileGetUrlParams = z.input; export type PresignChatFileGetUrlRuntimeParams = z.output; -export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchema({ +const ChatFileUploadHintShape = { filename: z.string().min(1).describe('文件名'), + contentType: z.string().min(1).optional().describe('浏览器或上游提供的文件 MIME hint'), + declaredExtension: z.string().min(1).optional().describe('无后缀来源显式声明的文件扩展名'), + declaredFilename: z.string().min(1).optional().describe('无稳定文件名来源显式声明的文件名'), + size: IntSchema.optional().describe('文件大小 hint,单位 byte') +}; + +export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchema({ + ...ChatFileUploadHintShape, chatId: z.string().min(1).describe('对话ID'), - fileSelectConfig: AppFileSelectConfigTypeSchema.describe('本次上传控件的文件选择配置'), outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') }).meta({ example: { - filename: '1234567890', + filename: 'report.pdf', chatId: '1234567890', - fileSelectConfig: { - canSelectFile: true, - customFileExtensionList: ['.txt'] - }, outLinkAuthData: { shareId: '1234567890', outLinkUid: '1234567890' @@ -51,10 +66,34 @@ export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchem } }); export const PresignChatFilePostUrlSchema = withChatFileTarget({ - filename: z.string().min(1).describe('文件名'), + ...ChatFileUploadHintShape, chatId: z.string().min(1).describe('对话ID'), - fileSelectConfig: AppFileSelectConfigTypeSchema.describe('本次上传控件的文件选择配置'), outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') }); export type PresignChatFilePostUrlParams = z.input; export type PresignChatFilePostUrlRuntimeParams = z.output; + +export const PresignDraftChatFilePostUrlRawSchema = createChatTargetInputSchema({ + ...ChatFileUploadHintShape, + chatId: z.string().min(1).describe('对话ID'), + fileSelectConfig: AppFileSelectConfigTypeSchema.describe('未保存草稿使用的文件选择配置') +}).meta({ + example: { + filename: 'draft.dat', + appId: '68ad85a7463006c963799a05', + chatId: '1234567890', + fileSelectConfig: { + canSelectCustomFileExtension: true, + customFileExtensionList: ['.dat'] + } + } +}); +export const PresignDraftChatFilePostUrlSchema = withInternalChatFileTarget({ + ...ChatFileUploadHintShape, + chatId: z.string().min(1).describe('对话ID'), + fileSelectConfig: AppFileSelectConfigTypeSchema.describe('未保存草稿使用的文件选择配置') +}); +export type PresignDraftChatFilePostUrlParams = z.input; +export type PresignDraftChatFilePostUrlRuntimeParams = z.output< + typeof PresignDraftChatFilePostUrlSchema +>; diff --git a/packages/global/openapi/core/chat/file/index.ts b/packages/global/openapi/core/chat/file/index.ts index 61121267c19e..26149e7a8523 100644 --- a/packages/global/openapi/core/chat/file/index.ts +++ b/packages/global/openapi/core/chat/file/index.ts @@ -1,6 +1,10 @@ import type { OpenAPIPath } from '../../../type'; import { DevApiTagsMap } from '../../../tag'; -import { PresignChatFilePostUrlRawSchema, PresignChatFileGetUrlRawSchema } from './api'; +import { + PresignChatFilePostUrlRawSchema, + PresignDraftChatFilePostUrlRawSchema, + PresignChatFileGetUrlRawSchema +} from './api'; import { CreatePostPresignedUrlResponseSchema } from '../../../../common/file/s3/type'; import { z } from 'zod'; @@ -29,6 +33,30 @@ export const ChatFilePath: OpenAPIPath = { } } }, + '/core/chat/file/presignDraftChatFilePostUrl': { + post: { + summary: '获取草稿聊天文件上传 URL', + description: '为 App ChatTest、Skill Edit 或 Home Chat 获取文件上传 URL', + tags: [DevApiTagsMap.chatFile], + requestBody: { + content: { + 'application/json': { + schema: PresignDraftChatFilePostUrlRawSchema + } + } + }, + responses: { + 200: { + description: '成功获取草稿聊天文件上传 URL', + content: { + 'application/json': { + schema: CreatePostPresignedUrlResponseSchema + } + } + } + } + } + }, '/core/chat/file/presignChatFileGetUrl': { post: { summary: '获取文件预览地址', diff --git a/packages/global/openapi/core/dataset/data/api.ts b/packages/global/openapi/core/dataset/data/api.ts index 9843e2ce5a37..3f16dada845c 100644 --- a/packages/global/openapi/core/dataset/data/api.ts +++ b/packages/global/openapi/core/dataset/data/api.ts @@ -59,7 +59,7 @@ export const UpdateDatasetDataBodySchema = UpdateDatasetDataPropsSchema; export type UpdateDatasetDataBody = z.infer; export const UpdateDatasetDataResponseSchema = z.object({ q: z.string().optional().meta({ - example: '![image.png](/api/system/file/download/xxx?filename=image.png)', + example: '![image.png](/api/system/file/d/alias.exp.sig)', description: '展示态问题/主文本,内部 S3 图片会替换为签名访问地址' }), a: z.string().optional().meta({ diff --git a/packages/global/openapi/plugin/invoke.ts b/packages/global/openapi/plugin/invoke.ts index d90c18860d31..380f3b870ea2 100644 --- a/packages/global/openapi/plugin/invoke.ts +++ b/packages/global/openapi/plugin/invoke.ts @@ -1,4 +1,5 @@ import z from 'zod'; +import { ChatFileTypeEnum } from '../../core/chat/constants'; /* ============================================================================ * API: 获取反向调用用户信息 @@ -46,9 +47,7 @@ export const InvokeWecomCorpTokenResponseSchema = z.object({ export type InvokeWecomCorpTokenBodyType = z.infer; export type InvokeWecomCorpTokenQueryType = z.infer; -export type InvokeWecomCorpTokenResponseType = z.infer< - typeof InvokeWecomCorpTokenResponseSchema ->; +export type InvokeWecomCorpTokenResponseType = z.infer; /* ============================================================================ * API: 反向调用文件上传 @@ -62,7 +61,26 @@ export const InvokeFileUploadBodySchema = z.object({}); export const InvokeFileUploadQuerySchema = z.object({}); export const InvokeFileUploadResponseSchema = z.object({ - url: z.string().describe('上传后的文件访问 URL') + url: z.string().meta({ + description: '上传后的文件访问 URL', + example: 'https://fastgpt.example.com/api/system/file/d/alias-abc' + }), + key: z.string().meta({ + description: '上传后的私有 S3 对象 key。用于对话持久化后移除临时 TTL 或返回标准文件对象。', + example: 'chat/app/68ad85a7463006c963799a05/user-1/chat-1/result.txt' + }), + filename: z.string().meta({ + description: '文件名', + example: 'result.txt' + }), + contentType: z.string().optional().meta({ + description: '文件 MIME 类型', + example: 'text/plain' + }), + type: z.enum(ChatFileTypeEnum).meta({ + description: '聊天文件类型', + example: ChatFileTypeEnum.file + }) }); export type InvokeFileUploadBodyType = z.infer; diff --git a/packages/global/test/openapi/core/chat/targetSchema.test.ts b/packages/global/test/openapi/core/chat/targetSchema.test.ts index 4519bddd3333..b8cdd3a5a789 100644 --- a/packages/global/test/openapi/core/chat/targetSchema.test.ts +++ b/packages/global/test/openapi/core/chat/targetSchema.test.ts @@ -18,7 +18,11 @@ import { ResumeStreamParamsSchema } from '../../../../openapi/core/ai/api'; import { ChatSourceTypeEnum } from '../../../../core/chat/constants'; import { StopV2ChatSchema } from '../../../../openapi/core/chat/controler/api'; import { InitOutLinkChatQuerySchema } from '../../../../openapi/core/chat/outLink/api'; -import { PresignChatFileGetUrlSchema } from '../../../../openapi/core/chat/file/api'; +import { + PresignChatFileGetUrlSchema, + PresignChatFilePostUrlSchema, + PresignDraftChatFilePostUrlSchema +} from '../../../../openapi/core/chat/file/api'; import { SandboxCheckExistBodySchema } from '../../../../openapi/core/ai/sandbox/api'; import { CreateQuestionGuideV2BodySchema } from '../../../../openapi/core/ai/agent/api'; import { ExportCollectionBodySchema } from '../../../../openapi/core/dataset/collection/api'; @@ -259,6 +263,61 @@ describe('openapi/core/chat target schema', () => { }); }); + it('strips client file policy from runtime uploads and keeps it for internal drafts', () => { + const runtimeUpload = PresignChatFilePostUrlSchema.parse({ + appId, + chatId: 'chat-1', + filename: 'tool.exe', + fileSelectConfig: { + canSelectCustomFileExtension: true, + customFileExtensionList: ['.exe'] + } + }); + + expect(runtimeUpload).toMatchObject({ + sourceType: ChatSourceTypeEnum.app, + sourceId: appId, + chatId: 'chat-1', + filename: 'tool.exe' + }); + expect('fileSelectConfig' in runtimeUpload).toBe(false); + + const draftUpload = PresignDraftChatFilePostUrlSchema.parse({ + appId, + chatId: 'chat-1', + filename: 'tool.exe', + fileSelectConfig: { + canSelectCustomFileExtension: true, + customFileExtensionList: ['.exe'] + } + }); + + expect(draftUpload).toMatchObject({ + sourceType: ChatSourceTypeEnum.app, + sourceId: appId, + fileSelectConfig: { + canSelectCustomFileExtension: true, + customFileExtensionList: ['.exe'] + } + }); + }); + + it('rejects external-link-only targets from draft uploads', () => { + expect(() => + PresignDraftChatFilePostUrlSchema.parse({ + chatId: 'chat-1', + filename: 'notes.txt', + fileSelectConfig: { + canSelectFile: true + }, + outLinkAuthData: { + shareId, + outLinkUid + } + }) + ).toThrow(); + }); + it('parses outLink init query string auth data for GET requests', () => { const result = InitOutLinkChatQuerySchema.parse({ chatId: 'chat-1', diff --git a/packages/service/common/file/read/text.ts b/packages/service/common/file/read/text.ts new file mode 100644 index 000000000000..54d6281c8801 --- /dev/null +++ b/packages/service/common/file/read/text.ts @@ -0,0 +1,18 @@ +/** + * 粗略判断 buffer 是否像文本。文本类没有稳定 magic bytes,因此只能作为弱证据: + * 可以用于无后缀外部 URL 的 txt fallback,不能用来证明任意二进制格式。 + */ +export const isLikelyTextBuffer = (buffer: Buffer) => { + if (buffer.length === 0) return true; + + let suspiciousBytes = 0; + for (const byte of buffer) { + if (byte === 0) return false; + + if (byte < 7 || (byte > 14 && byte < 32)) { + suspiciousBytes += 1; + } + } + + return suspiciousBytes / buffer.length < 0.1; +}; diff --git a/packages/service/common/s3/accessLink/accessLinkService.ts b/packages/service/common/s3/accessLink/accessLinkService.ts new file mode 100644 index 000000000000..eca5c5174d43 --- /dev/null +++ b/packages/service/common/s3/accessLink/accessLinkService.ts @@ -0,0 +1,48 @@ +import { createS3AccessLinkService } from '@fastgpt-sdk/storage'; +import { serviceEnv } from '../../../env'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { S3_DOWNLOAD_ALIAS_ID_LENGTH, S3_UPLOAD_TOKEN_LENGTH } from './constants'; +import { mongoS3DownloadAliasStore } from './downloadAlias/store'; +import { mongoS3UploadSessionStore } from './uploadSession/store'; +import { buildS3AccessLinkDownloadUrl, buildS3AccessLinkUploadUrl } from './url'; +import { getLogger, LogCategories } from '../../logger'; + +const logger = getLogger(LogCategories.INFRA.S3); +const roundDurationMs = (durationMs: number) => Number(durationMs.toFixed(3)); + +export const s3AccessLinkService = createS3AccessLinkService({ + secret: serviceEnv.FILE_TOKEN_KEY, + routes: { + buildDownloadUrl: buildS3AccessLinkDownloadUrl, + buildUploadUrl: buildS3AccessLinkUploadUrl + }, + stores: { + downloadAlias: mongoS3DownloadAliasStore, + uploadSession: mongoS3UploadSessionStore + }, + idGenerator: { + aliasId: () => getNanoid(S3_DOWNLOAD_ALIAS_ID_LENGTH), + uploadToken: () => getNanoid(S3_UPLOAD_TOKEN_LENGTH) + }, + onDownloadUrlTiming: (timing) => { + logger.debug('S3 short download URL issued', { + inputCount: timing.inputCount, + uniqueAliasCount: timing.uniqueAliasCount, + reusedAliasCount: timing.reusedAliasCount, + createdAliasCount: timing.createdAliasCount, + leaseTouchedCount: timing.leaseTouchedCount, + totalDurationMs: roundDurationMs(timing.totalDurationMs), + hmacDurationMs: roundDurationMs(timing.hmacDurationMs), + aliasKeyHmacDurationMs: roundDurationMs(timing.aliasKeyHmacDurationMs), + signatureHmacDurationMs: roundDurationMs(timing.signatureHmacDurationMs), + mongoIoDurationMs: roundDurationMs(timing.storeIoDurationMs), + mongoFindDurationMs: roundDurationMs(timing.storeFindDurationMs), + mongoCreateDurationMs: roundDurationMs(timing.storeCreateDurationMs), + mongoTouchLeaseDurationMs: roundDurationMs(timing.storeTouchLeaseDurationMs), + aliasReused: timing.aliasReused, + duplicateAliasRetry: timing.duplicateAliasRetry, + leaseTouched: timing.leaseTouched + }); + }, + uploadSessionUsePolicy: 'mark-used' +}); diff --git a/packages/service/common/s3/accessLink/constants.ts b/packages/service/common/s3/accessLink/constants.ts new file mode 100644 index 000000000000..f2b76f30c6a0 --- /dev/null +++ b/packages/service/common/s3/accessLink/constants.ts @@ -0,0 +1,14 @@ +export { + S3_ACCESS_LINK_PURGE_GRACE_HOURS, + S3_DOWNLOAD_ALIAS_ID_LENGTH, + S3_DOWNLOAD_ALIAS_SIGN_VERSION, + S3_DOWNLOAD_EXPIRE_BUCKET_MS, + S3_DOWNLOAD_EXPIRE_BUCKET_THRESHOLD_MS, + S3_DOWNLOAD_SIGNATURE_LENGTH, + S3_UPLOAD_TOKEN_LENGTH +} from '@fastgpt-sdk/storage'; + +export const S3_ACCESS_LINK_ROUTES = { + download: '/api/system/file/d', + upload: '/api/system/file/u' +} as const; diff --git a/packages/service/common/s3/accessLink/downloadAlias/entity.ts b/packages/service/common/s3/accessLink/downloadAlias/entity.ts new file mode 100644 index 000000000000..ed76f7507d6a --- /dev/null +++ b/packages/service/common/s3/accessLink/downloadAlias/entity.ts @@ -0,0 +1,102 @@ +import { MongoS3DownloadAlias } from './schema'; +import type { S3DownloadAliasType } from '../type'; + +type CreateS3DownloadAliasData = Omit & { + createTime?: Date; + updateTime?: Date; +}; + +const isMongoDuplicateKeyError = (error: unknown) => + !!error && typeof error === 'object' && 'code' in error && error.code === 11000; + +export const findS3DownloadAliasByAliasKey = (aliasKey: string) => { + return MongoS3DownloadAlias.findOne({ aliasKey }).lean(); +}; + +export const findS3DownloadAliasByAliasId = (aliasId: string) => { + return MongoS3DownloadAlias.findOne({ aliasId }).lean(); +}; + +export const createS3DownloadAlias = async (data: CreateS3DownloadAliasData) => { + try { + const now = new Date(); + const [created] = await MongoS3DownloadAlias.create([ + { + ...data, + createTime: data.createTime ?? now, + updateTime: data.updateTime ?? now + } + ]); + + return created.toObject() as S3DownloadAliasType; + } catch (error) { + if (isMongoDuplicateKeyError(error)) { + return findS3DownloadAliasByAliasKey(data.aliasKey); + } + + throw error; + } +}; + +export const touchS3DownloadAliasPurgeAt = ({ + aliasKey, + purgeAt, + now = new Date() +}: { + aliasKey: string; + purgeAt: Date; + now?: Date; +}) => { + return MongoS3DownloadAlias.updateOne( + { aliasKey }, + { + $set: { + updateTime: now, + lastIssuedAt: now + }, + $max: { + purgeAt + } + } + ); +}; + +export const disableS3DownloadAliasByAliasId = (aliasId: string) => { + return MongoS3DownloadAlias.updateOne( + { aliasId }, + { + $set: { + disabledAt: new Date(), + updateTime: new Date() + } + } + ); +}; + +export const deleteS3DownloadAliasByObject = ({ + bucketName, + objectKey +}: { + bucketName: string; + objectKey: string; +}) => { + return MongoS3DownloadAlias.deleteMany({ + bucketName, + objectKey + }); +}; + +export const deleteS3DownloadAliasByObjects = ({ + bucketName, + objectKeys +}: { + bucketName: string; + objectKeys: string[]; +}) => { + if (objectKeys.length === 0) return Promise.resolve(); + + return MongoS3DownloadAlias.deleteMany({ + bucketName, + objectKey: { $in: objectKeys } + }); +}; diff --git a/packages/service/common/s3/accessLink/downloadAlias/schema.ts b/packages/service/common/s3/accessLink/downloadAlias/schema.ts new file mode 100644 index 000000000000..548e310837ce --- /dev/null +++ b/packages/service/common/s3/accessLink/downloadAlias/schema.ts @@ -0,0 +1,59 @@ +import { getLogger, LogCategories } from '../../../logger'; +import { getMongoModel, Schema } from '../../../mongo'; +import type { S3DownloadAliasType } from '../type'; + +export const S3DownloadAliasCollectionName = 's3_download_aliases'; + +const logger = getLogger(LogCategories.INFRA.MONGO); + +const S3DownloadAliasMongoSchema = new Schema({ + aliasId: { + type: String, + required: true + }, + aliasKey: { + type: String, + required: true + }, + bucketName: { + type: String, + required: true + }, + objectKey: { + type: String, + required: true + }, + filename: String, + responseContentType: String, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + }, + lastIssuedAt: { + type: Date, + required: true + }, + purgeAt: { + type: Date, + required: true + }, + disabledAt: Date +}); + +try { + S3DownloadAliasMongoSchema.index({ aliasId: 1 }, { unique: true }); + S3DownloadAliasMongoSchema.index({ aliasKey: 1 }, { unique: true }); + S3DownloadAliasMongoSchema.index({ purgeAt: 1 }, { expireAfterSeconds: 0 }); + S3DownloadAliasMongoSchema.index({ bucketName: 1, objectKey: 1 }); +} catch (error) { + logger.error('Failed to build S3 download alias indexes', { error }); +} + +export const MongoS3DownloadAlias = getMongoModel( + S3DownloadAliasCollectionName, + S3DownloadAliasMongoSchema +); diff --git a/packages/service/common/s3/accessLink/downloadAlias/service.ts b/packages/service/common/s3/accessLink/downloadAlias/service.ts new file mode 100644 index 000000000000..391a1483c586 --- /dev/null +++ b/packages/service/common/s3/accessLink/downloadAlias/service.ts @@ -0,0 +1,46 @@ +import { + CreateS3DownloadAccessUrlParamsSchema, + CreateS3DownloadAccessUrlsParamsSchema, + VerifiedS3DownloadAccessSchema, + type VerifiedS3DownloadAccess +} from '../type'; +import { s3AccessLinkService } from '../accessLinkService'; + +/** + * 创建或复用下载 alias,并返回带过期时间与 HMAC 签名的短 URL。 + * + * 该函数只承载已经完成业务鉴权后的存储上下文,不做 app/dataset/team 等归属校验。 + * 调用方必须在传入 objectKey 前确认当前用户有权访问该文件。 + */ +export const createS3DownloadAccessUrl = async (params: unknown) => { + const parsed = CreateS3DownloadAccessUrlParamsSchema.parse(params); + return s3AccessLinkService.createDownloadUrl(parsed); +}; + +/** + * 批量创建或复用下载 alias,并按输入顺序返回短 URL。 + * + * 调用方仍需在进入该函数前完成所有 objectKey 的业务归属校验。 + */ +export const createS3DownloadAccessUrls = async (params: unknown) => { + const parsed = CreateS3DownloadAccessUrlsParamsSchema.parse(params); + return s3AccessLinkService.createDownloadUrls(parsed); +}; + +/** + * 校验 signed alias 并返回文件代理下载 payload。 + * + * URL 的过期由 `expMinute36 + HMAC` 保证;Mongo alias 只负责把短 id 映射回真实 + * `bucketName/objectKey`。 + */ +export const verifyS3DownloadAccess = async ( + signedAlias: string +): Promise => { + return VerifiedS3DownloadAccessSchema.parse( + await s3AccessLinkService.verifyDownloadAlias(signedAlias) + ); +}; + +export const revokeS3DownloadAlias = (aliasId: string) => { + return s3AccessLinkService.revokeDownloadAlias(aliasId); +}; diff --git a/packages/service/common/s3/accessLink/downloadAlias/store.ts b/packages/service/common/s3/accessLink/downloadAlias/store.ts new file mode 100644 index 000000000000..e86d4276a1f7 --- /dev/null +++ b/packages/service/common/s3/accessLink/downloadAlias/store.ts @@ -0,0 +1,94 @@ +import { + S3AccessLinkErrCode, + S3AccessLinkError, + type S3DownloadAliasStore +} from '@fastgpt-sdk/storage'; +import { MongoS3DownloadAlias } from './schema'; +import type { S3DownloadAliasType } from '../type'; + +const isMongoDuplicateKeyError = (error: unknown) => + !!error && typeof error === 'object' && 'code' in error && error.code === 11000; + +const toAliasRecord = (record: S3DownloadAliasType) => record; + +export const mongoS3DownloadAliasStore: S3DownloadAliasStore = { + findByAliasKeys: async (aliasKeys) => { + if (aliasKeys.length === 0) return []; + + const records = await MongoS3DownloadAlias.find({ aliasKey: { $in: aliasKeys } }).lean(); + return records.map(toAliasRecord); + }, + findByAliasId: async (aliasId) => { + const record = await MongoS3DownloadAlias.findOne({ aliasId }).lean(); + return record ? toAliasRecord(record) : null; + }, + createMany: async (records) => { + if (records.length === 0) return []; + + try { + const now = new Date(); + const created = await MongoS3DownloadAlias.insertMany( + records.map((record) => ({ + ...record, + createTime: record.createTime ?? now, + updateTime: record.updateTime ?? now + })), + { ordered: false } + ); + + return created.map((item) => toAliasRecord(item.toObject() as S3DownloadAliasType)); + } catch (error) { + if (isMongoDuplicateKeyError(error)) { + throw new S3AccessLinkError(S3AccessLinkErrCode.duplicateAliasKey, { cause: error }); + } + + throw error; + } + }, + touchLeases: async (params) => { + if (params.length === 0) return; + + await MongoS3DownloadAlias.bulkWrite( + params.map(({ aliasId, purgeAt, lastIssuedAt }) => ({ + updateOne: { + filter: { aliasId }, + update: { + $set: { + updateTime: lastIssuedAt, + lastIssuedAt + }, + $max: { + purgeAt + } + } + } + })), + { ordered: false } + ); + }, + disableByAliasId: async ({ aliasId, disabledAt }) => { + await MongoS3DownloadAlias.updateOne( + { aliasId }, + { + $set: { + disabledAt, + updateTime: disabledAt + } + } + ); + }, + deleteByObject: async ({ bucketName, objectKey }) => { + await MongoS3DownloadAlias.deleteMany({ + bucketName, + objectKey + }); + }, + deleteByObjects: async ({ bucketName, objectKeys }) => { + if (objectKeys.length === 0) return; + + await MongoS3DownloadAlias.deleteMany({ + bucketName, + objectKey: { $in: objectKeys } + }); + } +}; diff --git a/packages/service/common/s3/accessLink/error.ts b/packages/service/common/s3/accessLink/error.ts new file mode 100644 index 000000000000..2a61acf88aa5 --- /dev/null +++ b/packages/service/common/s3/accessLink/error.ts @@ -0,0 +1,6 @@ +export { + S3AccessLinkErrCode, + S3AccessLinkError, + isS3AccessLinkError, + type S3AccessLinkErrorCode +} from '@fastgpt-sdk/storage'; diff --git a/packages/service/common/s3/accessLink/index.ts b/packages/service/common/s3/accessLink/index.ts new file mode 100644 index 000000000000..4b434a0ac323 --- /dev/null +++ b/packages/service/common/s3/accessLink/index.ts @@ -0,0 +1,7 @@ +export * from './constants'; +export * from './error'; +export * from './type'; +export * from './utils'; +export * from './downloadAlias/entity'; +export * from './downloadAlias/service'; +export * from './uploadSession/service'; diff --git a/packages/service/common/s3/accessLink/type.ts b/packages/service/common/s3/accessLink/type.ts new file mode 100644 index 000000000000..62478857a9f5 --- /dev/null +++ b/packages/service/common/s3/accessLink/type.ts @@ -0,0 +1,122 @@ +import z from 'zod'; +import { UploadConstraintsSchema } from '../contracts/type'; +import { UploadFileHintSchema, UploadPolicySchema } from '../uploadPolicy/type'; +import { S3_DOWNLOAD_URL_BATCH_MAX_SIZE } from '@fastgpt-sdk/storage/access-link'; + +const UrlSafeTokenSchema = z.string().regex(/^[A-Za-z0-9_-]+$/); +const HexSha256Schema = z + .string() + .length(64) + .regex(/^[a-f0-9]+$/); + +export const S3AccessBucketNameSchema = z.string().min(1); +export const S3AccessObjectKeySchema = z.string().min(1); + +export const S3DownloadAliasIdSchema = UrlSafeTokenSchema.min(12).max(32); +export const S3DownloadAliasKeySchema = HexSha256Schema; +export const S3DownloadExpiresMinuteSchema = z + .string() + .min(1) + .max(8) + .regex(/^[0-9a-z]+$/); +export const S3DownloadSignatureSchema = UrlSafeTokenSchema.min(16).max(64); +export const S3SignedDownloadAliasValueSchema = z + .string() + .regex(/^[A-Za-z0-9_-]{12,32}\.[0-9a-z]{1,8}\.[A-Za-z0-9_-]{16,64}$/); + +export const S3DownloadAliasSchema = z.object({ + aliasId: S3DownloadAliasIdSchema, + aliasKey: S3DownloadAliasKeySchema, + bucketName: S3AccessBucketNameSchema, + objectKey: S3AccessObjectKeySchema, + filename: z.string().min(1).optional(), + responseContentType: z.string().min(1).optional(), + createTime: z.coerce.date(), + updateTime: z.coerce.date(), + lastIssuedAt: z.coerce.date(), + purgeAt: z.coerce.date(), + disabledAt: z.coerce.date().optional() +}); +export type S3DownloadAliasType = z.infer; + +export const CreateS3DownloadAccessUrlParamsSchema = z.object({ + bucketName: S3AccessBucketNameSchema, + objectKey: S3AccessObjectKeySchema, + expiredTime: z.coerce.date(), + filename: z.string().min(1).optional(), + responseContentType: z.string().min(1).optional() +}); +export type CreateS3DownloadAccessUrlParams = z.infer; +export const CreateS3DownloadAccessUrlsParamsSchema = z + .array(CreateS3DownloadAccessUrlParamsSchema) + .max(S3_DOWNLOAD_URL_BATCH_MAX_SIZE); + +export const ParsedS3SignedDownloadAliasSchema = z.object({ + aliasId: S3DownloadAliasIdSchema, + expMinute36: S3DownloadExpiresMinuteSchema, + sig: S3DownloadSignatureSchema +}); +export type ParsedS3SignedDownloadAlias = z.infer; + +export const S3ProxyDownloadPayloadSchema = z.object({ + bucketName: S3AccessBucketNameSchema, + objectKey: S3AccessObjectKeySchema, + filename: z.string().min(1).optional(), + responseContentType: z.string().min(1).optional() +}); +export type S3ProxyDownloadPayload = z.infer; + +export const VerifiedS3DownloadAccessSchema = S3ProxyDownloadPayloadSchema.extend({ + expiresAt: z.coerce.date() +}); +export type VerifiedS3DownloadAccess = z.infer; + +export const S3UploadTokenSchema = UrlSafeTokenSchema.min(20).max(64); +export const S3UploadTokenHashSchema = HexSha256Schema; + +export const S3UploadSessionSchema = z.object({ + tokenHash: S3UploadTokenHashSchema, + bucketName: S3AccessBucketNameSchema, + objectKey: S3AccessObjectKeySchema, + maxSize: z.number().positive(), + uploadConstraints: UploadConstraintsSchema, + uploadPolicy: UploadPolicySchema.optional(), + fileHint: UploadFileHintSchema.optional(), + metadata: z.record(z.string(), z.string()).optional(), + createTime: z.coerce.date(), + expiresAt: z.coerce.date(), + usedAt: z.coerce.date().optional(), + revokedAt: z.coerce.date().optional() +}); +export type S3UploadSessionType = z.infer; + +export const CreateS3UploadAccessUrlParamsSchema = z.object({ + bucketName: S3AccessBucketNameSchema, + objectKey: S3AccessObjectKeySchema, + expiredTime: z.coerce.date(), + maxSize: z.number().positive(), + uploadConstraints: UploadConstraintsSchema, + uploadPolicy: UploadPolicySchema.optional(), + fileHint: UploadFileHintSchema.optional(), + metadata: z.record(z.string(), z.string()).optional() +}); +export type CreateS3UploadAccessUrlParams = z.infer; + +export const S3ProxyUploadPayloadSchema = S3UploadSessionSchema.pick({ + bucketName: true, + objectKey: true, + maxSize: true, + uploadConstraints: true, + uploadPolicy: true, + fileHint: true, + metadata: true +}); +export type S3ProxyUploadPayload = z.infer; + +export const S3DownloadAccessRouteQuerySchema = z.object({ + signedAlias: S3SignedDownloadAliasValueSchema +}); + +export const S3UploadAccessRouteQuerySchema = z.object({ + token: S3UploadTokenSchema +}); diff --git a/packages/service/common/s3/accessLink/uploadSession/entity.ts b/packages/service/common/s3/accessLink/uploadSession/entity.ts new file mode 100644 index 000000000000..979b3959f33c --- /dev/null +++ b/packages/service/common/s3/accessLink/uploadSession/entity.ts @@ -0,0 +1,43 @@ +import { MongoS3UploadSession } from './schema'; +import type { S3UploadSessionType } from '../type'; + +type CreateS3UploadSessionData = Omit & { + createTime?: Date; +}; + +export const createS3UploadSession = async (data: CreateS3UploadSessionData) => { + const [created] = await MongoS3UploadSession.create([ + { + ...data, + createTime: data.createTime ?? new Date() + } + ]); + + return created.toObject() as S3UploadSessionType; +}; + +export const findS3UploadSessionByTokenHash = (tokenHash: string) => { + return MongoS3UploadSession.findOne({ tokenHash }).lean(); +}; + +export const markS3UploadSessionUsed = (tokenHash: string) => { + return MongoS3UploadSession.updateOne( + { tokenHash }, + { + $set: { + usedAt: new Date() + } + } + ); +}; + +export const revokeS3UploadSessionByTokenHash = (tokenHash: string) => { + return MongoS3UploadSession.updateOne( + { tokenHash }, + { + $set: { + revokedAt: new Date() + } + } + ); +}; diff --git a/packages/service/common/s3/accessLink/uploadSession/schema.ts b/packages/service/common/s3/accessLink/uploadSession/schema.ts new file mode 100644 index 000000000000..04c85192cec7 --- /dev/null +++ b/packages/service/common/s3/accessLink/uploadSession/schema.ts @@ -0,0 +1,56 @@ +import { getLogger, LogCategories } from '../../../logger'; +import { getMongoModel, Schema } from '../../../mongo'; +import type { S3UploadSessionType } from '../type'; + +export const S3UploadSessionCollectionName = 's3_upload_sessions'; + +const logger = getLogger(LogCategories.INFRA.MONGO); + +const S3UploadSessionMongoSchema = new Schema({ + tokenHash: { + type: String, + required: true + }, + bucketName: { + type: String, + required: true + }, + objectKey: { + type: String, + required: true + }, + maxSize: { + type: Number, + required: true + }, + uploadConstraints: { + type: Object, + required: true + }, + uploadPolicy: Object, + fileHint: Object, + metadata: Object, + createTime: { + type: Date, + default: () => new Date() + }, + expiresAt: { + type: Date, + required: true + }, + usedAt: Date, + revokedAt: Date +}); + +try { + S3UploadSessionMongoSchema.index({ tokenHash: 1 }, { unique: true }); + S3UploadSessionMongoSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + S3UploadSessionMongoSchema.index({ bucketName: 1, objectKey: 1 }); +} catch (error) { + logger.error('Failed to build S3 upload session indexes', { error }); +} + +export const MongoS3UploadSession = getMongoModel( + S3UploadSessionCollectionName, + S3UploadSessionMongoSchema +); diff --git a/packages/service/common/s3/accessLink/uploadSession/service.ts b/packages/service/common/s3/accessLink/uploadSession/service.ts new file mode 100644 index 000000000000..5400add043fb --- /dev/null +++ b/packages/service/common/s3/accessLink/uploadSession/service.ts @@ -0,0 +1,25 @@ +import { + CreateS3UploadAccessUrlParamsSchema, + S3ProxyUploadPayloadSchema, + type S3ProxyUploadPayload +} from '../type'; +import { s3AccessLinkService } from '../accessLinkService'; + +/** + * 创建一次上传会话并返回短上传 URL。 + * + * 上传 session 承载 maxSize/uploadConstraints/metadata 等服务端策略,不按 objectKey 复用, + * 避免重复 PUT、覆盖对象和策略变更不生效。 + */ +export const createS3UploadAccessUrl = async (params: unknown) => { + const parsed = CreateS3UploadAccessUrlParamsSchema.parse(params); + return s3AccessLinkService.createUploadUrl(parsed); +}; + +export const verifyS3UploadSessionToken = async (token: string): Promise => { + return S3ProxyUploadPayloadSchema.parse(await s3AccessLinkService.verifyUploadToken(token)); +}; + +export const revokeS3UploadSessionToken = (token: string) => { + return s3AccessLinkService.revokeUploadToken(token); +}; diff --git a/packages/service/common/s3/accessLink/uploadSession/store.ts b/packages/service/common/s3/accessLink/uploadSession/store.ts new file mode 100644 index 000000000000..76c0f8904642 --- /dev/null +++ b/packages/service/common/s3/accessLink/uploadSession/store.ts @@ -0,0 +1,42 @@ +import type { S3UploadSessionStore } from '@fastgpt-sdk/storage'; +import type { S3UploadSessionType } from '../type'; +import { MongoS3UploadSession } from './schema'; + +const toUploadSessionRecord = (record: S3UploadSessionType) => record; + +export const mongoS3UploadSessionStore: S3UploadSessionStore = { + create: async (data) => { + const [created] = await MongoS3UploadSession.create([ + { + ...data, + createTime: data.createTime ?? new Date() + } + ]); + + return toUploadSessionRecord(created.toObject() as S3UploadSessionType); + }, + findByTokenHash: async (tokenHash) => { + const record = await MongoS3UploadSession.findOne({ tokenHash }).lean(); + return record ? toUploadSessionRecord(record) : null; + }, + markUsed: async ({ tokenHash, usedAt }) => { + await MongoS3UploadSession.updateOne( + { tokenHash }, + { + $set: { + usedAt + } + } + ); + }, + revoke: async ({ tokenHash, revokedAt }) => { + await MongoS3UploadSession.updateOne( + { tokenHash }, + { + $set: { + revokedAt + } + } + ); + } +}; diff --git a/packages/service/common/s3/accessLink/url.ts b/packages/service/common/s3/accessLink/url.ts new file mode 100644 index 000000000000..7c201d0f8281 --- /dev/null +++ b/packages/service/common/s3/accessLink/url.ts @@ -0,0 +1,32 @@ +import { stripUrlTrailingSlash } from '@fastgpt/global/common/string/url'; +import { serviceEnv } from '../../../env'; +import { S3_ACCESS_LINK_ROUTES } from './constants'; + +const getS3AccessLinkEndpointUrl = () => { + const domain = serviceEnv.FILE_DOMAIN ?? serviceEnv.FE_DOMAIN ?? ''; + + return `${stripUrlTrailingSlash(domain)}${serviceEnv.NEXT_PUBLIC_BASE_URL}`; +}; + +/** + * 构造对外暴露的 S3 下载短链。 + * + * 当配置 `FILE_DOWNLOAD_PUBLIC_URL_PREFIX` 时,下载链接直接使用该公开前缀, + * 由 nginx 将 `{signedAlias}` rewrite 到 app 的下载 API;未配置时保持旧的 FastGPT API 路径。 + */ +export const buildS3AccessLinkDownloadUrl = (signedAlias: string) => { + if (serviceEnv.FILE_DOWNLOAD_PUBLIC_URL_PREFIX) { + return `${stripUrlTrailingSlash(serviceEnv.FILE_DOWNLOAD_PUBLIC_URL_PREFIX)}/${signedAlias}`; + } + + return `${getS3AccessLinkEndpointUrl()}${S3_ACCESS_LINK_ROUTES.download}/${signedAlias}`; +}; + +/** + * 构造对外暴露的 S3 上传短链。 + * + * 上传链路需要保留完整 API 路径,以承接请求体、大小限制、内容校验和 abort 语义。 + */ +export const buildS3AccessLinkUploadUrl = (token: string) => { + return `${getS3AccessLinkEndpointUrl()}${S3_ACCESS_LINK_ROUTES.upload}/${token}`; +}; diff --git a/packages/service/common/s3/accessLink/utils.ts b/packages/service/common/s3/accessLink/utils.ts new file mode 100644 index 000000000000..ec7f71d3a817 --- /dev/null +++ b/packages/service/common/s3/accessLink/utils.ts @@ -0,0 +1,99 @@ +import { + constantTimeEqual, + createS3AccessLinkCrypto, + decodeExpiresAtMinute, + encodeExpiresAtMinute, + parseSignedS3DownloadAlias as parseSdkSignedS3DownloadAlias, + resolveDownloadExpiresAt +} from '@fastgpt-sdk/storage'; +import { serviceEnv } from '../../../env'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { S3_DOWNLOAD_ALIAS_ID_LENGTH, S3_UPLOAD_TOKEN_LENGTH } from './constants'; +import { ParsedS3SignedDownloadAliasSchema, type ParsedS3SignedDownloadAlias } from './type'; +import { S3AccessLinkErrCode, S3AccessLinkError } from './error'; +import { buildS3AccessLinkDownloadUrl, buildS3AccessLinkUploadUrl } from './url'; + +const s3AccessLinkCrypto = createS3AccessLinkCrypto({ secret: serviceEnv.FILE_TOKEN_KEY }); + +export const generateS3AliasId = () => getNanoid(S3_DOWNLOAD_ALIAS_ID_LENGTH); + +export const generateS3UploadToken = () => getNanoid(S3_UPLOAD_TOKEN_LENGTH); + +export const hashS3UploadToken = (token: string) => s3AccessLinkCrypto.hashUploadToken(token); + +export const buildS3DownloadAliasKey = ({ + bucketName, + objectKey, + filename, + responseContentType +}: { + bucketName: string; + objectKey: string; + filename?: string; + responseContentType?: string; +}) => { + return s3AccessLinkCrypto.buildDownloadAliasKey({ + bucketName, + objectKey, + filename, + responseContentType + }); +}; + +export const signS3DownloadAlias = ({ + aliasId, + expMinute36 +}: { + aliasId: string; + expMinute36: string; +}) => { + return s3AccessLinkCrypto.signDownloadAlias({ aliasId, expMinute36 }); +}; + +export const parseSignedS3DownloadAlias = (value: string): ParsedS3SignedDownloadAlias => { + const parsed = ParsedS3SignedDownloadAliasSchema.safeParse(parseSdkSignedS3DownloadAlias(value)); + + if (!parsed.success) { + throw new S3AccessLinkError(S3AccessLinkErrCode.invalidSignedAlias); + } + + return parsed.data; +}; + +/** + * 校验 signed alias 的格式、过期时间和 HMAC 签名。 + * + * Mongo alias 只负责资源映射;URL 的有效期由 `expMinute36` 和 `sig` 保证。 + */ +export const assertS3DownloadAliasSignature = (value: string, now = new Date()) => { + const parsed = parseSignedS3DownloadAlias(value); + const expiresAt = decodeExpiresAtMinute(parsed.expMinute36); + + if (expiresAt.getTime() <= now.getTime()) { + throw new S3AccessLinkError(S3AccessLinkErrCode.expiredSignedAlias); + } + + const expectedSig = signS3DownloadAlias({ + aliasId: parsed.aliasId, + expMinute36: parsed.expMinute36 + }); + + if (!constantTimeEqual(parsed.sig, expectedSig)) { + throw new S3AccessLinkError(S3AccessLinkErrCode.invalidSignedAliasSignature); + } + + return { + ...parsed, + expiresAt + }; +}; + +export const buildS3DownloadUrl = (signedAlias: string) => { + return buildS3AccessLinkDownloadUrl(signedAlias); +}; + +export const buildS3UploadUrl = (token: string) => { + return buildS3AccessLinkUploadUrl(token); +}; + +export { decodeExpiresAtMinute, encodeExpiresAtMinute, resolveDownloadExpiresAt }; diff --git a/packages/service/common/s3/buckets/base.ts b/packages/service/common/s3/buckets/base.ts index b6cc5dcd9608..6d881222e1e4 100644 --- a/packages/service/common/s3/buckets/base.ts +++ b/packages/service/common/s3/buckets/base.ts @@ -5,10 +5,11 @@ import { type createPreviewUrlParams, CreateGetPresignedUrlParamsSchema, CreatePostPresignedUrlOptionsSchema, + CreatePostPresignedUrlParamsSchema, type CreatePostPresignedUrlResult } from '../contracts/type'; import { - storageDownloadMode, + storageDownloadUrlMode, getSystemMaxFileSize, replaceS3UrlWithCdnEndpoint } from '../config/constants'; @@ -23,7 +24,11 @@ import { type UploadFileByBufferParams, UploadFileByBodySchema } from '../contra import type { createStorage } from '@fastgpt-sdk/storage'; import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; import { getContentDisposition } from '@fastgpt/global/common/file/tools'; -import { jwtSignS3DownloadToken, jwtSignS3UploadToken } from '../security/token'; +import { + createS3DownloadAccessUrl, + createS3UploadAccessUrl, + deleteS3DownloadAliasByObject +} from '../accessLink'; const logger = getLogger(LogCategories.INFRA.S3); @@ -143,6 +148,17 @@ export class S3BaseBucket { }); throw err; }); + + deleteS3DownloadAliasByObject({ + bucketName: this.bucketName, + objectKey + }).catch((err) => { + logger.warn('S3 download alias cleanup failed after object delete', { + key: objectKey, + bucketName: this.bucketName, + error: err + }); + }); } addDeleteJob(params: Omit[0], 'bucketName'>) { @@ -165,45 +181,64 @@ export class S3BaseBucket { maxFileSize = getSystemMaxFileSize(), uploadConstraints } = CreatePostPresignedUrlOptionsSchema.parse(options); + const parsedParams = CreatePostPresignedUrlParamsSchema.parse(params); const formatMaxFileSize = maxFileSize * 1024 * 1024; - const filename = params.filename; - const resolvedUploadConstraints = createUploadConstraints({ + const filename = parsedParams.filename; + const resolvedFilename = parsedParams.declaredFilename || filename; + const fileHint = { filename, + ...(parsedParams.contentType ? { contentType: parsedParams.contentType } : {}), + ...(parsedParams.declaredExtension + ? { declaredExtension: parsedParams.declaredExtension } + : {}), + ...(parsedParams.declaredFilename + ? { declaredFilename: parsedParams.declaredFilename } + : {}), + ...(parsedParams.source ? { source: parsedParams.source } : {}), + ...(parsedParams.size !== undefined ? { size: parsedParams.size } : {}) + }; + const resolvedUploadPolicy = createUploadConstraints({ + ...fileHint, uploadConstraints }); const expiredSeconds = differenceInSeconds(addMinutes(new Date(), 10), new Date()); const metadata = { - contentDisposition: getContentDisposition({ filename, type: 'attachment' }), - originFilename: encodeURIComponent(filename), + contentDisposition: getContentDisposition({ + filename: resolvedFilename, + type: 'attachment' + }), + originFilename: encodeURIComponent(resolvedFilename), uploadTime: new Date().toISOString(), - ...params.metadata + ...parsedParams.metadata }; if (expiredHours) { await MongoS3TTL.create({ - minioKey: params.rawKey, + minioKey: parsedParams.rawKey, bucketName: this.bucketName, expiredTime: addHours(new Date(), expiredHours) }); } const { url: previewUrl } = await this.createExternalUrl({ - key: params.rawKey, + key: parsedParams.rawKey, expiredHours }); return { - url: jwtSignS3UploadToken({ - objectKey: params.rawKey, + url: await createS3UploadAccessUrl({ + objectKey: parsedParams.rawKey, bucketName: this.bucketName, expiredTime: addMinutes(new Date(), Math.ceil(expiredSeconds / 60)), maxSize: formatMaxFileSize, - uploadConstraints: resolvedUploadConstraints, + uploadConstraints: resolvedUploadPolicy, + uploadPolicy: resolvedUploadPolicy, + fileHint, metadata }), - key: params.rawKey, + key: parsedParams.rawKey, headers: { - 'content-type': resolvedUploadConstraints.defaultContentType + 'content-type': resolvedUploadPolicy.defaultContentType }, previewUrl, maxSize: formatMaxFileSize @@ -246,15 +281,16 @@ export class S3BaseBucket { const { key, expiredHours, mode, responseContentType } = parsed; const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟 - if ((mode || storageDownloadMode) === 'proxy') { + if ((mode ?? storageDownloadUrlMode) !== 'presigned') { return { bucket: this.bucketName, key, - url: jwtSignS3DownloadToken({ + url: await createS3DownloadAccessUrl({ objectKey: key, bucketName: this.bucketName, expiredTime: addMinutes(new Date(), Math.ceil(expires / 60)), - filename: path.basename(key) + filename: path.basename(key), + responseContentType }) }; } @@ -336,8 +372,11 @@ export class S3BaseBucket { }; } - async getFileStream(key: string) { - const downloadResponse = await this.client.downloadObject({ key }); + async getFileStream(key: string, options?: { abortSignal?: AbortSignal }) { + const downloadResponse = await this.client.downloadObject({ + key, + ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) + }); if (!downloadResponse) return; return downloadResponse.body; diff --git a/packages/service/common/s3/config/constants.ts b/packages/service/common/s3/config/constants.ts index 2083b52c5011..b1b608b96fbd 100644 --- a/packages/service/common/s3/config/constants.ts +++ b/packages/service/common/s3/config/constants.ts @@ -5,6 +5,7 @@ import type { IStorageOptions } from '@fastgpt-sdk/storage'; import { serviceEnv } from '../../../env'; +import { StorageDownloadUrlModeSchema } from '../contracts/type'; export const S3Buckets = { public: serviceEnv.STORAGE_PUBLIC_BUCKET, @@ -22,10 +23,19 @@ type BucketStorageOptions = { }; const storageRegion = serviceEnv.STORAGE_REGION; +const storageVendor = serviceEnv.STORAGE_VENDOR; const storageExternalEndpoint = serviceEnv.STORAGE_EXTERNAL_ENDPOINT; export const storageS3CdnEndpoint = serviceEnv.STORAGE_S3_CDN_ENDPOINT; const storageS3Endpoint = serviceEnv.STORAGE_S3_ENDPOINT; -export const storageDownloadMode = serviceEnv.STORAGE_EXTERNAL_ENDPOINT ? 'presigned' : 'proxy'; +export const storageDownloadUrlMode = StorageDownloadUrlModeSchema.parse( + serviceEnv.STORAGE_DOWNLOAD_URL_MODE +); +export const storageDownloadRedirectTtlSeconds = serviceEnv.STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS; +const needExplicitExternalEndpointForRedirect = + storageVendor === 'minio' || storageVendor === 'aws-s3'; +export const canUseStorageDownloadRedirect = + !needExplicitExternalEndpointForRedirect || + Boolean(storageExternalEndpoint || storageS3CdnEndpoint); const storagePublicAccessExtraSubPath = serviceEnv.STORAGE_PUBLIC_ACCESS_EXTRA_SUB_PATH; const bucketStorageOptions = { diff --git a/packages/service/common/s3/contracts/type.ts b/packages/service/common/s3/contracts/type.ts index b6eb87981195..02999b0dd0c1 100644 --- a/packages/service/common/s3/contracts/type.ts +++ b/packages/service/common/s3/contracts/type.ts @@ -1,5 +1,10 @@ import z from 'zod'; import { Readable } from 'node:stream'; +import { + UploadExtensionRuleSchema, + UploadFileHintSchema, + UploadPolicySchema +} from '../uploadPolicy/type'; export const S3MetadataSchema = z.object({ filename: z.string(), @@ -14,15 +19,13 @@ export type S3Metadata = z.infer; export type ContentType = string; export type ExtensionType = `.${string}`; -export const UploadConstraintsSchema = z.object({ - defaultContentType: z.string().nonempty(), - allowedExtensions: z.array(z.string().nonempty()).optional() -}); +export const UploadConstraintsSchema = UploadPolicySchema; export type UploadConstraints = z.infer; export const UploadConstraintsInputSchema = z.object({ defaultContentType: z.string().nonempty().optional(), - allowedExtensions: z.array(z.string().nonempty()).optional() + allowedExtensions: z.array(z.string().nonempty()).optional(), + extensionRules: z.array(UploadExtensionRuleSchema).optional() }); export type UploadConstraintsInput = z.infer; @@ -30,12 +33,20 @@ export const S3SourcesSchema = z.enum(['avatar', 'chat', 'dataset', 'temp', 'raw export const S3Sources = S3SourcesSchema.enum; export type S3SourceType = z.infer; -export const DownloadModeSchema = z.enum(['proxy', 'presigned']); +export const StorageDownloadUrlModeSchema = z.enum(['short-proxy', 'short-redirect', 'presigned']); +export type StorageDownloadUrlMode = z.infer; + +export const DownloadModeSchema = StorageDownloadUrlModeSchema; export type DownloadMode = z.infer; export const CreatePostPresignedUrlParamsSchema = z.object({ filename: z.string().min(1), rawKey: z.string().min(1), + contentType: UploadFileHintSchema.shape.contentType, + declaredExtension: UploadFileHintSchema.shape.declaredExtension, + declaredFilename: UploadFileHintSchema.shape.declaredFilename, + source: UploadFileHintSchema.shape.source, + size: UploadFileHintSchema.shape.size, metadata: z.record(z.string(), z.string()).optional() }); export type CreatePostPresignedUrlParams = z.infer; diff --git a/packages/service/common/s3/queue/delete.ts b/packages/service/common/s3/queue/delete.ts index b690d4443fdf..68ce977291ba 100644 --- a/packages/service/common/s3/queue/delete.ts +++ b/packages/service/common/s3/queue/delete.ts @@ -2,6 +2,7 @@ import { getQueue, getWorker, QueueNames } from '../../bullmq'; import { getLogger, LogCategories } from '../../logger'; import path from 'path'; import { batchRun } from '@fastgpt/global/common/system/utils'; +import { deleteS3DownloadAliasByObjects } from '../accessLink'; const logger = getLogger(LogCategories.INFRA.S3); @@ -64,6 +65,17 @@ export const executeS3DeleteJob = async ({ prefix, bucketName, key, keys }: S3MQ | undefined; assertNoFailedKeys(result?.keys, 'keys'); + deleteS3DownloadAliasByObjects({ + bucketName, + objectKeys: keys + }).catch((error) => { + logger.warn('S3 download alias cleanup failed after delete job', { + bucketName, + count: keys?.length, + error + }); + }); + await batchRun(keys, async (key) => { if (key.includes('-parsed/')) return; const fileParsedPrefix = `${path.dirname(key)}/${path.basename(key, path.extname(key))}-parsed`; diff --git a/packages/service/common/s3/security/token.ts b/packages/service/common/s3/security/token.ts index de105ca8c35b..d81c2027cdf1 100644 --- a/packages/service/common/s3/security/token.ts +++ b/packages/service/common/s3/security/token.ts @@ -1,16 +1,8 @@ import jwt from 'jsonwebtoken'; -import { differenceInSeconds } from 'date-fns'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import type { UploadConstraints } from '../contracts/type'; -import path from 'path'; import { serviceEnv } from '../../../env'; -/* ==================== 路由与类型 ==================== */ -const FileApiPath = { - proxyDownload: '/api/system/file/download', - proxyUpload: '/api/system/file/upload' -} as const; - export type S3ObjectKeyTokenPayload = { objectKey: string; }; @@ -30,27 +22,7 @@ type S3UploadTokenPayload = { type: 'upload'; }; -type SignS3DownloadTokenParams = { - objectKey: string; - bucketName: string; - expiredTime: Date; - filename?: string; -}; - -type SignS3UploadTokenParams = { - objectKey: string; - bucketName: string; - expiredTime: Date; - maxSize: number; - uploadConstraints: UploadConstraints; - metadata?: Record; -}; - /* ==================== 通用工具函数 ==================== */ -const getExpiresIn = (expiredTime: Date) => { - return Math.max(1, differenceInSeconds(expiredTime, new Date())); -}; - const isRecord = (val: unknown): val is Record => !!val && typeof val === 'object' && !Array.isArray(val); @@ -60,10 +32,6 @@ const isStringArray = (val: unknown): val is string[] => const endpointUrl = `${serviceEnv.FILE_DOMAIN || serviceEnv.FE_DOMAIN || ''}${serviceEnv.NEXT_PUBLIC_BASE_URL}`; -const buildFileApiUrl = (apiPath: string, token: string, query = '') => { - return `${endpointUrl}${apiPath}/${token}${query}`; -}; - const parsePayload = (payload: unknown, checker: (value: unknown) => value is T): T => { if (!checker(payload)) { throw ERROR_ENUM.unAuthFile; @@ -71,12 +39,6 @@ const parsePayload = (payload: unknown, checker: (value: unknown) => value is return payload; }; -const signToken = (payload: T, expiredTime: Date) => { - return jwt.sign(payload, serviceEnv.FILE_TOKEN_KEY, { - expiresIn: getExpiresIn(expiredTime) - }); -}; - export const verifyToken = (token: string, checker: (value: unknown) => value is T) => { return new Promise((resolve, reject) => { jwt.verify(token, serviceEnv.FILE_TOKEN_KEY, (err, payload) => { @@ -127,55 +89,11 @@ const isS3UploadTokenPayload = (value: unknown): value is S3UploadTokenPayload = }; /* ==================== 代理下载 token ==================== */ -export function jwtSignS3DownloadToken({ - objectKey, - bucketName, - expiredTime, - filename -}: SignS3DownloadTokenParams) { - const token = signToken( - { - objectKey, - bucketName, - type: 'download' - } satisfies S3DownloadTokenPayload, - expiredTime - ); - - const finalFilename = filename || path.basename(objectKey) || ''; - const query = finalFilename ? `?filename=${encodeURIComponent(finalFilename)}` : ''; - - return buildFileApiUrl(FileApiPath.proxyDownload, token, query); -} - export function jwtVerifyS3DownloadToken(token: string) { return verifyToken(token, isS3DownloadTokenPayload); } /* ==================== 代理上传 token ==================== */ -export function jwtSignS3UploadToken({ - objectKey, - bucketName, - expiredTime, - maxSize, - uploadConstraints, - metadata -}: SignS3UploadTokenParams) { - const token = signToken( - { - objectKey, - bucketName, - maxSize, - uploadConstraints, - metadata, - type: 'upload' - } satisfies S3UploadTokenPayload, - expiredTime - ); - - return buildFileApiUrl(FileApiPath.proxyUpload, token); -} - export function jwtVerifyS3UploadToken(token: string) { return verifyToken(token, isS3UploadTokenPayload); } diff --git a/packages/service/common/s3/sources/chat/index.ts b/packages/service/common/s3/sources/chat/index.ts index 475dacb0e96e..166895333f5b 100644 --- a/packages/service/common/s3/sources/chat/index.ts +++ b/packages/service/common/s3/sources/chat/index.ts @@ -1,5 +1,5 @@ import { S3PrivateBucket } from '../../buckets/private'; -import { S3Sources } from '../../contracts/type'; +import { type DownloadMode, S3Sources } from '../../contracts/type'; import { type CheckChatFileKeys, type DelChatFileByPrefixParams, @@ -76,7 +76,7 @@ export class S3ChatSource extends S3PrivateBucket { key: string; expiredHours?: number; external: boolean; - mode?: 'proxy' | 'presigned'; + mode?: DownloadMode; }) { const { key, expiredHours = 1, external = false, mode } = params; // 默认一个小时 @@ -93,18 +93,31 @@ export class S3ChatSource extends S3PrivateBucket { chatId, uId, filename, + contentType, + declaredExtension, + declaredFilename, + size, expiredTime, maxFileSize, - allowedExtensions + allowedExtensions, + extensionRules } = ChatFileUploadSchema.parse(params); const { fileKey } = getChatFileS3Key({ sourceType, sourceId, chatId, uId, filename }); return await this.createPresignedPutUrl( - { rawKey: fileKey, filename }, + { + rawKey: fileKey, + filename, + ...(contentType ? { contentType } : {}), + ...(declaredExtension ? { declaredExtension } : {}), + ...(declaredFilename ? { declaredFilename } : {}), + ...(size !== undefined ? { size } : {}) + }, { expiredHours: expiredTime ? differenceInHours(expiredTime, new Date()) : 1, maxFileSize, uploadConstraints: { - allowedExtensions + allowedExtensions, + extensionRules } } ); @@ -174,7 +187,7 @@ export function getS3ChatSource() { export const createChatFilePreviewUrlGetter = (options?: { expiredHours?: number; - mode?: 'proxy' | 'presigned'; + mode?: DownloadMode; }) => { const s3ChatSource = getS3ChatSource(); diff --git a/packages/service/common/s3/sources/chat/key.ts b/packages/service/common/s3/sources/chat/key.ts index b14eb89552b6..4f45f38871a3 100644 --- a/packages/service/common/s3/sources/chat/key.ts +++ b/packages/service/common/s3/sources/chat/key.ts @@ -64,11 +64,37 @@ export function isAuthorizedChatFileS3Key({ }) { const parsedKey = parseChatFileS3Key(key); + return ( + isChatFileS3KeyForChat({ key, sourceType, sourceId, chatId }) && + !!parsedKey && + String(parsedKey.uid) === String(uid) + ); +} + +/** + * 判断聊天文件 key 是否属于指定的 Chat。 + * + * 该校验不限制 uid,供服务端生成文件在 Chat 保存阶段认领临时 TTL;调用方仍需确保 + * key 来自可信的内部工具元数据。面向用户的文件访问鉴权应继续使用 + * isAuthorizedChatFileS3Key,同时校验 uid。 + */ +export function isChatFileS3KeyForChat({ + key, + sourceType, + sourceId, + chatId +}: { + key: string; + sourceType: ChatS3SourceType; + sourceId: string; + chatId?: string; +}) { + const parsedKey = parseChatFileS3Key(key); + return ( !!parsedKey && parsedKey.sourceType === sourceType && String(parsedKey.sourceId) === String(sourceId) && - String(parsedKey.uid) === String(uid) && (chatId === undefined || String(parsedKey.chatId) === String(chatId)) ); } diff --git a/packages/service/common/s3/sources/chat/type.ts b/packages/service/common/s3/sources/chat/type.ts index be4886091dbf..550efeec2f20 100644 --- a/packages/service/common/s3/sources/chat/type.ts +++ b/packages/service/common/s3/sources/chat/type.ts @@ -2,6 +2,7 @@ import z from 'zod'; import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo'; import { UploadFileByBodySchema } from '../../contracts/type'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { UploadExtensionRuleSchema, UploadFileHintSchema } from '../../uploadPolicy/type'; export const ChatS3SourceTypeSchema = z.enum(ChatSourceTypeEnum); export type ChatS3SourceType = z.infer; @@ -12,9 +13,14 @@ export const ChatFileUploadSchema = z.object({ chatId: z.string().nonempty(), uId: z.string().nonempty(), filename: z.string().nonempty(), + contentType: UploadFileHintSchema.shape.contentType, + declaredExtension: UploadFileHintSchema.shape.declaredExtension, + declaredFilename: UploadFileHintSchema.shape.declaredFilename, + size: UploadFileHintSchema.shape.size, expiredTime: z.coerce.date().optional(), maxFileSize: z.number().positive().optional(), - allowedExtensions: z.array(z.string().nonempty()).optional() + allowedExtensions: z.array(z.string().nonempty()).optional(), + extensionRules: z.array(UploadExtensionRuleSchema).optional() }); export type CheckChatFileKeys = z.input; diff --git a/packages/service/common/s3/uploadPolicy/service.ts b/packages/service/common/s3/uploadPolicy/service.ts new file mode 100644 index 000000000000..6f58fde9380e --- /dev/null +++ b/packages/service/common/s3/uploadPolicy/service.ts @@ -0,0 +1,434 @@ +import { fileTypeFromBuffer } from 'file-type'; +import { S3ErrEnum } from '@fastgpt/global/common/error/code/s3'; +import { serviceEnv } from '../../../env'; +import type { UploadConstraintsInput } from '../contracts/type'; +import { DEFAULT_CONTENT_TYPE, normalizeMimeType, resolveMimeType } from '../utils/mime'; +import { isLikelyTextBuffer } from '../../file/read/text'; +import { + createUploadExtensionRulesFromAllowedExtensions, + decodeFileName, + defaultInspectBytes, + detectOfficeDocumentMime, + getFilenameExtension, + getOfficeZipFormatByExtension, + isTextLikeMime, + mimesMatchForUpload, + normalizeAllowedExtensions, + normalizeFileExtension, + normalizeUploadExtensionRules, + officeZipInspectBytes, + replaceFilenameExtension, + resolveAllowedExtensionForMime, + resolveAllowedMimeTypes, + resolveExtensionForMime, + resolveExtensionRule +} from './utils'; +import type { ResolvedUploadFile, UploadFileEvidence, UploadFileHint, UploadPolicy } from './type'; + +const preferredTextFallbackExtensions = ['.txt', '.md', '.csv', '.json', '.html']; + +const normalizeUploadHint = (hint: UploadFileHint): UploadFileHint => { + const filename = decodeFileName(hint.declaredFilename || hint.filename); + return { + ...hint, + filename, + ...(hint.declaredExtension + ? { declaredExtension: normalizeFileExtension(hint.declaredExtension) } + : {}), + ...(hint.declaredFilename ? { declaredFilename: decodeFileName(hint.declaredFilename) } : {}), + ...(hint.contentType ? { contentType: normalizeMimeType(hint.contentType, '') } : {}) + }; +}; + +const resolveDeclaredExtension = (hint: UploadFileHint) => { + const declaredFilenameExtension = getFilenameExtension(hint.declaredFilename); + return declaredFilenameExtension || normalizeFileExtension(hint.declaredExtension); +}; + +const resolveTextFallbackExtension = (allowedExtensions: string[]) => { + return ( + preferredTextFallbackExtensions.find((extension) => allowedExtensions.includes(extension)) || + allowedExtensions.find((extension) => { + const mime = resolveMimeType([extension], ''); + return Boolean(mime) && isTextLikeMime(mime); + }) || + '' + ); +}; + +const resolveDefaultContentType = ({ + hint, + filename, + explicitExtension, + uploadConstraints, + allowedExtensions +}: { + hint: UploadFileHint; + filename: string; + explicitExtension: string; + uploadConstraints?: UploadConstraintsInput; + allowedExtensions: string[]; +}) => { + if (uploadConstraints?.defaultContentType) { + return normalizeMimeType(uploadConstraints.defaultContentType, DEFAULT_CONTENT_TYPE); + } + + const hintContentType = normalizeMimeType(hint.contentType, ''); + if (hintContentType) { + const hintedExtension = resolveExtensionForMime({ + mime: hintContentType, + allowedExtensions + }); + if ( + !allowedExtensions.length || + (hintedExtension && allowedExtensions.includes(hintedExtension)) + ) { + return hintContentType; + } + } + + return normalizeMimeType( + resolveMimeType([filename, explicitExtension], DEFAULT_CONTENT_TYPE), + DEFAULT_CONTENT_TYPE + ); +}; + +const createFallbackExtension = ({ + hint, + explicitExtension, + allowedExtensions, + defaultContentType +}: { + hint: UploadFileHint; + explicitExtension: string; + allowedExtensions: string[]; + defaultContentType: string; +}) => { + if (explicitExtension) return explicitExtension; + + const hintExtension = resolveExtensionForMime({ + mime: hint.contentType, + allowedExtensions + }); + if (hintExtension && (!allowedExtensions.length || allowedExtensions.includes(hintExtension))) { + return hintExtension; + } + + const defaultExtension = resolveExtensionForMime({ + mime: defaultContentType, + allowedExtensions + }); + if ( + defaultExtension && + (!allowedExtensions.length || allowedExtensions.includes(defaultExtension)) + ) { + return defaultExtension; + } + + return ''; +}; + +/** + * 构建服务端上传策略。预签阶段只校验明确违反 policy 的后缀,不因为缺后缀拒绝; + * 缺失的信息会留到上传阶段通过 evidence 或 declared hint 决策。 + */ +export const createUploadPolicy = ({ + hint, + uploadConstraints +}: { + hint: UploadFileHint; + uploadConstraints?: UploadConstraintsInput; +}): UploadPolicy => { + const normalizedHint = normalizeUploadHint(hint); + const filename = normalizedHint.filename; + const filenameExtension = getFilenameExtension(filename); + const declaredExtension = resolveDeclaredExtension(normalizedHint); + const explicitExtension = filenameExtension || declaredExtension; + const allowedExtensions = normalizeAllowedExtensions(uploadConstraints?.allowedExtensions); + + if ( + allowedExtensions.length > 0 && + explicitExtension && + !allowedExtensions.includes(explicitExtension) + ) { + throw new Error(S3ErrEnum.invalidUploadFileType); + } + + const baseRules = (() => { + const normalizedRules = normalizeUploadExtensionRules(uploadConstraints?.extensionRules); + if (!allowedExtensions.length) return normalizedRules; + + const ruleMap = new Map(normalizedRules.map((rule) => [rule.extension, rule])); + for (const rule of createUploadExtensionRulesFromAllowedExtensions(allowedExtensions)) { + if (!ruleMap.has(rule.extension)) { + ruleMap.set(rule.extension, rule); + } + } + return Array.from(ruleMap.values()).filter((rule) => + allowedExtensions.includes(rule.extension) + ); + })(); + + const explicitRule = explicitExtension + ? baseRules.find((rule) => rule.extension === explicitExtension) + : undefined; + const defaultContentType = + explicitRule?.verification === 'opaque' + ? DEFAULT_CONTENT_TYPE + : resolveDefaultContentType({ + hint: normalizedHint, + filename, + explicitExtension, + uploadConstraints, + allowedExtensions + }); + const fallbackExtension = createFallbackExtension({ + hint: normalizedHint, + explicitExtension, + allowedExtensions, + defaultContentType + }); + const textFallbackExtension = resolveTextFallbackExtension(allowedExtensions); + + return { + defaultContentType, + ...(allowedExtensions.length > 0 ? { allowedExtensions } : {}), + ...(baseRules.length > 0 ? { extensionRules: baseRules } : {}), + ...(allowedExtensions.length > 0 + ? { allowedMimeTypes: resolveAllowedMimeTypes(allowedExtensions) } + : {}), + ...(fallbackExtension ? { fallbackExtension } : {}), + ...(!filenameExtension ? { allowMissingExtension: true } : {}), + ...(textFallbackExtension ? { textFallbackExtension } : {}) + }; +}; + +export const getUploadInspectBytes = ({ + hint, + policy +}: { + hint?: UploadFileHint; + policy?: UploadPolicy; +} = {}) => { + const filenameExtension = getFilenameExtension(hint?.declaredFilename || hint?.filename); + const declaredExtension = normalizeFileExtension(hint?.declaredExtension); + const possibleExtensions = [ + filenameExtension, + declaredExtension, + ...(policy?.allowedExtensions || []) + ].filter(Boolean); + const contentType = normalizeMimeType(hint?.contentType, ''); + + if (possibleExtensions.some((extension) => getOfficeZipFormatByExtension(extension))) { + return officeZipInspectBytes; + } + if ( + contentType && + ['wordprocessingml.document', 'spreadsheetml.sheet', 'presentationml.presentation'].some( + (part) => contentType.includes(part) + ) + ) { + return officeZipInspectBytes; + } + + return defaultInspectBytes; +}; + +export const detectUploadFileEvidence = async ({ + buffer +}: { + buffer: Buffer; +}): Promise => { + const detected = await fileTypeFromBuffer(buffer).catch((error) => { + if (error?.name === 'EndOfStreamError' || error?.message === 'End-Of-Stream') { + return undefined; + } + throw error; + }); + const officeFormat = detectOfficeDocumentMime({ + buffer, + detectedMime: detected?.mime + }); + const detectedMime = officeFormat?.mime || detected?.mime; + + if (detectedMime) { + return { + detectedMime, + detectedExtension: officeFormat?.extension || normalizeFileExtension(detected?.ext), + isTextLike: false, + ...(officeFormat ? { officeExtension: officeFormat.extension } : {}), + source: officeFormat ? 'office-zip' : 'magic' + }; + } + + const isTextLike = isLikelyTextBuffer(buffer); + return { + isTextLike, + source: isTextLike ? 'text' : 'unknown' + }; +}; + +const resolveExpectedMime = ({ + filename, + extension, + policy +}: { + filename: string; + extension: string; + policy: UploadPolicy; +}) => { + return resolveMimeType([filename, extension], policy.defaultContentType); +}; + +const resolveAcceptedFilename = ({ + filename, + extension +}: { + filename: string; + extension: string; +}) => { + return extension ? replaceFilenameExtension(filename, extension) : filename; +}; + +/** + * 基于 policy、hint 和内容 evidence 做最终上传裁决。可内容验证类型必须由内容证明; + * opaque/custom 类型必须由显式后缀或 declared hint 与服务端白名单共同证明。 + */ +export const resolveUploadFile = ({ + hint, + policy, + evidence, + skipFileTypeCheck = serviceEnv.SKIP_FILE_TYPE_CHECK +}: { + hint: UploadFileHint; + policy: UploadPolicy; + evidence: UploadFileEvidence; + skipFileTypeCheck?: boolean; +}): ResolvedUploadFile => { + const normalizedHint = normalizeUploadHint(hint); + const filename = normalizedHint.declaredFilename || normalizedHint.filename; + const filenameExtension = getFilenameExtension(filename); + const declaredExtension = resolveDeclaredExtension(normalizedHint); + const explicitExtension = filenameExtension || declaredExtension; + const allowedExtensions = normalizeAllowedExtensions(policy.allowedExtensions); + + if ( + allowedExtensions.length > 0 && + explicitExtension && + !allowedExtensions.includes(explicitExtension) + ) { + throw new Error(S3ErrEnum.invalidUploadFileType); + } + + const explicitRule = resolveExtensionRule({ extension: explicitExtension, policy }); + const expectedMime = resolveExpectedMime({ + filename, + extension: explicitExtension, + policy + }); + + if (skipFileTypeCheck) { + const resolvedExtension = explicitExtension || policy.fallbackExtension || ''; + return { + filename: resolveAcceptedFilename({ filename, extension: resolvedExtension }), + contentType: explicitRule?.verification === 'opaque' ? DEFAULT_CONTENT_TYPE : expectedMime, + extension: resolvedExtension, + detectionSource: explicitRule?.verification === 'opaque' ? 'opaque-extension' : 'fallback', + correctedFilename: Boolean(resolvedExtension && resolvedExtension !== filenameExtension) + }; + } + + if (explicitRule?.verification === 'opaque') { + return { + filename: resolveAcceptedFilename({ filename, extension: explicitRule.extension }), + contentType: DEFAULT_CONTENT_TYPE, + extension: explicitRule.extension, + detectionSource: 'opaque-extension', + correctedFilename: explicitRule.extension !== filenameExtension + }; + } + + if (evidence.detectedMime) { + const matchedAllowedExtension = resolveAllowedExtensionForMime({ + allowedExtensions, + mime: evidence.detectedMime + }); + const detectedMatchesExpected = + expectedMime !== DEFAULT_CONTENT_TYPE && + mimesMatchForUpload(expectedMime, evidence.detectedMime); + + // 显式的可验证后缀必须与内容一致,不能因为检测出的另一种类型也在白名单中就静默改名。 + if (explicitExtension && !detectedMatchesExpected) { + throw new Error(S3ErrEnum.uploadFileTypeMismatch); + } + + const detectedMatchesPolicy = (() => { + if (!allowedExtensions.length) { + return expectedMime === DEFAULT_CONTENT_TYPE || detectedMatchesExpected; + } + + return Boolean(matchedAllowedExtension) || detectedMatchesExpected; + })(); + + if (!detectedMatchesPolicy) { + throw new Error(S3ErrEnum.uploadFileTypeMismatch); + } + + const resolvedExtension = explicitExtension + ? explicitExtension + : matchedAllowedExtension || evidence.officeExtension || evidence.detectedExtension || ''; + return { + filename: resolveAcceptedFilename({ filename, extension: resolvedExtension }), + contentType: evidence.detectedMime, + extension: resolvedExtension, + detectionSource: evidence.source === 'office-zip' ? 'office-zip' : 'magic', + correctedFilename: Boolean(resolvedExtension && resolvedExtension !== filenameExtension) + }; + } + + if (evidence.isTextLike) { + const textExtension = (() => { + if (explicitExtension) { + if (explicitRule?.verification === 'text') return explicitExtension; + + // 文本只能匹配显式声明的文本类型;fallback 仅用于真正缺少后缀的输入。 + throw new Error(S3ErrEnum.uploadFileTypeMismatch); + } + + if (policy.textFallbackExtension) return policy.textFallbackExtension; + + const hintedExtension = resolveExtensionForMime({ + mime: normalizedHint.contentType, + allowedExtensions + }); + const hintedRule = resolveExtensionRule({ extension: hintedExtension, policy }); + return hintedRule?.verification === 'text' ? hintedExtension : ''; + })(); + + if (textExtension && (!allowedExtensions.length || allowedExtensions.includes(textExtension))) { + return { + filename: resolveAcceptedFilename({ filename, extension: textExtension }), + contentType: resolveMimeType([textExtension], expectedMime), + extension: textExtension, + detectionSource: 'text', + correctedFilename: textExtension !== filenameExtension + }; + } + } + + if (!allowedExtensions.length) { + return { + filename, + contentType: expectedMime, + extension: explicitExtension, + detectionSource: 'fallback', + correctedFilename: false + }; + } + + if (!explicitExtension) { + throw new Error(S3ErrEnum.invalidUploadFileType); + } + + throw new Error(S3ErrEnum.invalidUploadFileType); +}; diff --git a/packages/service/common/s3/uploadPolicy/type.ts b/packages/service/common/s3/uploadPolicy/type.ts new file mode 100644 index 000000000000..9c26277ad457 --- /dev/null +++ b/packages/service/common/s3/uploadPolicy/type.ts @@ -0,0 +1,57 @@ +import z from 'zod'; + +export const UploadExtensionRuleSchema = z.object({ + extension: z.string().nonempty(), + source: z.enum(['builtin', 'custom']).default('builtin'), + verification: z.enum(['content', 'text', 'opaque']).default('content') +}); +export type UploadExtensionRule = z.infer; + +export const UploadPolicySchema = z.object({ + defaultContentType: z.string().nonempty(), + allowedExtensions: z.array(z.string().nonempty()).optional(), + extensionRules: z.array(UploadExtensionRuleSchema).optional(), + allowedMimeTypes: z.array(z.string().nonempty()).optional(), + fallbackExtension: z.string().nonempty().optional(), + allowMissingExtension: z.boolean().optional(), + textFallbackExtension: z.string().nonempty().optional() +}); +export type UploadPolicy = z.infer; + +export const UploadFileHintSchema = z.object({ + filename: z.string().min(1), + contentType: z.string().min(1).optional(), + declaredExtension: z.string().min(1).optional(), + declaredFilename: z.string().min(1).optional(), + source: z.enum(['local-file', 'remote-url', 'server-generated']).optional(), + size: z.number().int().nonnegative().optional() +}); +export type UploadFileHint = z.infer; + +export const UploadFileEvidenceSchema = z.object({ + detectedMime: z.string().optional(), + detectedExtension: z.string().optional(), + isTextLike: z.boolean(), + officeExtension: z.enum(['.docx', '.xlsx', '.pptx']).optional(), + source: z.enum(['magic', 'office-zip', 'text', 'unknown']) +}); +export type UploadFileEvidence = z.infer; + +export const ResolvedUploadFileSchema = z.object({ + filename: z.string().min(1), + contentType: z.string().min(1), + extension: z.string(), + detectionSource: z.enum(['magic', 'office-zip', 'text', 'hint', 'fallback', 'opaque-extension']), + correctedFilename: z.boolean() +}); +export type ResolvedUploadFile = z.infer; + +export const UploadRejectReasonSchema = z.enum([ + 'extension-not-allowed', + 'detected-mime-not-allowed', + 'text-fallback-not-allowed', + 'unknown-binary-with-allow-list', + 'opaque-extension-required', + 'office-zip-marker-mismatch' +]); +export type UploadRejectReason = z.infer; diff --git a/packages/service/common/s3/uploadPolicy/utils.ts b/packages/service/common/s3/uploadPolicy/utils.ts new file mode 100644 index 000000000000..2f65c15284d7 --- /dev/null +++ b/packages/service/common/s3/uploadPolicy/utils.ts @@ -0,0 +1,301 @@ +import { + defaultFileExtensionTypes, + type FileExtensionKeyType +} from '@fastgpt/global/core/app/constants'; +import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; +import path from 'node:path'; +import { + DEFAULT_CONTENT_TYPE, + normalizeMimeType, + resolveMimeExtension, + resolveMimeType +} from '../utils/mime'; +import type { UploadExtensionRule, UploadPolicy } from './type'; + +const uploadConfigKeys: FileExtensionKeyType[] = [ + 'canSelectFile', + 'canSelectImg', + 'canSelectVideo', + 'canSelectAudio', + 'canSelectCustomFileExtension' +]; + +const textLikeMimePrefixes = ['text/']; +const textLikeMimeSet = new Set([ + 'application/javascript', + 'application/json', + 'application/ld+json', + 'application/markdown', + 'application/x-javascript', + 'application/xml', + 'image/svg+xml' +]); +const textLikeExtensions = new Set([ + '.csv', + '.htm', + '.html', + '.json', + '.log', + '.md', + '.markdown', + '.svg', + '.txt', + '.xml', + '.yaml', + '.yml' +]); + +export const defaultInspectBytes = 8192; +export const officeZipInspectBytes = 64 * 1024; + +export const officeZipFormats = [ + { + extension: '.docx', + mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + markers: ['word/', 'word/document.xml'] + }, + { + extension: '.xlsx', + mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + markers: ['xl/', 'xl/workbook.xml'] + }, + { + extension: '.pptx', + mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + markers: ['ppt/', 'ppt/presentation.xml'] + } +] as const; + +/** + * 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单 + * 在预签、上传校验和 metadata 修正阶段出现不同表示。 + */ +export const normalizeFileExtension = (extension?: string) => { + if (!extension) return ''; + + const trimmedExtension = extension.trim().toLowerCase(); + if (!trimmedExtension) return ''; + + return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`; +}; + +export const normalizeAllowedExtensions = (extensions?: string[]) => { + if (!extensions?.length) return []; + + return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))]; +}; + +export const parseAllowedExtensions = (value: string) => { + return normalizeAllowedExtensions(value.split(',')); +}; + +export const decodeFileName = (filename?: string) => { + if (!filename) return ''; + try { + return decodeURIComponent(filename); + } catch { + return filename; + } +}; + +export const getFilenameExtension = (filename?: string) => { + return normalizeFileExtension(path.extname(decodeFileName(filename))); +}; + +export const isTextLikeMime = (mime: string) => { + const normalizedMime = normalizeMimeType(mime, ''); + return ( + textLikeMimePrefixes.some((prefix) => normalizedMime.startsWith(prefix)) || + textLikeMimeSet.has(normalizedMime) + ); +}; + +const isTextLikeExtension = (extension: string) => { + const normalizedExtension = normalizeFileExtension(extension); + if (textLikeExtensions.has(normalizedExtension)) return true; + + const mime = resolveMimeType([normalizedExtension], ''); + return Boolean(mime) && isTextLikeMime(mime); +}; + +export const replaceFilenameExtension = (filename: string, extension: string) => { + const normalizedExtension = normalizeFileExtension(extension); + if (!normalizedExtension) return filename; + + const currentExtension = getFilenameExtension(filename); + if (!currentExtension) { + return `${filename}${normalizedExtension}`; + } + + return `${filename.slice(0, -currentExtension.length)}${normalizedExtension}`; +}; + +export const getOfficeZipFormatByExtension = (extension: string) => + officeZipFormats.find((format) => format.extension === normalizeFileExtension(extension)); + +export const detectOfficeDocumentMime = ({ + buffer, + detectedMime +}: { + buffer: Buffer; + detectedMime?: string; +}) => { + if (detectedMime && detectedMime !== 'application/zip') return; + + return officeZipFormats.find((format) => + format.markers.some((marker) => buffer.includes(Buffer.from(marker, 'utf8'))) + ); +}; + +/** + * mime-types(按扩展名)与 file-type(按魔数)对同一容器可能给出不同登记名,例如 .avi: + * lookup → video/x-msvideo,file-type → video/vnd.avi。.mpeg:lookup → video/mpeg,file-type 可能为 + * video/MP1S(MPEG-1 PS)、video/MP2P(MPEG-2 PS)或 video/mpeg(模糊检测)。 + * .m4a:lookup → audio/mp4(RFC),file-type(ftyp M4A)→ audio/x-m4a。 + * 比较前统一小写(忽略参数、大小写差异)。 + */ +const MIME_EQUIVALENCE_GROUPS: ReadonlyArray> = [ + new Set(['video/x-msvideo', 'video/vnd.avi', 'video/avi', 'video/msvideo']), + new Set(['video/mpeg', 'video/mp1s', 'video/mp2p']), + new Set(['audio/mp4', 'audio/x-m4a']) +]; + +const normalizeMimeForCompare = (mime: string) => mime.split(';')[0]?.trim().toLowerCase() || ''; + +export const mimesMatchForUpload = (expected: string, detected: string): boolean => { + const normalizedExpected = normalizeMimeForCompare(expected); + const normalizedDetected = normalizeMimeForCompare(detected); + if (normalizedExpected === normalizedDetected) return true; + for (const group of MIME_EQUIVALENCE_GROUPS) { + if (group.has(normalizedExpected) && group.has(normalizedDetected)) return true; + } + return false; +}; + +export const resolveAllowedExtensionForMime = ({ + allowedExtensions, + mime +}: { + allowedExtensions: string[]; + mime: string; +}) => { + return ( + allowedExtensions.find((extension) => { + const allowedMime = resolveMimeType([extension], ''); + return Boolean(allowedMime) && mimesMatchForUpload(allowedMime, mime); + }) || '' + ); +}; + +export const resolveAllowedMimeTypes = (extensions: string[]) => { + return [ + ...new Set( + normalizeAllowedExtensions(extensions) + .map((extension) => resolveMimeType([extension], '')) + .filter(Boolean) + ) + ]; +}; + +export const resolveExtensionForMime = ({ + mime, + allowedExtensions +}: { + mime?: string; + allowedExtensions?: string[]; +}) => { + if (!mime) return ''; + + const normalizedMime = normalizeMimeType(mime, ''); + const allowedExtension = resolveAllowedExtensionForMime({ + allowedExtensions: normalizeAllowedExtensions(allowedExtensions), + mime: normalizedMime + }); + if (allowedExtension) return allowedExtension; + + return resolveMimeExtension(normalizedMime); +}; + +const inferExtensionVerification = (extension: string): UploadExtensionRule['verification'] => { + const normalizedExtension = normalizeFileExtension(extension); + if (!normalizedExtension) return 'opaque'; + if (isTextLikeExtension(normalizedExtension)) return 'text'; + + const mime = resolveMimeType([normalizedExtension], ''); + if (!mime || mime === DEFAULT_CONTENT_TYPE) return 'opaque'; + return 'content'; +}; + +export const createUploadExtensionRulesFromAllowedExtensions = ( + extensions?: string[] +): UploadExtensionRule[] => { + return normalizeAllowedExtensions(extensions).map((extension) => ({ + extension, + source: 'builtin', + verification: inferExtensionVerification(extension) + })); +}; + +export const createUploadExtensionRulesFromFileSelectConfig = ( + config?: AppFileSelectConfigType +): UploadExtensionRule[] => { + if (!config) return []; + + const rules = uploadConfigKeys.flatMap((key) => { + if (!config[key]) return []; + + const extensions = + key === 'canSelectCustomFileExtension' + ? config.customFileExtensionList || [] + : defaultFileExtensionTypes[key]; + + return normalizeAllowedExtensions(extensions).map((extension) => ({ + extension, + source: key === 'canSelectCustomFileExtension' ? 'custom' : 'builtin', + verification: + key === 'canSelectCustomFileExtension' ? 'opaque' : inferExtensionVerification(extension) + })); + }); + + const ruleMap = new Map(); + for (const rule of rules) { + if (!ruleMap.has(rule.extension)) { + ruleMap.set(rule.extension, rule); + } + } + + return Array.from(ruleMap.values()); +}; + +export const normalizeUploadExtensionRules = (rules?: UploadExtensionRule[]) => { + if (!rules?.length) return []; + + return Array.from( + new Map( + rules + .map((rule) => ({ + ...rule, + extension: normalizeFileExtension(rule.extension) + })) + .filter((rule) => Boolean(rule.extension)) + .map((rule) => [rule.extension, rule]) + ).values() + ); +}; + +export const resolveExtensionRule = ({ + extension, + policy +}: { + extension?: string; + policy: UploadPolicy; +}) => { + const normalizedExtension = normalizeFileExtension(extension); + if (!normalizedExtension) return; + + const rules = normalizeUploadExtensionRules(policy.extensionRules); + return ( + rules.find((rule) => rule.extension === normalizedExtension) || + createUploadExtensionRulesFromAllowedExtensions([normalizedExtension])[0] + ); +}; diff --git a/packages/service/common/s3/utils/uploadConstraints.ts b/packages/service/common/s3/utils/uploadConstraints.ts index 6956c1fce133..67b6080c00b1 100644 --- a/packages/service/common/s3/utils/uploadConstraints.ts +++ b/packages/service/common/s3/utils/uploadConstraints.ts @@ -4,10 +4,14 @@ import { type FileExtensionKeyType } from '@fastgpt/global/core/app/constants'; import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; -import { S3ErrEnum } from '@fastgpt/global/common/error/code/s3'; import type { UploadConstraintsInput, UploadConstraints } from '../contracts/type'; -import { DEFAULT_CONTENT_TYPE, normalizeMimeType, resolveMimeType } from './mime'; -import path from 'node:path'; +import { + createUploadExtensionRulesFromFileSelectConfig, + normalizeAllowedExtensions, + normalizeFileExtension, + parseAllowedExtensions +} from '../uploadPolicy/utils'; +import { createUploadPolicy } from '../uploadPolicy/service'; const uploadConfigKeys: FileExtensionKeyType[] = [ 'canSelectFile', @@ -17,24 +21,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [ 'canSelectCustomFileExtension' ]; -export const normalizeFileExtension = (extension?: string) => { - if (!extension) return ''; - - const trimmedExtension = extension.trim().toLowerCase(); - if (!trimmedExtension) return ''; - - return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`; -}; - -export const normalizeAllowedExtensions = (extensions?: string[]) => { - if (!extensions?.length) return []; - - return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))]; -}; - -export const parseAllowedExtensions = (value: string) => { - return normalizeAllowedExtensions(value.split(',')); -}; +export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions }; export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']); export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType); @@ -55,30 +42,35 @@ export const getAllowedExtensionsFromFileSelectConfig = (config?: AppFileSelectC return normalizeAllowedExtensions(extensions); }; +export const getUploadExtensionRulesFromFileSelectConfig = + createUploadExtensionRulesFromFileSelectConfig; + export const createUploadConstraints = ({ filename, - uploadConstraints + uploadConstraints, + contentType, + declaredExtension, + declaredFilename, + source, + size }: { filename: string; uploadConstraints?: UploadConstraintsInput; + contentType?: string; + declaredExtension?: string; + declaredFilename?: string; + source?: 'local-file' | 'remote-url' | 'server-generated'; + size?: number; }): UploadConstraints => { - const allowedExtensions = normalizeAllowedExtensions(uploadConstraints?.allowedExtensions); - const fileExtension = normalizeFileExtension(path.extname(filename)); - - if ( - allowedExtensions.length > 0 && - (!fileExtension || !allowedExtensions.includes(fileExtension)) - ) { - throw new Error(S3ErrEnum.invalidUploadFileType); - } - - const defaultContentType = normalizeMimeType( - uploadConstraints?.defaultContentType || resolveMimeType([filename], DEFAULT_CONTENT_TYPE), - DEFAULT_CONTENT_TYPE - ); - - return { - defaultContentType, - ...(allowedExtensions.length > 0 ? { allowedExtensions } : {}) - }; + return createUploadPolicy({ + hint: { + filename, + ...(contentType ? { contentType } : {}), + ...(declaredExtension ? { declaredExtension } : {}), + ...(declaredFilename ? { declaredFilename } : {}), + ...(source ? { source } : {}), + ...(size ? { size } : {}) + }, + uploadConstraints + }); }; diff --git a/packages/service/common/s3/validation/upload.ts b/packages/service/common/s3/validation/upload.ts index 10177e01e3ed..558ead2cf97b 100644 --- a/packages/service/common/s3/validation/upload.ts +++ b/packages/service/common/s3/validation/upload.ts @@ -1,235 +1,62 @@ -import { fileTypeFromBuffer } from 'file-type'; -import { S3ErrEnum } from '@fastgpt/global/common/error/code/s3'; -import path from 'node:path'; import type { UploadConstraints } from '../contracts/type'; -import { DEFAULT_CONTENT_TYPE, resolveMimeType } from '../utils/mime'; -import { normalizeAllowedExtensions, normalizeFileExtension } from '../utils/uploadConstraints'; -import { serviceEnv } from '../../../env'; - -const defaultInspectBytes = 8192; -const officeZipInspectBytes = 64 * 1024; -const textLikeMimePrefixes = ['text/']; -const textLikeMimeSet = new Set([ - 'application/json', - 'application/javascript', - 'application/xml', - 'image/svg+xml' -]); -const officeZipFormats = [ - { - extension: '.docx', - mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - markers: ['word/', 'word/document.xml'] - }, - { - extension: '.xlsx', - mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - markers: ['xl/', 'xl/workbook.xml'] - }, - { - extension: '.pptx', - mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - markers: ['ppt/', 'ppt/presentation.xml'] - } -] as const; - -const decodeFileName = (filename?: string) => { - if (!filename) return ''; - try { - return decodeURIComponent(filename); - } catch { - return filename; - } -}; - -const isLikelyTextBuffer = (buffer: Buffer) => { - if (buffer.length === 0) return true; - - let suspiciousBytes = 0; - for (const byte of buffer) { - if (byte === 0) return false; - - if (byte < 7 || (byte > 14 && byte < 32)) { - suspiciousBytes += 1; - } - } - - return suspiciousBytes / buffer.length < 0.1; -}; - -const replaceFilenameExtension = (filename: string, extension: string) => { - const normalizedExtension = normalizeFileExtension(extension); - if (!normalizedExtension) return filename; - - const currentExtension = normalizeFileExtension(path.extname(filename)); - if (!currentExtension) { - return `${filename}${normalizedExtension}`; +import type { UploadFileHint, UploadPolicy } from '../uploadPolicy/type'; +import { + createUploadPolicy, + detectUploadFileEvidence, + getUploadInspectBytes as getPolicyUploadInspectBytes, + resolveUploadFile +} from '../uploadPolicy/service'; + +export const getUploadInspectBytes = ( + filenameOrParams?: + | string + | { + hint?: UploadFileHint; + policy?: UploadPolicy; + } +) => { + if (typeof filenameOrParams === 'string' || filenameOrParams === undefined) { + return getPolicyUploadInspectBytes({ + hint: filenameOrParams ? { filename: filenameOrParams } : undefined + }); } - return `${filename.slice(0, -currentExtension.length)}${normalizedExtension}`; + return getPolicyUploadInspectBytes(filenameOrParams); }; -const resolveExpectedMime = ({ - filename, - extension, - uploadConstraints -}: { - filename: string; - extension: string; - uploadConstraints: UploadConstraints; -}) => { - return resolveMimeType([filename, extension], uploadConstraints.defaultContentType); -}; - -const isTextLikeMime = (mime: string) => { - return ( - textLikeMimePrefixes.some((prefix) => mime.startsWith(prefix)) || textLikeMimeSet.has(mime) - ); -}; - -const getOfficeZipFormatByExtension = (extension: string) => - officeZipFormats.find((format) => format.extension === extension); - /** - * mime-types(按扩展名)与 file-type(按魔数)对同一容器可能给出不同登记名,例如 .avi: - * lookup → video/x-msvideo,file-type → video/vnd.avi。.mpeg:lookup → video/mpeg,file-type 可能为 - * video/MP1S(MPEG-1 PS)、video/MP2P(MPEG-2 PS)或 video/mpeg(模糊检测)。 - * .m4a:lookup → audio/mp4(RFC),file-type(ftyp M4A)→ audio/x-m4a。 - * 比较前统一小写(忽略参数、大小写差异)。 + * 校验上传文件内容并返回最终写入 metadata 的文件信息。 + * + * 兼容旧调用方的 `filename + uploadConstraints` 入参;新短上传链路应优先传 + * `fileHint + uploadPolicy`,避免把客户端 hint、服务端策略和内容 evidence 混在一起。 */ -const MIME_EQUIVALENCE_GROUPS: ReadonlyArray> = [ - new Set(['video/x-msvideo', 'video/vnd.avi', 'video/avi', 'video/msvideo']), - new Set(['video/mpeg', 'video/mp1s', 'video/mp2p']), - new Set(['audio/mp4', 'audio/x-m4a']) -]; - -const normalizeMimeForCompare = (mime: string) => mime.split(';')[0]?.trim().toLowerCase() || ''; - -const mimesMatchForUpload = (expected: string, detected: string): boolean => { - const e = normalizeMimeForCompare(expected); - const d = normalizeMimeForCompare(detected); - if (e === d) return true; - for (const group of MIME_EQUIVALENCE_GROUPS) { - if (group.has(e) && group.has(d)) return true; - } - return false; -}; - -const resolveAllowedExtensionForMime = ({ - allowedExtensions, - mime -}: { - allowedExtensions: string[]; - mime: string; -}) => { - return ( - allowedExtensions.find((extension) => { - const allowedMime = resolveMimeType([extension], ''); - return Boolean(allowedMime) && mimesMatchForUpload(allowedMime, mime); - }) || '' - ); -}; - -const detectOfficeDocumentMime = ({ - buffer, - detectedMime -}: { - buffer: Buffer; - detectedMime?: string; -}) => { - if (detectedMime && detectedMime !== 'application/zip') return; - - return officeZipFormats.find((format) => - format.markers.some((marker) => buffer.includes(Buffer.from(marker, 'utf8'))) - ); -}; - -export const getUploadInspectBytes = (filename?: string) => { - const extension = normalizeFileExtension(path.extname(decodeFileName(filename))); - - return getOfficeZipFormatByExtension(extension) ? officeZipInspectBytes : defaultInspectBytes; -}; - export async function validateUploadFile({ buffer, filename, - uploadConstraints + uploadConstraints, + uploadPolicy, + fileHint }: { buffer: Buffer; filename?: string; uploadConstraints: UploadConstraints; + uploadPolicy?: UploadPolicy; + fileHint?: UploadFileHint; }) { - const normalizedFileName = decodeFileName(filename); - const extension = normalizeFileExtension(path.extname(normalizedFileName)); - const allowedExtensions = normalizeAllowedExtensions(uploadConstraints.allowedExtensions); - - if (allowedExtensions.length > 0 && (!extension || !allowedExtensions.includes(extension))) { - throw new Error(S3ErrEnum.invalidUploadFileType); - } - - const expectedMime = resolveExpectedMime({ - filename: normalizedFileName, - extension, - uploadConstraints - }); - - if (serviceEnv.SKIP_FILE_TYPE_CHECK) { - return { - filename: normalizedFileName, - contentType: expectedMime - }; - } - - const detected = await fileTypeFromBuffer(buffer).catch((error) => { - if (error?.name === 'EndOfStreamError' || error?.message === 'End-Of-Stream') { - return undefined; - } - throw error; - }); - const officeFormat = detectOfficeDocumentMime({ - buffer, - detectedMime: detected?.mime + const hint = fileHint || { + filename: filename || 'file' + }; + const policy = + uploadPolicy || + createUploadPolicy({ + hint, + uploadConstraints + }); + const evidence = await detectUploadFileEvidence({ buffer }); + + return resolveUploadFile({ + hint, + policy, + evidence }); - const detectedMime = officeFormat?.mime || detected?.mime; - - if (detectedMime) { - if (expectedMime !== DEFAULT_CONTENT_TYPE && !mimesMatchForUpload(expectedMime, detectedMime)) { - const matchedAllowedExtension = resolveAllowedExtensionForMime({ - allowedExtensions, - mime: detectedMime - }); - - if (!matchedAllowedExtension) { - throw new Error(S3ErrEnum.uploadFileTypeMismatch); - } - - return { - filename: - matchedAllowedExtension !== extension - ? replaceFilenameExtension(normalizedFileName, matchedAllowedExtension) - : normalizedFileName, - contentType: detectedMime - }; - } - return { - filename: normalizedFileName, - contentType: detectedMime - }; - } - - if (isTextLikeMime(expectedMime) && isLikelyTextBuffer(buffer)) { - return { - filename: normalizedFileName, - contentType: expectedMime - }; - } - - if (!extension || expectedMime === DEFAULT_CONTENT_TYPE) { - return { - filename: normalizedFileName, - contentType: expectedMime - }; - } - - throw new Error(S3ErrEnum.invalidUploadFileType); } diff --git a/packages/service/core/ai/llm/agentLoop/domain/event.ts b/packages/service/core/ai/llm/agentLoop/domain/event.ts index f3ac0371f77d..9bb1797d9e40 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/event.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/event.ts @@ -6,6 +6,7 @@ import type { import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentAskPayload } from './systemTool/ask'; import type { AgentLoopUsage } from './usage'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; export type AgentLoopToolResponseCompress = { response: string; @@ -84,6 +85,7 @@ export type AgentLoopEvent = errorMessage?: string; seconds: number; usages?: AgentLoopUsage[]; + fileRefs?: SandboxFileRef[]; toolResponseCompress?: AgentLoopToolResponseCompress; /** executeTool 返回的 opaque metadata,agent-loop 不解释其业务结构。 */ metadata?: unknown; diff --git a/packages/service/core/ai/llm/agentLoop/domain/tool.ts b/packages/service/core/ai/llm/agentLoop/domain/tool.ts index 77233d239b99..8854c1fc42c9 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/tool.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/tool.ts @@ -6,6 +6,7 @@ import type { import type { SandboxClient } from '../../../sandbox/interface/runtime'; import type { AgentLoopDatasetSearchExecutor } from './systemTool/datasetSearch'; import type { AgentLoopUsage } from './usage'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; export type AgentLoopToolCatalog = { runtimeTools: ChatCompletionTool[]; @@ -30,6 +31,7 @@ export type AgentLoopToolExecutionResult = { stop?: boolean; skipResponseCompress?: boolean; errorMessage?: string; + fileRefs?: SandboxFileRef[]; /** 由调用方透传并在 agent-loop 外部解释的工具运行元数据。 */ metadata?: unknown; }; diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts index 560954d4bcb6..814b007dbd2f 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/base.ts @@ -23,6 +23,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; import { batchRun } from '@fastgpt/global/common/system/utils'; import { normalizeToolResponseContent } from '@fastgpt/global/core/ai/llm/utils'; import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; type RunAgentCallProps = { maxRunAgentTimes: number; @@ -87,6 +88,7 @@ type RunAgentCallProps = { errorMessage?: string; seconds: number; usages?: AgentLoopUsage[]; + fileRefs?: SandboxFileRef[]; metadata?: unknown; toolResponseCompress?: { response: string; @@ -290,6 +292,7 @@ export const runAgentLoop = async ({ interactive, stop, errorMessage, + fileRefs, metadata } = await (async () => { try { @@ -317,6 +320,7 @@ export const runAgentLoop = async ({ errorMessage: interactiveToolErrorMessage || errorMessage, seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2), usages: toolUsages, + fileRefs, metadata }); @@ -526,6 +530,7 @@ export const runAgentLoop = async ({ stop: stopLoop, skipResponseCompress, errorMessage, + fileRefs, metadata } = await (async () => { try { @@ -589,6 +594,7 @@ export const runAgentLoop = async ({ : {}), seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2), usages: [...toolUsages, ...(toolResponseCompress ? [toolResponseCompress.usage] : [])], + fileRefs, toolResponseCompress, metadata }); diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts index 03d0013e00d6..eb8533559b16 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts @@ -353,6 +353,7 @@ export const runFastAgentMainLoop = async ({ seconds, errorMessage, usages, + fileRefs, toolResponseCompress, metadata }) => { @@ -368,6 +369,7 @@ export const runFastAgentMainLoop = async ({ seconds, errorMessage, usages, + fileRefs, toolResponseCompress, metadata }); @@ -458,7 +460,8 @@ export const runFastAgentMainLoop = async ({ }); return createToolResponse(sandboxResult.response, { skipResponseCompress: true, - errorMessage: sandboxResult.success ? undefined : sandboxResult.response + errorMessage: sandboxResult.success ? undefined : sandboxResult.response, + fileRefs: sandboxResult.fileRefs }); } diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts index 99e4087f80bd..9183fc2473e9 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts @@ -305,6 +305,7 @@ export const runPiAgentLoop = async ({ assistantMessages: result.assistantMessages, usages: result.usages, errorMessage: result.errorMessage, + fileRefs: result.fileRefs, metadata: result.metadata, seconds: +((Date.now() - startedAt) / 1000).toFixed(2) }); diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts index dc2f953dda2e..a9935e6a80d9 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/tool/build.ts @@ -33,6 +33,7 @@ import { import { runSandboxTools } from '../../../../../sandbox/interface/toolCall'; import { createToolCall, normalizeToolArgs, stringifyJson } from '../message'; import { getPiAgentRuntimeTools } from './catalog'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; type PlanOperationEvent = Extract; @@ -104,6 +105,7 @@ export const buildPiAgentTools = async ({ interactive?: TChildrenResponse; stop?: boolean; errorMessage?: string; + fileRefs?: SandboxFileRef[]; metadata?: unknown; }>; }) => { @@ -131,6 +133,7 @@ export const buildPiAgentTools = async ({ assistantMessages, usages, errorMessage: result.errorMessage, + fileRefs: result.fileRefs, metadata: result.metadata, seconds: +((Date.now() - startedAt) / 1000).toFixed(2) }); @@ -293,6 +296,7 @@ export const buildPiAgentTools = async ({ response: sandboxResult.response, assistantMessages: [], usages: [], + fileRefs: sandboxResult.fileRefs, errorMessage: sandboxResult.success ? undefined : sandboxResult.response }; } diff --git a/packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts b/packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts index 1bb5c0243a87..e9ad0fe0d5e6 100644 --- a/packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts +++ b/packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts @@ -44,15 +44,20 @@ export const sandboxGetFileUrlTool = defineTool({ const { url: fileUrl } = await chatBucket.createGetChatFileURL({ key, expiredHours: 2, - external: true, - mode: 'presigned' + external: true }); - return { fileUrl, filename }; + return { + responseFile: { fileUrl, filename }, + fileRef: { key, filename, url: fileUrl } + }; }) ); - return { response: JSON.stringify(result) }; + return { + response: JSON.stringify(result.map((item) => item.responseFile)), + fileRefs: result.map((item) => item.fileRef) + }; } }); diff --git a/packages/service/core/ai/sandbox/application/toolCall/index.ts b/packages/service/core/ai/sandbox/application/toolCall/index.ts index 1f28ddd6b032..911260a64d53 100644 --- a/packages/service/core/ai/sandbox/application/toolCall/index.ts +++ b/packages/service/core/ai/sandbox/application/toolCall/index.ts @@ -22,6 +22,7 @@ import { getSandboxRuntimeProfile } from '../../infrastructure/provider/runtimeP import { preparePackageMirrors, prepareSandbox } from '../runtime/prepare'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { getRunningSandboxId } from '../../utils/id'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; const ToolMap = { ...editFileToolMap, @@ -38,6 +39,7 @@ export type SandboxToolCallResult = { success: boolean; input: Record; response: string; + fileRefs?: SandboxFileRef[]; durationSeconds: number; }; @@ -89,6 +91,7 @@ export const runSandboxTools = async ({ success: true, input: parsedArgs.data, response: result.response, + ...(result.fileRefs?.length ? { fileRefs: result.fileRefs } : {}), durationSeconds: getDuration() }; }; diff --git a/packages/service/core/ai/sandbox/application/toolCall/type.ts b/packages/service/core/ai/sandbox/application/toolCall/type.ts index 4ee82ba86458..95fe336ef634 100644 --- a/packages/service/core/ai/sandbox/application/toolCall/type.ts +++ b/packages/service/core/ai/sandbox/application/toolCall/type.ts @@ -5,6 +5,7 @@ */ import type { z } from 'zod'; import type { SandboxClient } from '../runtime/client'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; type ToolExecuteContext

= { sandboxInstance: SandboxClient; @@ -18,7 +19,9 @@ type ToolExecuteContext

= { */ export type ToolDefinition = { zodSchema: S; - execute: (ctx: ToolExecuteContext>) => Promise<{ response: string }>; + execute: ( + ctx: ToolExecuteContext> + ) => Promise<{ response: string; fileRefs?: SandboxFileRef[] }>; }; /** diff --git a/packages/service/core/chat/fileContext.ts b/packages/service/core/chat/fileContext.ts index 4505ab7d0937..0f6fe4ba4048 100644 --- a/packages/service/core/chat/fileContext.ts +++ b/packages/service/core/chat/fileContext.ts @@ -23,6 +23,18 @@ import { replaceS3KeyToPreviewUrl } from '../dataset/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { getUserFilesPrompt, injectUserQueryPrompt } from '../ai/llm/prompt'; import { getAxiosHeaderValue } from '@fastgpt/global/common/axios/utils'; +import { + DEFAULT_CONTENT_TYPE, + normalizeMimeType, + resolveMimeExtension +} from '../../common/s3/utils/mime'; +import { isLikelyTextBuffer } from '../../common/file/read/text'; +import { + S3_ACCESS_LINK_ROUTES, + S3SignedDownloadAliasValueSchema, + verifyS3DownloadAccess, + type VerifiedS3DownloadAccess +} from '../../common/s3/accessLink'; type GetFileProps = { requestOrigin?: string; @@ -33,6 +45,172 @@ type GetFileProps = { usageId?: string; }; +const readableFileExtensions = new Set(['txt', 'md', 'html', 'pdf', 'docx', 'pptx', 'csv', 'xlsx']); + +const normalizeReadableExtension = (extension?: string) => + extension?.trim().toLowerCase().replace(/^\./, '') || ''; + +const resolveSupportedReadableExtension = (extension?: string) => { + const normalizedExtension = normalizeReadableExtension(extension); + const aliasExtension = (() => { + if (normalizedExtension === 'markdown') return 'md'; + if (normalizedExtension === 'htm') return 'html'; + return normalizedExtension; + })(); + + return readableFileExtensions.has(aliasExtension) ? aliasExtension : ''; +}; + +const fileTypeIncludesExtension = (fileTypes: string, extension: string) => + !!extension && fileTypes.split(',').some((item) => item.trim() === extension); + +const resolveMediaChatFileTypeFromFilename = (filename?: string) => { + const extension = path.extname(filename || '').toLowerCase(); + if (fileTypeIncludesExtension(imageFileType, extension)) return ChatFileTypeEnum.image; + if (fileTypeIncludesExtension(audioFileType, extension)) return ChatFileTypeEnum.audio; + if (fileTypeIncludesExtension(videoFileType, extension)) return ChatFileTypeEnum.video; +}; + +const resolveMediaChatFileTypeFromContentType = (contentType?: string) => { + const normalizedContentType = normalizeMimeType(contentType, ''); + if (normalizedContentType.startsWith('image/')) return ChatFileTypeEnum.image; + if (normalizedContentType.startsWith('audio/')) return ChatFileTypeEnum.audio; + if (normalizedContentType.startsWith('video/')) return ChatFileTypeEnum.video; +}; + +const resolveMediaChatFileTypeFromDownloadAccess = (access: VerifiedS3DownloadAccess) => { + const contentTypeFileType = resolveMediaChatFileTypeFromContentType(access.responseContentType); + if (contentTypeFileType) return contentTypeFileType; + + return ( + resolveMediaChatFileTypeFromFilename(access.filename) || + resolveMediaChatFileTypeFromFilename(access.objectKey) + ); +}; + +const resolveSignedDownloadAliasFromUrl = (url: string) => { + try { + const parsedUrl = new URL(url, 'http://localhost:3000'); + const routePrefix = `${S3_ACCESS_LINK_ROUTES.download}/`; + const routeIndex = parsedUrl.pathname.indexOf(routePrefix); + + const signedAlias = (() => { + if (routeIndex >= 0) { + return decodeURIComponent( + parsedUrl.pathname.slice(routeIndex + routePrefix.length).split('/')[0] || '' + ); + } + + // 支持 FILE_DOWNLOAD_PUBLIC_URL_PREFIX 暴露的 nginx 短路径: + // /{signedAlias} + // /f/{signedAlias} + // 这类 URL 的 path 不是文件名,最后一段只有通过短链格式和 HMAC 校验后才可信。 + const lastPathSegment = parsedUrl.pathname.split('/').filter(Boolean).at(-1) || ''; + return decodeURIComponent(lastPathSegment); + })(); + + return S3SignedDownloadAliasValueSchema.safeParse(signedAlias).success ? signedAlias : ''; + } catch { + return ''; + } +}; + +/** + * 短链 path 只携带 alias/expiry/signature,不能当作文件名或后缀。 + * 文件解析需要先把短链还原成真实对象,再用 alias 记录中的 filename/objectKey 推断类型。 + */ +const resolveShortDownloadAccessFromUrl = async ( + url: string +): Promise => { + const signedAlias = resolveSignedDownloadAliasFromUrl(url); + if (!signedAlias) return; + + return verifyS3DownloadAccess(signedAlias); +}; + +const resolveShortLinkMediaFileItem = async (file: UserChatItemFileItemType) => { + if (file.type !== ChatFileTypeEnum.file) return file; + if (!resolveSignedDownloadAliasFromUrl(file.url)) return file; + + try { + const shortDownloadAccess = await resolveShortDownloadAccessFromUrl(file.url); + if (!shortDownloadAccess) return file; + + const mediaType = resolveMediaChatFileTypeFromDownloadAccess(shortDownloadAccess); + if (!mediaType) return file; + + return { + ...file, + type: mediaType, + name: + file.name || + shortDownloadAccess.filename || + path.basename(shortDownloadAccess.objectKey) || + file.url + }; + } catch { + return file; + } +}; + +const resolveShortLinkMediaFilesInUserQuery = async (userQuery: UserChatItemValueItemType[]) => { + let changed = false; + + const normalizedUserQuery = await Promise.all( + userQuery.map(async (item) => { + if (!item.file) return item; + + const file = await resolveShortLinkMediaFileItem(item.file); + if (file === item.file) return item; + + changed = true; + return { + ...item, + file + }; + }) + ); + + return changed ? normalizedUserQuery : userQuery; +}; + +/** + * 解析文件读取 worker 使用的扩展名。外部 URL 可能没有后缀,例如 GitHub raw LICENSE; + * 这时允许从响应 Content-Type 或文本内容推断为 txt,但显式存在的不支持后缀仍交给 worker 报错。 + */ +const resolveReadFileExtension = ({ + extension, + contentType, + buffer +}: { + extension: string; + contentType?: string; + buffer: Buffer; +}) => { + const normalizedExtension = normalizeReadableExtension(extension); + const supportedExtension = resolveSupportedReadableExtension(normalizedExtension); + if (supportedExtension) return supportedExtension; + + if (normalizedExtension) return normalizedExtension; + + const normalizedContentType = normalizeMimeType(contentType, ''); + const mimeExtension = resolveSupportedReadableExtension( + resolveMimeExtension(normalizedContentType) + ); + if (mimeExtension) return mimeExtension; + + if (normalizedContentType.startsWith('text/')) return 'txt'; + + if ( + (!normalizedContentType || normalizedContentType === DEFAULT_CONTENT_TYPE) && + isLikelyTextBuffer(buffer) + ) { + return 'txt'; + } + + return ''; +}; + /** * 将 URL 解析成 ChatBox 文件结构。 * @@ -140,22 +318,30 @@ export const formatUserQueryWithFiles = async ({ }[] >; }): Promise => { - const urls = userQuery + const hasShortLinkFile = userQuery.some( + (item) => + item.file?.type === ChatFileTypeEnum.file && + !!resolveSignedDownloadAliasFromUrl(item.file.url) + ); + const normalizedUserQuery = hasShortLinkFile + ? await resolveShortLinkMediaFilesInUserQuery(userQuery) + : userQuery; + const urls = normalizedUserQuery .map((item) => (item.file?.type === ChatFileTypeEnum.file ? item.file.url : '')) .filter(Boolean); if (urls.length === 0) { - return userQuery; + return normalizedUserQuery; } const readFilesResult = await parseFileFn(urls); if (readFilesResult.length === 0) { - return userQuery; + return normalizedUserQuery; } // 把 file 和 text 合并成一个 text(实际上应该只会有一个 text+多个 files) - const text = userQuery.find((item) => item.text?.content)?.text?.content; + const text = normalizedUserQuery.find((item) => item.text?.content)?.text?.content; const fileQuery = getUserFilesPrompt(readFilesResult); const finalQuery = injectUserQueryPrompt({ @@ -258,19 +444,48 @@ export const normalizeReadableFileUrl = ({ }; export const getFileInfoFromUrl = async ({ teamId, url }: { teamId: string; url: string }) => { + const shortDownloadAccess = await resolveShortDownloadAccessFromUrl(url); const response = await pickOutboundAxios(url).get(url, { responseType: 'arraybuffer' }); const urlObj = new URL(url, 'http://localhost:3000'); - const isChatExternalUrl = !urlObj.pathname.startsWith(`/${S3Buckets.private}/${S3Sources.chat}/`); + const isShortChatFile = + shortDownloadAccess?.bucketName === S3Buckets.private && + shortDownloadAccess.objectKey.startsWith(`${S3Sources.chat}/`); + const isChatExternalUrl = + !isShortChatFile && !urlObj.pathname.startsWith(`/${S3Buckets.private}/${S3Sources.chat}/`); // Get file name const { filename, extension, imageParsePrefix } = (() => { + if (isShortChatFile && shortDownloadAccess) { + const parsedChatUrl = S3ChatSource.parseChatUrl( + new URL( + `/${shortDownloadAccess.bucketName}/${shortDownloadAccess.objectKey}`, + 'http://localhost:3000' + ) + ); + const filename = + shortDownloadAccess.filename || + parsedChatUrl.filename || + path.basename(shortDownloadAccess.objectKey); + + return { + filename, + extension: path.extname(filename).replace('.', '') || parsedChatUrl.extension, + imageParsePrefix: parsedChatUrl.imageParsePrefix + }; + } + if (isChatExternalUrl) { const contentDisposition = getAxiosHeaderValue(response.headers['content-disposition']) || ''; const matchFilename = parseContentDispositionFilename(contentDisposition); - const filename = matchFilename || urlObj.pathname.split('/').pop() || 'file'; + const filename = + shortDownloadAccess?.filename || + matchFilename || + (shortDownloadAccess?.objectKey ? path.basename(shortDownloadAccess.objectKey) : '') || + urlObj.pathname.split('/').pop() || + 'file'; const extension = path.extname(filename).replace('.', ''); return { @@ -288,7 +503,9 @@ export const getFileInfoFromUrl = async ({ teamId, url }: { teamId: string; url: filename, extension, imageParsePrefix, - contentType: getAxiosHeaderValue(response.headers['content-type']), + contentType: + shortDownloadAccess?.responseContentType || + getAxiosHeaderValue(response.headers['content-type']), stream: response.data }; }; @@ -336,8 +553,14 @@ export const getFileContentByUrl = async ({ return detectFileEncoding(buffer); })(); - const { rawText } = await readFileContentByBuffer({ + const readExtension = resolveReadFileExtension({ extension, + contentType, + buffer + }); + + const { rawText } = await readFileContentByBuffer({ + extension: readExtension, teamId, tmbId, buffer, @@ -354,7 +577,7 @@ export const getFileContentByUrl = async ({ usageId }); - const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90)); + const replacedText = await replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90)); // Add to buffer getS3RawTextSource().addRawTextBuffer({ diff --git a/packages/service/core/chat/saveChat.ts b/packages/service/core/chat/saveChat.ts index fd50cd878558..00b4fc2cdd20 100644 --- a/packages/service/core/chat/saveChat.ts +++ b/packages/service/core/chat/saveChat.ts @@ -42,6 +42,7 @@ import { stripUserContentFileUrls } from './utils/prepare'; import { buildChatSourceQuery, buildChatSourceWriteFields, type ChatSourceParams } from './source'; +import { isChatFileS3KeyForChat } from '../../common/s3/sources/chat/key'; const logger = getLogger(LogCategories.MODULE.CHAT); @@ -85,8 +86,12 @@ export const persistChatFiles = async ({ contents, variables, variableList, + sourceType, + sourceId, + chatId, session -}: { +}: ChatSourceParams & { + chatId: string; contents: (UserChatItemType | AIChatItemType)[]; variables?: Record; variableList?: VariableItemType[]; @@ -105,7 +110,26 @@ export const persistChatFiles = async ({ keys.push(valueItem.file.key); } - // 2. query 是特殊格式的(工作流工具 + 表单输入) + // 2. Sandbox 生成文件只在对话落库时转为正式资产。 + if ('tools' in valueItem && valueItem.tools) { + valueItem.tools.forEach((tool) => { + tool.fileRefs?.forEach((fileRef) => { + if ( + !isChatFileS3KeyForChat({ + key: fileRef.key, + sourceType, + sourceId, + chatId + }) + ) { + throw new Error('Sandbox file reference does not belong to the current chat'); + } + keys.push(fileRef.key); + }); + }); + } + + // 3. query 是特殊格式的(工作流工具 + 表单输入) if ('text' in valueItem && valueItem.text?.content) { try { const parsed = JSON.parse(valueItem.text.content); @@ -380,6 +404,8 @@ export const finalizeChatRound = async (props: Props) => { contents: processedContent, variables, variableList, + ...chatSource, + chatId, session }); @@ -616,6 +642,8 @@ export const pushChatRecords = async (props: Props) => { contents: processedContent, variables, variableList, + ...chatSource, + chatId, session }); @@ -1013,6 +1041,8 @@ export const updateInteractiveChat = async ({ contents: [userContent, aiContent], variables, variableList, + ...chatSource, + chatId, session }); }); diff --git a/packages/service/core/chat/utils.ts b/packages/service/core/chat/utils.ts index f182513c5af7..2e7d6a26e32e 100644 --- a/packages/service/core/chat/utils.ts +++ b/packages/service/core/chat/utils.ts @@ -36,6 +36,56 @@ export const addPreviewUrlToChatItems = async ( type: 'chatFlow' | 'workflowTool' ) => { const getPreviewUrl = createChatFilePreviewUrlGetter(); + const sandboxUrlReplacements = new Map(); + + async function refreshSandboxToolFiles() { + await Promise.all( + histories.map(async (item) => { + if (item.obj !== ChatRoleEnum.AI) return; + + await Promise.all( + item.value.flatMap((value) => + (value.tools ?? []).map(async (tool) => { + if (!tool.fileRefs?.length) return; + + const replacements = await Promise.all( + tool.fileRefs.map(async (fileRef) => ({ + oldUrl: fileRef.url, + newUrl: await getPreviewUrl(fileRef.key) + })) + ); + + replacements.forEach(({ oldUrl, newUrl }) => { + sandboxUrlReplacements.set(oldUrl, newUrl); + if (tool.response) { + tool.response = tool.response.replaceAll(oldUrl, newUrl); + } + }); + + // fileRefs 只用于服务端持久化,不能进入历史接口或下一轮模型上下文。 + delete tool.fileRefs; + }) + ) + ); + }) + ); + + if (sandboxUrlReplacements.size === 0) return; + + histories.forEach((item) => { + if (item.obj !== ChatRoleEnum.AI) return; + + item.value.forEach((value) => { + if (!value.text?.content) return; + + let content = value.text.content; + sandboxUrlReplacements.forEach((newUrl, oldUrl) => { + content = content.replaceAll(oldUrl, newUrl); + }); + value.text.content = content; + }); + }); + } async function addPreviewUrlToFileValue(files: ChatFileValueWithPreview[]) { await Promise.all( @@ -107,6 +157,9 @@ export const addPreviewUrlToChatItems = async ( ); } + // 先刷新工具产出文件,确保后续历史上下文不会继续引用过期链接。 + await refreshSandboxToolFiles(); + // Presign file urls await Promise.all( histories.map(async (item) => { diff --git a/packages/service/core/dataset/data/controller.ts b/packages/service/core/dataset/data/controller.ts index 98a5219f86bf..ebd3c66747de 100644 --- a/packages/service/core/dataset/data/controller.ts +++ b/packages/service/core/dataset/data/controller.ts @@ -1,27 +1,36 @@ -import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils'; +import { + createS3KeysPreviewUrlMap, + getS3ObjectKeysFromMarkdownTexts, + replaceS3KeysWithPreviewUrlMap +} from '../../../core/dataset/utils'; import { addEndpointToImageUrl } from '../../../common/file/image/utils'; import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; import { addDays } from 'date-fns'; import { isS3ObjectKey } from '../../../common/s3/utils'; -import { jwtSignS3DownloadToken } from '../../../common/s3/security/token'; -import { S3Buckets } from '../../../common/s3/config/constants'; import { matchDatasetDataMarkdownImages } from './utils'; -export const formatDatasetDataValue = ({ - q, - a, - imageId, - imageDescMap -}: { +type FormatDatasetDataValueProps = { q: string; a?: string; imageId?: string; imageDescMap?: Record; -}): { +}; + +type FormattedDatasetDataValue = { q: string; a?: string; imagePreivewUrl?: string; -} => { +}; + +/** + * 整理数据块的图片描述和图片 endpoint,不签发访问链接。 + * 搜索候选阶段使用该函数,确保去重、相似度和 token 过滤前没有 Mongo IO。 + */ +export const formatDatasetDataTextValue = ({ + q, + a, + imageDescMap +}: Pick) => { // Add image description to image markdown if (imageDescMap) { // Helper function to replace image markdown with description @@ -58,36 +67,70 @@ export const formatDatasetDataValue = ({ a = addEndpointToImageUrl(a); } - if (!imageId) { + return { q, a }; +}; + +/** + * 批量格式化数据块,并让 q、a 与 imageId 中的重复对象键共用一次短链签发。 + */ +export const formatDatasetDataValues = async ( + items: FormatDatasetDataValueProps[] +): Promise => { + const normalizedItems = items.map(({ q, a, imageId, imageDescMap }) => ({ + ...formatDatasetDataTextValue({ q, a, imageDescMap }), + imageId + })); + const markdownObjectKeys = getS3ObjectKeysFromMarkdownTexts( + normalizedItems.flatMap((item) => (item.imageId ? [] : [item.q, item.a])) + ); + const imageObjectKeys = normalizedItems.flatMap(({ imageId }) => + imageId && isS3ObjectKey(imageId, 'dataset') ? [imageId] : [] + ); + const previewUrlMap = await createS3KeysPreviewUrlMap({ + objectKeys: [...markdownObjectKeys, ...imageObjectKeys], + expiredTime: addDays(new Date(), 90) + }); + + return normalizedItems.map(({ q, a, imageId }) => { + if (!imageId) { + return { + q: replaceS3KeysWithPreviewUrlMap(q, previewUrlMap), + a: a ? replaceS3KeysWithPreviewUrlMap(a, previewUrlMap) : undefined + }; + } + + const imagePreivewUrl = isS3ObjectKey(imageId, 'dataset') + ? previewUrlMap.get(imageId)! + : imageId; + return { - q: replaceS3KeyToPreviewUrl(q, addDays(new Date(), 90)), - a: a ? replaceS3KeyToPreviewUrl(a, addDays(new Date(), 90)) : undefined + q: `![${q.replaceAll('\n', '')}](${imagePreivewUrl})`, + a, + imagePreivewUrl }; - } - - const imagePreivewUrl = isS3ObjectKey(imageId, 'dataset') - ? jwtSignS3DownloadToken({ - objectKey: imageId, - bucketName: S3Buckets.private, - expiredTime: addDays(new Date(), 90) - }) - : imageId; + }); +}; - return { - q: `![${q.replaceAll('\n', '')}](${imagePreivewUrl})`, - a, - imagePreivewUrl - }; +/** 单条数据格式化兼容入口,复用批量实现以保持签发语义一致。 */ +export const formatDatasetDataValue = async ( + item: FormatDatasetDataValueProps +): Promise => { + const [result] = await formatDatasetDataValues([item]); + return result!; }; -export const getFormatDatasetCiteList = (list: DatasetDataSchemaType[]) => { - return list.map((item) => ({ - _id: item._id, - ...formatDatasetDataValue({ +export const getFormatDatasetCiteList = async (list: DatasetDataSchemaType[]) => { + const formattedValues = await formatDatasetDataValues( + list.map((item) => ({ q: item.q, a: item.a, imageId: item.imageId - }), + })) + ); + + return list.map((item, index) => ({ + _id: item._id, + ...formattedValues[index]!, history: item.history, updateTime: item.updateTime, index: item.chunkIndex diff --git a/packages/service/core/dataset/search/defaultRecall/embeddingRecall.ts b/packages/service/core/dataset/search/defaultRecall/embeddingRecall.ts index bb5892067d8e..18b5f208f3ab 100644 --- a/packages/service/core/dataset/search/defaultRecall/embeddingRecall.ts +++ b/packages/service/core/dataset/search/defaultRecall/embeddingRecall.ts @@ -245,36 +245,39 @@ export const embeddingRecall = async ({ image: [] }; - recallResults.forEach((recallResult, taskIndex) => { + for (const [taskIndex, recallResult] of recallResults.entries()) { const task = tasks[taskIndex]; const set = new Set(); - const list = recallResult.results - .map((item, index) => { - const collection = collectionMaps.get(String(item.collectionId)); - if (!collection) { - logger.warn('Dataset collection not found during recall', { - collectionId: item.collectionId, - dataId: item.id - }); - return; - } + const list = ( + await Promise.all( + recallResult.results.map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + logger.warn('Dataset collection not found during recall', { + collectionId: item.collectionId, + dataId: item.id + }); + return; + } - const data = dataMaps.get(String(item.id?.trim())); - if (!data) { - logger.warn('Dataset data not found during recall', { - dataId: item.id, - collectionId: item.collectionId - }); - return; - } + const data = dataMaps.get(String(item.id?.trim())); + if (!data) { + logger.warn('Dataset data not found during recall', { + dataId: item.id, + collectionId: item.collectionId + }); + return; + } - return buildSearchResultItem({ - data, - collection, - score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }] - }); - }) + return buildSearchResultItem({ + data, + collection, + score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }] + }); + }) + ) + ) .filter((item) => { if (!item) return false; if (set.has(item.id)) return false; @@ -289,7 +292,7 @@ export const embeddingRecall = async ({ }) as SearchDataResponseItemType[]; groupedRecallLists[task.source].push(list); - }); + } return { textEmbeddingRecallResults: concatRecallLists(groupedRecallLists.text, limit), diff --git a/packages/service/core/dataset/search/defaultRecall/fullTextRecall.ts b/packages/service/core/dataset/search/defaultRecall/fullTextRecall.ts index 06fccb1385ce..2d8e771a059f 100644 --- a/packages/service/core/dataset/search/defaultRecall/fullTextRecall.ts +++ b/packages/service/core/dataset/search/defaultRecall/fullTextRecall.ts @@ -159,41 +159,44 @@ export const fullTextRecall = async ({ imageCaption: [] }; - recallResults.forEach((recallResult, taskIndex) => { + for (const [taskIndex, recallResult] of recallResults.entries()) { const task = queryTasks[taskIndex]; - const list = recallResult - .map((item, index) => { - const collection = collectionMaps.get(String(item.collectionId)); - if (!collection) { - logger.warn('Dataset collection not found during full-text recall', { - collectionId: item.collectionId, - dataId: item.dataId - }); - return; - } + const list = ( + await Promise.all( + recallResult.map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + if (!collection) { + logger.warn('Dataset collection not found during full-text recall', { + collectionId: item.collectionId, + dataId: item.dataId + }); + return; + } - const data = dataMaps.get(String(item.dataId)); - if (!data) { - logger.warn('Dataset data not found during full-text recall', { - dataId: item.dataId, - collectionId: item.collectionId - }); - return; - } + const data = dataMaps.get(String(item.dataId)); + if (!data) { + logger.warn('Dataset data not found during full-text recall', { + dataId: item.dataId, + collectionId: item.collectionId + }); + return; + } - return buildSearchResultItem({ - data, - collection, - includeIndexes: true, - score: [ - { - type: SearchScoreTypeEnum.fullText, - value: item.score || 0, - index - } - ] - }); - }) + return buildSearchResultItem({ + data, + collection, + includeIndexes: true, + score: [ + { + type: SearchScoreTypeEnum.fullText, + value: item.score || 0, + index + } + ] + }); + }) + ) + ) .filter((item) => { if (!item) return false; return true; @@ -206,7 +209,7 @@ export const fullTextRecall = async ({ }) as SearchDataResponseItemType[]; groupedRecallLists[task.source].push(list); - }); + } return { textFullTextRecallResults: concatRecallLists(groupedRecallLists.text, limit), diff --git a/packages/service/core/dataset/search/defaultRecall/index.ts b/packages/service/core/dataset/search/defaultRecall/index.ts index 7f41e3150219..f4ace23fb46a 100644 --- a/packages/service/core/dataset/search/defaultRecall/index.ts +++ b/packages/service/core/dataset/search/defaultRecall/index.ts @@ -2,11 +2,10 @@ import { DatasetSearchModeEnum, DatasetSearchModeMap } from '@fastgpt/global/core/dataset/constants'; -import { addDays } from 'date-fns'; import { getDefaultRerankModel } from '../../../ai/model'; import { pushTrack } from '../../../../common/middle/tracks/utils'; -import { replaceS3KeyToPreviewUrl } from '../../../../core/dataset/utils'; import type { SearchDatasetDataProps, SearchDatasetDataResponse } from '../type'; +import { formatDatasetDataValues } from '../../data/controller'; import { getImageCaptionQueries } from './imageCaption'; import { multiQueryRecall } from './multiQueryRecall'; import { reRankSearchResults } from './rerank'; @@ -169,11 +168,18 @@ export async function searchDatasetData( }); const filterMaxTokensResult = await filterDatasetDataByMaxTokens(scoreFilter, maxTokens); - // Step 8: 返回前把 q 中的内部图片 key 转为可预览 URL。 - // 只在最终结果处理,避免中间召回和去重阶段混入带过期时间的动态 URL。 - const finalResult = filterMaxTokensResult.map((item) => { - item.q = replaceS3KeyToPreviewUrl(item.q, addDays(new Date(), 90)); - return item; + // Step 8: 返回前一次收集最终结果中的 q、a、imageId,并批量签发唯一对象 key。 + // 被前面过滤掉的候选不会产生 alias 查询;最终输出也不暴露仅供格式化使用的 imageId。 + const formattedValues = await formatDatasetDataValues( + filterMaxTokensResult.map(({ q, a, imageId }) => ({ q, a, imageId })) + ); + const finalResult = filterMaxTokensResult.map((item, index) => { + const result = { ...item }; + delete result.imageId; + return { + ...result, + ...formattedValues[index]! + }; }); pushTrack.datasetSearch({ datasetIds, teamId }); diff --git a/packages/service/core/dataset/search/defaultRecall/result.ts b/packages/service/core/dataset/search/defaultRecall/result.ts index 40ce97aaa9bc..dc26b7cb85fc 100644 --- a/packages/service/core/dataset/search/defaultRecall/result.ts +++ b/packages/service/core/dataset/search/defaultRecall/result.ts @@ -7,7 +7,7 @@ import type { DatasetDataSchemaType, SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; -import { formatDatasetDataValue } from '../../data/controller'; +import { formatDatasetDataTextValue } from '../../data/controller'; /** * 把召回命中的 data 与 collection 统一整理成搜索结果。 @@ -23,22 +23,26 @@ export const buildSearchResultItem = ({ collection: DatasetCollectionSchemaType; score: SearchDataResponseItemType['score']; includeIndexes?: boolean; -}): SearchDataResponseItemType => ({ - id: String(data._id), - updateTime: data.updateTime, - ...formatDatasetDataValue({ +}): SearchDataResponseItemType => { + const formattedValue = formatDatasetDataTextValue({ q: data.q, a: data.a, - imageId: data.imageId, imageDescMap: data.imageDescMap - }), - chunkIndex: data.chunkIndex, - ...(includeIndexes ? { indexes: data.indexes } : {}), - datasetId: String(data.datasetId), - collectionId: String(data.collectionId), - ...getCollectionSourceData(collection), - score -}); + }); + + return { + id: String(data._id), + updateTime: data.updateTime, + ...formattedValue, + imageId: data.imageId, + chunkIndex: data.chunkIndex, + ...(includeIndexes ? { indexes: data.indexes } : {}), + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score + }; +}; export const concatRecallLists = (lists: SearchDataResponseItemType[][], limit: number) => { return datasetSearchResultConcat(lists.map((list) => ({ weight: 1, list }))).slice(0, limit); diff --git a/packages/service/core/dataset/utils.ts b/packages/service/core/dataset/utils.ts index 483a4c05f3eb..190c672593b8 100644 --- a/packages/service/core/dataset/utils.ts +++ b/packages/service/core/dataset/utils.ts @@ -2,13 +2,118 @@ import { authDatasetByTmbId } from '../../support/permission/dataset/auth'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { S3Sources } from '../../common/s3/contracts/type'; import { isS3ObjectKey } from '../../common/s3/utils'; -import { jwtSignS3DownloadToken } from '../../common/s3/security/token'; import { getLogger, LogCategories } from '../../common/logger'; import { S3Buckets } from '../../common/s3/config/constants'; import { getVlmModelList, isImageEmbeddingModel } from '../ai/model'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { S3_DOWNLOAD_URL_BATCH_MAX_SIZE } from '@fastgpt-sdk/storage/access-link'; +import { createS3DownloadAccessUrls } from '../../common/s3/accessLink'; const logger = getLogger(LogCategories.MODULE.DATASET.FILE); +const previewUrlS3Sources = ['dataset', 'chat', 'temp'] as const; + +const createS3MarkdownKeyRegex = () => { + const pattern = Object.values(S3Sources) + .map((prefix) => `${prefix}\\/[^)]+?`) + .join('|'); + + return new RegExp(String.raw`(!?)\[([^\]]*)\]\(\s*(?!https?:\/\/)(${pattern})\s*\)`, 'g'); +}; + +const isPreviewUrlS3ObjectKey = (objectKey: string) => + previewUrlS3Sources.some((source) => isS3ObjectKey(objectKey, source)); + +/** + * 从多段 Markdown 中提取允许签发预览链接的 S3 对象键,并按首次出现顺序去重。 + */ +export const getS3ObjectKeysFromMarkdownTexts = (texts: Array) => { + const objectKeys = new Set(); + + for (const text of texts) { + if (!text || typeof text !== 'string') continue; + + for (const match of text.matchAll(createS3MarkdownKeyRegex())) { + const objectKey = match[3]; + if (objectKey && isPreviewUrlS3ObjectKey(objectKey)) { + objectKeys.add(objectKey); + } + } + } + + return Array.from(objectKeys); +}; + +/** + * 为一批 S3 对象键创建预览 URL 映射。 + * + * 输入会先去重,并按 SDK 的批量上限分片,避免调用方因结果规模变化退化成逐条 Mongo 查询。 + */ +export const createS3KeysPreviewUrlMap = async ({ + objectKeys, + expiredTime +}: { + objectKeys: string[]; + expiredTime: Date; +}) => { + const uniqueObjectKeys = Array.from(new Set(objectKeys)); + const previewUrlMap = new Map(); + + for (let index = 0; index < uniqueObjectKeys.length; index += S3_DOWNLOAD_URL_BATCH_MAX_SIZE) { + const batchKeys = uniqueObjectKeys.slice(index, index + S3_DOWNLOAD_URL_BATCH_MAX_SIZE); + const urls = await createS3DownloadAccessUrls( + batchKeys.map((objectKey) => ({ + objectKey, + bucketName: S3Buckets.private, + expiredTime + })) + ); + + batchKeys.forEach((objectKey, batchIndex) => { + previewUrlMap.set(objectKey, urls[batchIndex]!); + }); + } + + return previewUrlMap; +}; + +/** 使用已签发的 URL 映射替换 Markdown 中的 S3 对象键,不产生额外存储 IO。 */ +export const replaceS3KeysWithPreviewUrlMap = ( + documentQuoteText: string, + previewUrlMap: ReadonlyMap +) => { + if (!documentQuoteText || typeof documentQuoteText !== 'string') { + return documentQuoteText as string; + } + + const matches = Array.from(documentQuoteText.matchAll(createS3MarkdownKeyRegex())); + let content = documentQuoteText; + + for (const match of matches.slice().reverse()) { + const [full, bang, alt, objectKey] = match; + const previewUrl = objectKey ? previewUrlMap.get(objectKey) : undefined; + + if (previewUrl) { + const replacement = `${bang}[${alt}](${previewUrl})`; + content = + content.slice(0, match.index) + replacement + content.slice(match.index + full.length); + } + } + + return content; +}; + +/** 批量替换多段 Markdown 中的 S3 对象键,所有唯一 key 共用批量签发请求。 */ +export const replaceS3KeysToPreviewUrls = async ( + documentQuoteTexts: string[], + expiredTime: Date +) => { + const previewUrlMap = await createS3KeysPreviewUrlMap({ + objectKeys: getS3ObjectKeysFromMarkdownTexts(documentQuoteTexts), + expiredTime + }); + + return documentQuoteTexts.map((text) => replaceS3KeysWithPreviewUrlMap(text, previewUrlMap)); +}; // TODO: 需要优化成批量获取权限 export const filterDatasetsByTmbId = async ({ @@ -39,7 +144,7 @@ export const filterDatasetsByTmbId = async ({ }; /** - * 替换数据集引用 markdown 文本中的图片链接格式的 S3 对象键为 JWT 签名后的 URL + * 替换数据集引用 markdown 文本中的图片链接格式的 S3 对象键为短访问 URL。 * * @param documentQuoteText 数据集引用文本 * @param expiredTime 过期时间 @@ -51,39 +156,12 @@ export const filterDatasetsByTmbId = async ({ * const datasetQuoteText = '![image.png](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/aZos7D-214afce5-4d42-4356-9e05-8164d51c59ae.png)'; * const replacedText = await replaceS3KeyToPreviewUrl(datasetQuoteText, addDays(new Date(), 90)) * console.log(replacedText) - * // '![image.png](http://localhost:3000/api/system/file/download/xxx?filename=image.png)' + * // '![image.png](http://localhost:3000/api/system/file/d/alias.exp.sig)' * ``` */ -export function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime: Date) { - if (!documentQuoteText || typeof documentQuoteText !== 'string') - return documentQuoteText as string; - - const prefixes = Object.values(S3Sources); - const pattern = prefixes.map((p) => `${p}\\/[^)]+`).join('|'); - const regex = new RegExp(String.raw`(!?)\[([^\]]*)\]\(\s*(?!https?:\/\/)(${pattern})\s*\)`, 'g'); - - const matches = Array.from(documentQuoteText.matchAll(regex)); - let content = documentQuoteText; - - for (const match of matches.slice().reverse()) { - const [full, bang, alt, objectKey] = match; - - const allowedKeys: (keyof typeof S3Sources)[] = ['dataset', 'chat', 'temp']; - const allowedKeysGuard = allowedKeys.some((key) => isS3ObjectKey(objectKey, key)); - - if (allowedKeysGuard) { - const url = jwtSignS3DownloadToken({ - objectKey, - bucketName: S3Buckets.private, - expiredTime - }); - const replacement = `${bang}[${alt}](${url})`; - content = - content.slice(0, match.index) + replacement + content.slice(match.index + full.length); - } - } - - return content; +export async function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime: Date) { + const [content] = await replaceS3KeysToPreviewUrls([documentQuoteText], expiredTime); + return content!; } const getAvailableDatasetVlmModel = (vlmModel?: string) => { diff --git a/packages/service/core/workflow/dispatch/ai/agentLoopCore/adapter/assistantResponses/eventCollector.ts b/packages/service/core/workflow/dispatch/ai/agentLoopCore/adapter/assistantResponses/eventCollector.ts index 046cdc34087b..48c4f8617cca 100644 --- a/packages/service/core/workflow/dispatch/ai/agentLoopCore/adapter/assistantResponses/eventCollector.ts +++ b/packages/service/core/workflow/dispatch/ai/agentLoopCore/adapter/assistantResponses/eventCollector.ts @@ -356,12 +356,14 @@ export const createAgentLoopCoreAssistantEventCollector = ({ toolAvatar: toolInfo?.avatar || '', functionName, params: event.call.function.arguments ?? '', - response: event.response + response: event.response, + ...(event.fileRefs?.length ? { fileRefs: event.fileRefs } : {}) }); } else { updateToolResponse(event.call.id, (tool) => ({ ...tool, - response: appendUniqueDelta(tool.response, event.response) + response: appendUniqueDelta(tool.response, event.response), + ...(event.fileRefs?.length ? { fileRefs: event.fileRefs } : {}) })); } diff --git a/packages/service/core/workflow/dispatch/ai/agentLoopCore/domain/toolProvider.ts b/packages/service/core/workflow/dispatch/ai/agentLoopCore/domain/toolProvider.ts index 4494c34a48ee..9598d3b296b7 100644 --- a/packages/service/core/workflow/dispatch/ai/agentLoopCore/domain/toolProvider.ts +++ b/packages/service/core/workflow/dispatch/ai/agentLoopCore/domain/toolProvider.ts @@ -10,6 +10,7 @@ import type { AgentLoopToolExecutionResult } from '../../../../../ai/llm/agentLoop/interface'; import type { AgentLoopCoreSystemToolInfo } from './toolInfo'; +import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type'; export type AgentLoopCoreUserToolInfo = { type: 'user'; @@ -34,6 +35,7 @@ export type AgentLoopCoreToolRunResult = { interactive?: TChildrenResponse; stop?: boolean; errorMessage?: string; + fileRefs?: SandboxFileRef[]; nodeResponse?: ChatHistoryItemResType; }; @@ -61,6 +63,7 @@ export const normalizeAgentLoopCoreToolRunResult = interactive, stop = false, errorMessage, + fileRefs, nodeResponse }: AgentLoopCoreToolRunResult): AgentLoopToolExecutionResult => { return { @@ -70,6 +73,7 @@ export const normalizeAgentLoopCoreToolRunResult = interactive, stop, errorMessage, + fileRefs, metadata: nodeResponse }; }; diff --git a/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts b/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts index 96184aea3cf5..772b965063e7 100644 --- a/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts +++ b/packages/service/core/workflow/dispatch/ai/chat/dispatchChatCompletion.ts @@ -72,6 +72,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise { runWithContext( { - queryUrlTypeMap: {}, + queryUrlTypeMap, + queryUrlFileMap, mcpClientMemory: {} }, (ctx) => { diff --git a/packages/service/core/workflow/utils/context.ts b/packages/service/core/workflow/utils/context.ts index cb248116b519..3441cc3287e0 100644 --- a/packages/service/core/workflow/utils/context.ts +++ b/packages/service/core/workflow/utils/context.ts @@ -1,4 +1,8 @@ import type { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import type { + UserChatItemFileItemType, + UserChatItemValueItemType +} from '@fastgpt/global/core/chat/type'; import { parseUrlToChatFileType } from '../../chat/fileContext'; import { AsyncLocalStorage } from 'async_hooks'; @@ -6,6 +10,7 @@ import type { MCPClient } from '../../app/mcp'; type ContextType = { queryUrlTypeMap: Record; + queryUrlFileMap?: Record; mcpClientMemory: Record; }; @@ -32,9 +37,48 @@ export const updateWorkflowContextVal = (val: Partial) => { } }; -/** 结合 workflow 运行态 URL 类型映射,将 URL 解析成 ChatBox 文件结构。 */ -export const parseUrlToFileType = (url: string) => - parseUrlToChatFileType({ +/** + * 从当前轮用户输入建立 URL 到聊天文件类型的映射。 + * + * 私有文件会在工作流启动前从稳定 key 恢复成无后缀短链,无法再依赖 URL 后缀推断媒体类型。 + * 该映射保留前端已经确认的 image/audio/video/file 类型,供所有下游节点统一解析 userFiles。 + */ +export const buildQueryUrlTypeMap = (query: UserChatItemValueItemType[]) => + query.reduce>((map, item) => { + if (item.file?.url) { + map[item.file.url] = item.file.type; + } + return map; + }, {}); + +/** + * 从当前轮用户输入建立 URL 到完整文件元数据的映射。 + * + * 音频模型协议需要从原始文件名获取 mp3/wav 等 format;无后缀短链只能表达访问地址, + * 因此除类型外还必须保留文件名和 key,避免第一轮媒体输入在出站时被过滤。 + */ +export const buildQueryUrlFileMap = (query: UserChatItemValueItemType[]) => + query.reduce>((map, item) => { + if (item.file?.url) { + map[item.file.url] = item.file; + } + return map; + }, {}); + +/** 结合 workflow 运行态文件元数据,将 URL 解析成 ChatBox 文件结构。 */ +export const parseUrlToFileType = (url: string) => { + const context = getWorkflowContext(); + const queryFile = context?.queryUrlFileMap?.[url]; + + if (queryFile) { + return { + ...queryFile, + url + }; + } + + return parseUrlToChatFileType({ url, - urlTypeMap: getWorkflowContext()?.queryUrlTypeMap + urlTypeMap: context?.queryUrlTypeMap }); +}; diff --git a/packages/service/env.ts b/packages/service/env.ts index d6dddcc82935..8e97b5d20c19 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -172,6 +172,16 @@ export const serviceEnv = createEnv({ STORAGE_REGION: z.string().default('us-east-1'), STORAGE_EXTERNAL_ENDPOINT: UrlSchema.optional(), STORAGE_S3_CDN_ENDPOINT: UrlSchema.optional(), + STORAGE_DOWNLOAD_URL_MODE: z + .enum(['short-proxy', 'short-redirect', 'presigned']) + .default('short-proxy') + .meta({ + description: + '下载链接模式:short-proxy 返回 FastGPT 短链并由 app 代理;short-redirect 返回 FastGPT 短链并 302 到短 TTL S3 链接;presigned 直接返回 S3 预签名长链' + }), + STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS: IntSchema.min(1).default(300).meta({ + description: 'short-redirect 模式下临时 S3 预签名下载链接 TTL(秒)' + }), STORAGE_S3_ENDPOINT: UrlSchema.default('http://localhost:9000'), STORAGE_PUBLIC_ACCESS_EXTRA_SUB_PATH: z.string().optional(), STORAGE_ACCESS_KEY_ID: z.string().default('minioadmin'), @@ -215,6 +225,10 @@ export const serviceEnv = createEnv({ description: '文件域名(也指向 FastGPT 服务);如需更高安全性可独立分配域名,避免高危文件读取到主域名内容' }), + FILE_DOWNLOAD_PUBLIC_URL_PREFIX: UrlSchema.optional().meta({ + description: + '下载短链公开 URL 前缀。配置后下载链接生成为 {prefix}/{signedAlias},通常由 nginx rewrite 到 FastGPT /api/system/file/d/{signedAlias};仅影响下载,不影响上传' + }), NEXT_PUBLIC_BASE_URL: z.string().default(''), //==================== 安全配置 ==================== diff --git a/packages/service/support/invoke/invoke.ts b/packages/service/support/invoke/invoke.ts index 4238e27e0a95..b596e13f23b2 100644 --- a/packages/service/support/invoke/invoke.ts +++ b/packages/service/support/invoke/invoke.ts @@ -1,14 +1,20 @@ import jwt from 'jsonwebtoken'; +import path from 'node:path'; +import { audioFileType, imageFileType, videoFileType } from '@fastgpt/global/common/file/constants'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { PluginPermissionEnum, type PluginPermissionEnumType } from '@fastgpt/global/sdk/fastgpt-plugin'; +import { ChatFileTypeEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { DefaultGroupName } from '@fastgpt/global/support/user/team/group/constant'; -import type { InvokeUserInfoResponseType } from '@fastgpt/global/openapi/plugin/invoke'; +import type { + InvokeFileUploadResponseType, + InvokeUserInfoResponseType +} from '@fastgpt/global/openapi/plugin/invoke'; import { getS3ChatSource } from '../../common/s3/sources/chat'; +import { removeS3TTL } from '../../common/s3/utils'; import { serviceEnv } from '../../env'; -import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { getGroupsByTmbId } from '../permission/memberGroup/controllers'; import { getOrgsByTmbId } from '../permission/org/controllers'; import { MongoOrgModel } from '../permission/org/orgSchema'; @@ -68,7 +74,7 @@ export class InvokeProcessor { return InvokeSessionSchema.parse(this._session); } - async handleFileUpload(params: InvokeFileUploadType): Promise<{ url: string }> { + async handleFileUpload(params: InvokeFileUploadType): Promise { this.assertPermission(PluginPermissionEnum['file-upload:allow']); const { appId, chatId, uId } = InvokeSessionSchema.parse(this._session); @@ -85,8 +91,27 @@ export class InvokeProcessor { expiredTime: addHours(new Date(), 365) }); + await removeS3TTL({ key: result.key, bucketName: 'private' }); + + const type = (() => { + if (contentType?.startsWith('image/')) return ChatFileTypeEnum.image; + if (contentType?.startsWith('audio/')) return ChatFileTypeEnum.audio; + if (contentType?.startsWith('video/')) return ChatFileTypeEnum.video; + + const extname = path.extname(filename).toLowerCase(); + if (extname && imageFileType.includes(extname)) return ChatFileTypeEnum.image; + if (extname && audioFileType.includes(extname)) return ChatFileTypeEnum.audio; + if (extname && videoFileType.includes(extname)) return ChatFileTypeEnum.video; + + return ChatFileTypeEnum.file; + })(); + return { - url: result.accessUrl.url + url: result.accessUrl.url, + key: result.key, + filename, + contentType, + type }; } diff --git a/packages/service/test/common/s3/accessLink.test.ts b/packages/service/test/common/s3/accessLink.test.ts new file mode 100644 index 000000000000..2834f0127b8a --- /dev/null +++ b/packages/service/test/common/s3/accessLink.test.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const strongFileTokenKey = '1234567890abcdef1234567890abcdef'; +const originalEnv = { + FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY, + FILE_DOMAIN: process.env.FILE_DOMAIN, + FILE_DOWNLOAD_PUBLIC_URL_PREFIX: process.env.FILE_DOWNLOAD_PUBLIC_URL_PREFIX, + FE_DOMAIN: process.env.FE_DOMAIN, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL +}; + +const loadAccessLinkModules = async ( + env: { + fileDomain?: string; + fileDownloadPublicUrlPrefix?: string; + feDomain?: string; + nextPublicBaseUrl?: string; + } = {} +) => { + vi.resetModules(); + vi.stubEnv('FILE_TOKEN_KEY', strongFileTokenKey); + vi.stubEnv('FILE_DOMAIN', env.fileDomain ?? 'https://files.example.com/'); + vi.stubEnv('FILE_DOWNLOAD_PUBLIC_URL_PREFIX', env.fileDownloadPublicUrlPrefix); + vi.stubEnv('FE_DOMAIN', env.feDomain); + vi.stubEnv('NEXT_PUBLIC_BASE_URL', env.nextPublicBaseUrl ?? '/fastgpt'); + + const [accessLink, downloadAliasSchema, uploadSessionSchema] = await Promise.all([ + import('@fastgpt/service/common/s3/accessLink'), + import('@fastgpt/service/common/s3/accessLink/downloadAlias/schema'), + import('@fastgpt/service/common/s3/accessLink/uploadSession/schema') + ]); + + return { + ...accessLink, + MongoS3DownloadAlias: downloadAliasSchema.MongoS3DownloadAlias, + MongoS3UploadSession: uploadSessionSchema.MongoS3UploadSession + }; +}; + +const getFutureDate = (minutes: number) => new Date(Date.now() + minutes * 60 * 1000); + +const extractLastPathSegment = (url: string) => url.split('/').pop() || ''; + +describe('s3 access link', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.stubEnv('FILE_TOKEN_KEY', originalEnv.FILE_TOKEN_KEY); + vi.stubEnv('FILE_DOMAIN', originalEnv.FILE_DOMAIN); + vi.stubEnv('FILE_DOWNLOAD_PUBLIC_URL_PREFIX', originalEnv.FILE_DOWNLOAD_PUBLIC_URL_PREFIX); + vi.stubEnv('FE_DOMAIN', originalEnv.FE_DOMAIN); + vi.stubEnv('NEXT_PUBLIC_BASE_URL', originalEnv.NEXT_PUBLIC_BASE_URL); + vi.restoreAllMocks(); + }); + + it('creates stable short download URLs backed by one alias document', async () => { + const { + createS3DownloadAccessUrl, + verifyS3DownloadAccess, + revokeS3DownloadAlias, + MongoS3DownloadAlias + } = await loadAccessLinkModules(); + const params = { + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/file.png', + expiredTime: getFutureDate(30), + filename: 'file.png', + responseContentType: 'image/png' + }; + + const firstUrl = await createS3DownloadAccessUrl(params); + const secondUrl = await createS3DownloadAccessUrl(params); + + expect(firstUrl).toBe(secondUrl); + expect(firstUrl).toMatch( + /^https:\/\/files\.example\.com\/fastgpt\/api\/system\/file\/d\/[A-Za-z0-9_-]{16}\.[0-9a-z]+\.[A-Za-z0-9_-]{22}$/ + ); + expect(firstUrl).not.toContain(params.objectKey); + + const aliases = await MongoS3DownloadAlias.find({}).lean(); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.bucketName).toBe(params.bucketName); + expect(aliases[0]?.objectKey).toBe(params.objectKey); + expect(aliases[0]?.purgeAt.getTime()).toBeGreaterThan(params.expiredTime.getTime()); + + const verified = await verifyS3DownloadAccess(extractLastPathSegment(firstUrl)); + expect(verified).toMatchObject({ + bucketName: params.bucketName, + objectKey: params.objectKey, + filename: params.filename, + responseContentType: params.responseContentType + }); + expect(verified.expiresAt).toBeInstanceOf(Date); + + const aliasId = extractLastPathSegment(firstUrl).split('.')[0] || ''; + await revokeS3DownloadAlias(aliasId); + + await expect(verifyS3DownloadAccess(extractLastPathSegment(firstUrl))).rejects.toThrow( + 'DownloadAliasRevoked' + ); + }); + + it('uses one Mongo batch lookup and insert for unique download aliases', async () => { + const { createS3DownloadAccessUrls, MongoS3DownloadAlias } = await loadAccessLinkModules(); + const findSpy = vi.spyOn(MongoS3DownloadAlias, 'find'); + const insertManySpy = vi.spyOn(MongoS3DownloadAlias, 'insertMany'); + const firstParams = { + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/batch-first.png', + expiredTime: getFutureDate(30) + }; + const secondParams = { + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/batch-second.png', + expiredTime: getFutureDate(30) + }; + + const urls = await createS3DownloadAccessUrls([firstParams, secondParams, firstParams]); + + expect(urls).toHaveLength(3); + expect(urls[0]).toBe(urls[2]); + expect(findSpy).toHaveBeenCalledTimes(1); + expect(findSpy).toHaveBeenCalledWith({ + aliasKey: { + $in: expect.arrayContaining([expect.any(String), expect.any(String)]) + } + }); + expect(insertManySpy).toHaveBeenCalledTimes(1); + expect(insertManySpy.mock.calls[0]?.[0]).toHaveLength(2); + expect(insertManySpy.mock.calls[0]?.[1]).toEqual({ ordered: false }); + }); + + it('uses one Mongo bulk write when a batch of reused aliases needs lease renewal', async () => { + const { createS3DownloadAccessUrls, MongoS3DownloadAlias } = await loadAccessLinkModules(); + const params = ['first', 'second'].map((name) => ({ + bucketName: 'fastgpt-private', + objectKey: `dataset/team-1/renew-${name}.png`, + expiredTime: getFutureDate(10) + })); + await createS3DownloadAccessUrls(params); + const bulkWriteSpy = vi.spyOn(MongoS3DownloadAlias, 'bulkWrite'); + + await createS3DownloadAccessUrls( + params.map((item) => ({ + ...item, + expiredTime: getFutureDate(60 * 48) + })) + ); + + expect(bulkWriteSpy).toHaveBeenCalledTimes(1); + expect(bulkWriteSpy.mock.calls[0]?.[0]).toHaveLength(2); + expect(bulkWriteSpy.mock.calls[0]?.[1]).toEqual({ ordered: false }); + }); + + it('uses public download URL prefix without changing upload URLs', async () => { + const { + createS3DownloadAccessUrl, + createS3UploadAccessUrl, + verifyS3DownloadAccess, + MongoS3DownloadAlias + } = await loadAccessLinkModules({ + fileDomain: 'https://app.example.com/', + fileDownloadPublicUrlPrefix: 'https://files.example.com/' + }); + const downloadParams = { + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/public-prefix.png', + expiredTime: getFutureDate(30), + filename: 'public-prefix.png' + }; + + const downloadUrl = await createS3DownloadAccessUrl(downloadParams); + const uploadUrl = await createS3UploadAccessUrl({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/user/chat/public-prefix.txt', + expiredTime: getFutureDate(10), + maxSize: 1024, + uploadConstraints: { + defaultContentType: 'text/plain', + allowedExtensions: ['.txt'] + } + }); + + expect(downloadUrl).toMatch( + /^https:\/\/files\.example\.com\/[A-Za-z0-9_-]{16}\.[0-9a-z]+\.[A-Za-z0-9_-]{22}$/ + ); + expect(uploadUrl).toMatch( + /^https:\/\/app\.example\.com\/fastgpt\/api\/system\/file\/u\/[A-Za-z0-9_-]{22}$/ + ); + + await expect( + verifyS3DownloadAccess(extractLastPathSegment(downloadUrl)) + ).resolves.toMatchObject({ + bucketName: downloadParams.bucketName, + objectKey: downloadParams.objectKey + }); + + const aliases = await MongoS3DownloadAlias.find({ objectKey: downloadParams.objectKey }).lean(); + expect(aliases).toHaveLength(1); + }); + + it('supports path based public download URL prefix', async () => { + const { createS3DownloadAccessUrl } = await loadAccessLinkModules({ + fileDomain: 'https://app.example.com/', + fileDownloadPublicUrlPrefix: 'https://files.example.com/f/' + }); + + const downloadUrl = await createS3DownloadAccessUrl({ + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/path-prefix.png', + expiredTime: getFutureDate(30), + filename: 'path-prefix.png' + }); + + expect(downloadUrl).toMatch( + /^https:\/\/files\.example\.com\/f\/[A-Za-z0-9_-]{16}\.[0-9a-z]+\.[A-Za-z0-9_-]{22}$/ + ); + }); + + it('extends download alias purgeAt without creating another alias document', async () => { + const { createS3DownloadAccessUrl, MongoS3DownloadAlias } = await loadAccessLinkModules(); + const params = { + bucketName: 'fastgpt-private', + objectKey: 'dataset/team-1/long-lived.png', + filename: 'long-lived.png' + }; + + await createS3DownloadAccessUrl({ + ...params, + expiredTime: getFutureDate(10) + }); + const firstAlias = await MongoS3DownloadAlias.findOne({ objectKey: params.objectKey }).lean(); + + await createS3DownloadAccessUrl({ + ...params, + expiredTime: getFutureDate(60 * 48) + }); + const aliases = await MongoS3DownloadAlias.find({ objectKey: params.objectKey }).lean(); + + expect(aliases).toHaveLength(1); + expect(aliases[0]?.purgeAt.getTime()).toBeGreaterThan(firstAlias?.purgeAt.getTime() || 0); + }); + + it('rejects expired or tampered signed aliases before alias lookup', async () => { + const { assertS3DownloadAliasSignature, encodeExpiresAtMinute, signS3DownloadAlias } = + await loadAccessLinkModules(); + const aliasId = 'R7mQG0Yh2kVxP9Za'; + const expMinute36 = encodeExpiresAtMinute(getFutureDate(10)); + const sig = signS3DownloadAlias({ aliasId, expMinute36 }); + const tamperedExpMinute36 = (Number.parseInt(expMinute36, 36) + 60).toString(36); + const expiredExpMinute36 = encodeExpiresAtMinute(getFutureDate(-10)); + const expiredSig = signS3DownloadAlias({ + aliasId, + expMinute36: expiredExpMinute36 + }); + + expect(() => + assertS3DownloadAliasSignature(`${aliasId}.${tamperedExpMinute36}.${sig}`) + ).toThrow('InvalidSignedAliasSignature'); + expect(() => + assertS3DownloadAliasSignature(`${aliasId}.${expiredExpMinute36}.${expiredSig}`) + ).toThrow('ExpiredSignedAlias'); + }); + + it('stores upload session payload behind a short hashed token', async () => { + const { + createS3UploadAccessUrl, + verifyS3UploadSessionToken, + parseSignedS3DownloadAlias, + MongoS3UploadSession + } = await loadAccessLinkModules(); + + const url = await createS3UploadAccessUrl({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/user/chat/file.txt', + expiredTime: getFutureDate(10), + maxSize: 1024, + uploadConstraints: { + defaultContentType: 'text/plain', + allowedExtensions: ['.txt'] + }, + uploadPolicy: { + defaultContentType: 'text/plain', + allowedExtensions: ['.txt'], + extensionRules: [ + { + extension: '.txt', + source: 'builtin', + verification: 'text' + } + ], + textFallbackExtension: '.txt' + }, + fileHint: { + filename: 'file', + declaredExtension: '.txt', + source: 'remote-url' + }, + metadata: { + originFilename: 'file.txt' + } + }); + const token = extractLastPathSegment(url); + + expect(url).toMatch( + /^https:\/\/files\.example\.com\/fastgpt\/api\/system\/file\/u\/[A-Za-z0-9_-]{22}$/ + ); + expect(() => parseSignedS3DownloadAlias(token)).toThrow('InvalidSignedAlias'); + + const sessions = await MongoS3UploadSession.find({}).lean(); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.tokenHash).toMatch(/^[a-f0-9]{64}$/); + expect(sessions[0]?.tokenHash).not.toBe(token); + + await expect(verifyS3UploadSessionToken(token)).resolves.toMatchObject({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/user/chat/file.txt', + maxSize: 1024, + uploadPolicy: expect.objectContaining({ + textFallbackExtension: '.txt', + extensionRules: [ + { + extension: '.txt', + source: 'builtin', + verification: 'text' + } + ] + }), + fileHint: { + filename: 'file', + declaredExtension: '.txt', + source: 'remote-url' + } + }); + + const usedSession = await MongoS3UploadSession.findOne({ + tokenHash: sessions[0]?.tokenHash + }).lean(); + expect(usedSession?.usedAt).toBeInstanceOf(Date); + }); + + it('rejects expired and revoked upload sessions', async () => { + const { createS3UploadAccessUrl, verifyS3UploadSessionToken, revokeS3UploadSessionToken } = + await loadAccessLinkModules(); + const expiredToken = extractLastPathSegment( + await createS3UploadAccessUrl({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/user/chat/expired.txt', + expiredTime: getFutureDate(-10), + maxSize: 1024, + uploadConstraints: { + defaultContentType: 'text/plain' + } + }) + ); + const revokedToken = extractLastPathSegment( + await createS3UploadAccessUrl({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/user/chat/revoked.txt', + expiredTime: getFutureDate(10), + maxSize: 1024, + uploadConstraints: { + defaultContentType: 'text/plain' + } + }) + ); + + await revokeS3UploadSessionToken(revokedToken); + + await expect(verifyS3UploadSessionToken(expiredToken)).rejects.toThrow('UploadSessionExpired'); + await expect(verifyS3UploadSessionToken(revokedToken)).rejects.toThrow('UploadSessionRevoked'); + }); +}); diff --git a/packages/service/test/common/s3/config/constants.test.ts b/packages/service/test/common/s3/config/constants.test.ts index d5f722fd7e8c..e1eddcb39c74 100644 --- a/packages/service/test/common/s3/config/constants.test.ts +++ b/packages/service/test/common/s3/config/constants.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createVitestStorageMock } from '@fastgpt-sdk/storage'; const originalEnv = { + STORAGE_VENDOR: process.env.STORAGE_VENDOR, STORAGE_EXTERNAL_ENDPOINT: process.env.STORAGE_EXTERNAL_ENDPOINT, - STORAGE_S3_CDN_ENDPOINT: process.env.STORAGE_S3_CDN_ENDPOINT + STORAGE_S3_CDN_ENDPOINT: process.env.STORAGE_S3_CDN_ENDPOINT, + STORAGE_DOWNLOAD_URL_MODE: process.env.STORAGE_DOWNLOAD_URL_MODE, + STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS: process.env.STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS }; const loadConstants = async () => { @@ -14,31 +17,49 @@ const loadConstants = async () => { describe('s3 storage constants', () => { beforeEach(() => { vi.resetModules(); + vi.stubEnv('STORAGE_VENDOR', undefined); vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', undefined); vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', undefined); + vi.stubEnv('STORAGE_DOWNLOAD_URL_MODE', undefined); + vi.stubEnv('STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS', undefined); }); afterEach(() => { + vi.stubEnv('STORAGE_VENDOR', originalEnv.STORAGE_VENDOR); vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', originalEnv.STORAGE_EXTERNAL_ENDPOINT); vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', originalEnv.STORAGE_S3_CDN_ENDPOINT); + vi.stubEnv('STORAGE_DOWNLOAD_URL_MODE', originalEnv.STORAGE_DOWNLOAD_URL_MODE); + vi.stubEnv( + 'STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS', + originalEnv.STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS + ); vi.restoreAllMocks(); }); - it('keeps proxy download mode when no external or CDN endpoint is configured', async () => { - const { storageDownloadMode, replaceS3UrlWithCdnEndpoint } = await loadConstants(); - - expect(storageDownloadMode).toBe('proxy'); + it('defaults to short proxy download mode when no explicit mode is configured', async () => { + const { + storageDownloadUrlMode, + storageDownloadRedirectTtlSeconds, + canUseStorageDownloadRedirect, + replaceS3UrlWithCdnEndpoint + } = await loadConstants(); + + expect(storageDownloadUrlMode).toBe('short-proxy'); + expect(storageDownloadRedirectTtlSeconds).toBe(300); + expect(canUseStorageDownloadRedirect).toBe(false); expect(replaceS3UrlWithCdnEndpoint('https://s3.example.com/bucket/file.png')).toBe( 'https://s3.example.com/bucket/file.png' ); }); - it('keeps proxy download mode but rewrites S3 URLs when CDN endpoint is configured', async () => { + it('keeps short proxy download mode but enables redirect when CDN endpoint is configured', async () => { vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', 'https://cdn.example.com/files'); - const { storageDownloadMode, replaceS3UrlWithCdnEndpoint } = await loadConstants(); + const { storageDownloadUrlMode, canUseStorageDownloadRedirect, replaceS3UrlWithCdnEndpoint } = + await loadConstants(); - expect(storageDownloadMode).toBe('proxy'); + expect(storageDownloadUrlMode).toBe('short-proxy'); + expect(canUseStorageDownloadRedirect).toBe(true); expect( replaceS3UrlWithCdnEndpoint( 'https://fastgpt-private.s3.example.com/chat/app/file.png?X-Amz-Signature=abc#preview' @@ -46,6 +67,32 @@ describe('s3 storage constants', () => { ).toBe('https://cdn.example.com/files/chat/app/file.png?X-Amz-Signature=abc#preview'); }); + it('uses explicit short redirect mode and redirect ttl from env', async () => { + vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', 'https://s3.example.com'); + vi.stubEnv('STORAGE_DOWNLOAD_URL_MODE', 'short-redirect'); + vi.stubEnv('STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS', '120'); + + const { + storageDownloadUrlMode, + storageDownloadRedirectTtlSeconds, + canUseStorageDownloadRedirect + } = await loadConstants(); + + expect(storageDownloadUrlMode).toBe('short-redirect'); + expect(storageDownloadRedirectTtlSeconds).toBe(120); + expect(canUseStorageDownloadRedirect).toBe(true); + }); + + it('allows short redirect for storage vendors that do not use STORAGE_EXTERNAL_ENDPOINT', async () => { + vi.stubEnv('STORAGE_VENDOR', 'cos'); + vi.stubEnv('STORAGE_DOWNLOAD_URL_MODE', 'short-redirect'); + + const { storageDownloadUrlMode, canUseStorageDownloadRedirect } = await loadConstants(); + + expect(storageDownloadUrlMode).toBe('short-redirect'); + expect(canUseStorageDownloadRedirect).toBe(true); + }); + it('rewrites external presigned URLs from S3BaseBucket', async () => { vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', 'https://cdn.example.com'); @@ -73,6 +120,33 @@ describe('s3 storage constants', () => { ); }); + it('returns short download links by default even when an external endpoint is configured', async () => { + vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', 'https://s3.example.com'); + + const { S3BaseBucket } = await vi.importActual< + typeof import('@fastgpt/service/common/s3/buckets/base') + >('@fastgpt/service/common/s3/buckets/base'); + const storage = createVitestStorageMock({ + vi, + bucketName: 'fastgpt-private', + baseUrl: 'https://s3.example.com' + }); + const bucket = new S3BaseBucket(storage, undefined); + + const result = await bucket.createExternalUrl({ + key: 'chat/app/user/chat/file.png' + }); + + expect(storage.generatePresignedGetUrl).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + bucket: 'fastgpt-private', + key: 'chat/app/user/chat/file.png' + }); + expect(result.url).toMatch( + /\/api\/system\/file\/d\/[A-Za-z0-9_-]{16}\.[0-9a-z]+\.[A-Za-z0-9_-]{22}$/ + ); + }); + it('passes response content type overrides into external presigned URLs', async () => { const { S3BaseBucket } = await vi.importActual< typeof import('@fastgpt/service/common/s3/buckets/base') diff --git a/packages/service/test/common/s3/key.test.ts b/packages/service/test/common/s3/key.test.ts index 21e18011e10d..35c6998451f0 100644 --- a/packages/service/test/common/s3/key.test.ts +++ b/packages/service/test/common/s3/key.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { isAuthorizedChatFileS3Key, + isChatFileS3KeyForChat, parseChatFileS3Key } from '@fastgpt/service/common/s3/sources/chat/key'; import { @@ -107,6 +108,22 @@ describe('authorized S3 object key helpers', () => { chatId: 'chat-2' }) ).toBe(false); + expect( + isChatFileS3KeyForChat({ + key: skillKey, + sourceType: 'skillEdit', + sourceId: 'skill-1', + chatId: 'chat-1' + }) + ).toBe(true); + expect( + isChatFileS3KeyForChat({ + key: skillKey, + sourceType: 'skillEdit', + sourceId: 'skill-1', + chatId: 'chat-2' + }) + ).toBe(false); }); it('parses and authorizes dataset file keys by datasetId', () => { diff --git a/packages/service/test/common/s3/token.test.ts b/packages/service/test/common/s3/token.test.ts index 7b14f828eaef..a2a8575d21ff 100644 --- a/packages/service/test/common/s3/token.test.ts +++ b/packages/service/test/common/s3/token.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import jwt from 'jsonwebtoken'; const strongFileTokenKey = '1234567890abcdef1234567890abcdef'; -const getExpiredTime = () => new Date(Date.now() + 5 * 60 * 1000); const originalEnv = { FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY, FILE_DOMAIN: process.env.FILE_DOMAIN, @@ -10,10 +10,6 @@ const originalEnv = { NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL }; -const extractTokenFromUrl = (url: string) => { - return url.split('/').pop()?.split('?')[0] || ''; -}; - const loadTokenModule = async () => { vi.resetModules(); vi.stubEnv('FILE_TOKEN_KEY', strongFileTokenKey); @@ -35,50 +31,43 @@ describe('s3 token validation', () => { }); it('rejects upload tokens when verifying download tokens', async () => { - const { jwtSignS3UploadToken, jwtVerifyS3DownloadToken } = await loadTokenModule(); - const token = extractTokenFromUrl( - jwtSignS3UploadToken({ + const { jwtVerifyS3DownloadToken } = await loadTokenModule(); + const token = jwt.sign( + { objectKey: 'chat/appId/userId/chatId/file.txt', bucketName: 'fastgpt-private', - expiredTime: getExpiredTime(), maxSize: 1024, uploadConstraints: { defaultContentType: 'text/plain' - } - }) + }, + type: 'upload' + }, + strongFileTokenKey, + { expiresIn: 300 } ); await expect(jwtVerifyS3DownloadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile); }); it('rejects download tokens when verifying upload tokens', async () => { - const { jwtSignS3DownloadToken, jwtVerifyS3UploadToken } = await loadTokenModule(); - const token = extractTokenFromUrl( - jwtSignS3DownloadToken({ + const { jwtVerifyS3UploadToken } = await loadTokenModule(); + const token = jwt.sign( + { objectKey: 'dataset/datasetId/file.txt', bucketName: 'fastgpt-private', - expiredTime: getExpiredTime(), - filename: 'file.txt' - }) + type: 'download' + }, + strongFileTokenKey, + { expiresIn: 300 } ); await expect(jwtVerifyS3UploadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile); }); - it('normalizes endpoint slashes when signing proxy download URLs', async () => { - vi.stubEnv('FILE_DOMAIN', 'https://files.example.com/'); - vi.stubEnv('FE_DOMAIN', undefined); - vi.stubEnv('NEXT_PUBLIC_BASE_URL', '/fastgpt'); + it('does not expose legacy JWT signing helpers', async () => { + const tokenModule = await loadTokenModule(); - const { jwtSignS3DownloadToken } = await loadTokenModule(); - const url = jwtSignS3DownloadToken({ - objectKey: 'chat/appId/userId/chatId/file.txt', - bucketName: 'fastgpt-private', - expiredTime: getExpiredTime() - }); - - expect(url).toMatch( - /^https:\/\/files\.example\.com\/fastgpt\/api\/system\/file\/download\/[^/?#]+\?filename=file\.txt$/ - ); + expect('jwtSignS3DownloadToken' in tokenModule).toBe(false); + expect('jwtSignS3UploadToken' in tokenModule).toBe(false); }); }); diff --git a/packages/service/test/common/s3/uploadConstraints.test.ts b/packages/service/test/common/s3/uploadConstraints.test.ts index 4a31bb5920cd..35d859ca4852 100644 --- a/packages/service/test/common/s3/uploadConstraints.test.ts +++ b/packages/service/test/common/s3/uploadConstraints.test.ts @@ -4,6 +4,7 @@ import { createUploadConstraints, datasetAllowedExtensions, getAllowedExtensionsFromFileSelectConfig, + getUploadExtensionRulesFromFileSelectConfig, normalizeAllowedExtensions, parseAllowedExtensions } from '@fastgpt/service/common/s3/utils/uploadConstraints'; @@ -22,7 +23,7 @@ describe('parseAllowedExtensions', () => { describe('createUploadConstraints', () => { it('derives the default content type from the filename', () => { - expect(createUploadConstraints({ filename: 'demo.pdf' })).toEqual({ + expect(createUploadConstraints({ filename: 'demo.pdf' })).toMatchObject({ defaultContentType: 'application/pdf' }); }); @@ -35,12 +36,27 @@ describe('createUploadConstraints', () => { allowedExtensions: ['.png', '.jpg'] } }) - ).toEqual({ + ).toMatchObject({ defaultContentType: 'image/png', allowedExtensions: ['.png', '.jpg'] }); }); + it('does not reject missing extension during policy creation', () => { + expect( + createUploadConstraints({ + filename: 'avatar', + uploadConstraints: { + allowedExtensions: ['.png', '.jpg'] + } + }) + ).toMatchObject({ + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.png', '.jpg'], + allowMissingExtension: true + }); + }); + it('rejects filenames outside the allowed extension list', () => { expect(() => createUploadConstraints({ @@ -72,6 +88,49 @@ describe('getAllowedExtensionsFromFileSelectConfig', () => { }); }); +describe('getUploadExtensionRulesFromFileSelectConfig', () => { + it('keeps custom extensions as opaque rules', () => { + expect( + getUploadExtensionRulesFromFileSelectConfig({ + canSelectImg: true, + canSelectCustomFileExtension: true, + customFileExtensionList: ['DAT'] + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extension: '.png', + source: 'builtin', + verification: 'content' + }), + expect.objectContaining({ + extension: '.dat', + source: 'custom', + verification: 'opaque' + }) + ]) + ); + }); + + it('does not let custom duplicate extensions override builtin content rules', () => { + expect( + getUploadExtensionRulesFromFileSelectConfig({ + canSelectImg: true, + canSelectCustomFileExtension: true, + customFileExtensionList: ['PNG'] + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extension: '.png', + source: 'builtin', + verification: 'content' + }) + ]) + ); + }); +}); + describe('preset extension lists', () => { it('exposes avatar and dataset defaults', () => { expect(avatarAllowedExtensions).toEqual(['.jpg', '.jpeg', '.png']); diff --git a/packages/service/test/common/s3/uploadPolicy.test.ts b/packages/service/test/common/s3/uploadPolicy.test.ts new file mode 100644 index 000000000000..9917aa7edfcf --- /dev/null +++ b/packages/service/test/common/s3/uploadPolicy.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + createUploadPolicy, + getUploadInspectBytes +} from '@fastgpt/service/common/s3/uploadPolicy/service'; +import { createUploadExtensionRulesFromFileSelectConfig } from '@fastgpt/service/common/s3/uploadPolicy/utils'; + +describe('createUploadPolicy', () => { + it('does not reject missing extension and uses contentType as fallback hint', () => { + expect( + createUploadPolicy({ + hint: { + filename: 'image', + contentType: 'image/png' + }, + uploadConstraints: { + allowedExtensions: ['.png'] + } + }) + ).toMatchObject({ + defaultContentType: 'image/png', + allowedExtensions: ['.png'], + fallbackExtension: '.png', + allowMissingExtension: true + }); + }); + + it('keeps custom fileSelectConfig extensions as opaque rules', () => { + const extensionRules = createUploadExtensionRulesFromFileSelectConfig({ + canSelectCustomFileExtension: true, + customFileExtensionList: ['DAT'] + }); + + expect( + createUploadPolicy({ + hint: { + filename: 'data.dat' + }, + uploadConstraints: { + allowedExtensions: ['.dat'], + extensionRules + } + }) + ).toMatchObject({ + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.dat'], + extensionRules: [ + { + extension: '.dat', + source: 'custom', + verification: 'opaque' + } + ], + fallbackExtension: '.dat' + }); + }); +}); + +describe('getUploadInspectBytes', () => { + it('uses larger window when policy allows extensionless OOXML upload', () => { + expect( + getUploadInspectBytes({ + hint: { + filename: 'document' + }, + policy: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.docx'] + } + }) + ).toBe(64 * 1024); + }); +}); diff --git a/packages/service/test/common/s3/uploadValidation.test.ts b/packages/service/test/common/s3/uploadValidation.test.ts index 783b0c62c14c..6521434a0fec 100644 --- a/packages/service/test/common/s3/uploadValidation.test.ts +++ b/packages/service/test/common/s3/uploadValidation.test.ts @@ -43,7 +43,7 @@ describe('validateUploadFile', () => { defaultContentType: 'image/png' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'demo.png', contentType: 'image/png' }); @@ -61,7 +61,7 @@ describe('validateUploadFile', () => { ).rejects.toThrow('UploadFileTypeMismatch'); }); - it('accepts mismatched binary content when detected type is also allowed', async () => { + it('rejects mismatched binary content when detected type is also allowed', async () => { await expect( validateUploadFile({ buffer: pngBuffer, @@ -71,10 +71,7 @@ describe('validateUploadFile', () => { allowedExtensions: ['.jpg', '.jpeg', '.png'] } }) - ).resolves.toEqual({ - filename: 'demo.png', - contentType: 'image/png' - }); + ).rejects.toThrow('UploadFileTypeMismatch'); }); it('accepts text-like files without binary signature', async () => { @@ -86,7 +83,7 @@ describe('validateUploadFile', () => { defaultContentType: 'application/json' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'demo.json', contentType: 'application/json' }); @@ -101,7 +98,7 @@ describe('validateUploadFile', () => { defaultContentType: 'application/octet-stream' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'hello world.txt', contentType: 'text/plain' }); @@ -116,7 +113,7 @@ describe('validateUploadFile', () => { defaultContentType: 'application/octet-stream' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'archive.custom', contentType: 'application/octet-stream' }); @@ -131,12 +128,184 @@ describe('validateUploadFile', () => { defaultContentType: 'application/octet-stream' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'README', contentType: 'application/octet-stream' }); }); + it('accepts extensionless png content when png is allowed', async () => { + await expect( + validateUploadFile({ + buffer: pngBuffer, + filename: 'image', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.png'] + } + }) + ).resolves.toMatchObject({ + filename: 'image.png', + contentType: 'image/png', + extension: '.png', + detectionSource: 'magic', + correctedFilename: true + }); + }); + + it('accepts extensionless docx content when docx is allowed', async () => { + await expect( + validateUploadFile({ + buffer: docxBuffer, + filename: 'document', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.docx'] + } + }) + ).resolves.toMatchObject({ + filename: 'document.docx', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + extension: '.docx' + }); + }); + + it('rejects extensionless unknown binary when only opaque custom extension is allowed', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from([0, 1, 2, 3]), + filename: 'data', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.dat'], + extensionRules: [ + { + extension: '.dat', + source: 'custom', + verification: 'opaque' + } + ] + } + }) + ).rejects.toThrow('InvalidUploadFileType'); + }); + + it('accepts extensionless unknown binary with declared opaque extension', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from([0, 1, 2, 3]), + filename: 'data', + fileHint: { + filename: 'data', + declaredExtension: '.dat', + source: 'remote-url' + }, + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.dat'], + extensionRules: [ + { + extension: '.dat', + source: 'custom', + verification: 'opaque' + } + ] + } + }) + ).resolves.toMatchObject({ + filename: 'data.dat', + contentType: 'application/octet-stream', + extension: '.dat', + detectionSource: 'opaque-extension' + }); + }); + + it('rejects extensionless unknown binary declared as content-verifiable image', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from([0, 1, 2, 3]), + filename: 'image', + fileHint: { + filename: 'image', + declaredExtension: '.png', + source: 'remote-url' + }, + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.png'] + } + }) + ).rejects.toThrow('InvalidUploadFileType'); + }); + + it('accepts custom opaque extension even when content has no stable magic', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from('plain custom payload', 'utf8'), + filename: 'data.dat', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.dat'], + extensionRules: [ + { + extension: '.dat', + source: 'custom', + verification: 'opaque' + } + ] + } + }) + ).resolves.toMatchObject({ + filename: 'data.dat', + contentType: 'application/octet-stream', + extension: '.dat', + detectionSource: 'opaque-extension' + }); + }); + + it('accepts custom exe extension as opaque', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from([0, 1, 2, 3]), + filename: 'tool.exe', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.exe'], + extensionRules: [ + { + extension: '.exe', + source: 'custom', + verification: 'opaque' + } + ] + } + }) + ).resolves.toMatchObject({ + filename: 'tool.exe', + contentType: 'application/octet-stream', + extension: '.exe', + detectionSource: 'opaque-extension' + }); + }); + + it('accepts extensionless text when text fallback is allowed', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from('plain text body', 'utf8'), + filename: 'README', + uploadConstraints: { + defaultContentType: 'application/octet-stream', + allowedExtensions: ['.txt'] + } + }) + ).resolves.toMatchObject({ + filename: 'README.txt', + contentType: 'text/plain', + extension: '.txt', + detectionSource: 'text' + }); + }); + it('rejects disallowed file extensions before content inspection', async () => { await expect( validateUploadFile({ @@ -160,7 +329,20 @@ describe('validateUploadFile', () => { allowedExtensions: ['.png'] } }) - ).rejects.toThrow('InvalidUploadFileType'); + ).rejects.toThrow('UploadFileTypeMismatch'); + }); + + it('rejects PHP text content renamed as png', async () => { + await expect( + validateUploadFile({ + buffer: Buffer.from('', 'utf8'), + filename: 'sample.png', + uploadConstraints: { + defaultContentType: 'image/png', + allowedExtensions: ['.txt', '.png'] + } + }) + ).rejects.toThrow('UploadFileTypeMismatch'); }); it('accepts OOXML files even when detection falls back to zip container', async () => { @@ -173,7 +355,7 @@ describe('validateUploadFile', () => { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'demo.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); @@ -191,7 +373,7 @@ describe('validateUploadFile', () => { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ filename: 'demo.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); diff --git a/packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts b/packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts index d4974419c431..1ddd2be83e81 100644 --- a/packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts +++ b/packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts @@ -49,6 +49,14 @@ describe('sandboxGetFileUrlTool', () => { }); expect(JSON.parse(result.response)).toEqual([{ fileUrl: 'signed-url', filename: 'file.txt' }]); + expect(result.fileRefs).toEqual([ + { + key: 'chat/file.txt', + filename: 'file.txt', + url: 'signed-url' + } + ]); + expect(result.response).not.toContain('chat/file.txt'); expect(sandbox.provider.readFileStream).toHaveBeenCalledWith('/workspace/file.txt'); expect(s3Mock.uploadChatFile).toHaveBeenCalledWith( expect.objectContaining({ @@ -56,14 +64,14 @@ describe('sandboxGetFileUrlTool', () => { sourceId: 'app', chatId: 'chat', uId: 'user', - filename: 'file.txt' + filename: 'file.txt', + expiredTime: expect.any(Date) }) ); expect(s3Mock.createGetChatFileURL).toHaveBeenCalledWith({ key: 'chat/file.txt', expiredHours: 2, - external: true, - mode: 'presigned' + external: true }); }); }); diff --git a/packages/service/test/core/chat/saveChat.test.ts b/packages/service/test/core/chat/saveChat.test.ts index 0acd23c8ae2e..98e646a9cbce 100644 --- a/packages/service/test/core/chat/saveChat.test.ts +++ b/packages/service/test/core/chat/saveChat.test.ts @@ -168,6 +168,51 @@ describe('pushChatRecords', () => { expect(chatItems).toHaveLength(0); }); + it('should keep Sandbox temporary TTL when chat history is not recorded', async () => { + const chatId = 'NO_RECORD_HISTORIES'; + const fileKey = `chat/${ChatSourceTypeEnum.app}/${testAppId}/${testTmbId}/${chatId}/report.csv`; + const props = createMockProps( + { + chatId, + aiContent: { + obj: ChatRoleEnum.AI, + value: [ + { + tools: [ + { + id: 'call_file', + toolName: 'Sandbox/Get File URL', + toolAvatar: '', + functionName: 'sandbox_get_file_url', + params: '{}', + response: '[]', + fileRefs: [ + { key: fileKey, filename: 'report.csv', url: 'https://files/report' } + ] + } + ] + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + await MongoS3TTL.create({ + bucketName: S3Buckets.private, + minioKey: fileKey, + expiredTime: new Date(Date.now() + 2 * 60 * 60 * 1000) + }); + + await pushChatRecords(props); + + expect( + await MongoS3TTL.exists({ + bucketName: S3Buckets.private, + minioKey: fileKey + }) + ).not.toBeNull(); + }); + it('should remove file URLs from user content before processing', async () => { const props = createMockProps({ userContent: { @@ -689,6 +734,101 @@ describe('pushChatRecords', () => { }); }); + it('should persist Sandbox tool files and remove their temporary S3 TTL', async () => { + const responseChatItemId = 'sandbox-file-finalize'; + const chatId = 'sandbox-file-chat'; + const fileUrl = 'https://files.example.com/temporary-link'; + const fileKey = `chat/${ChatSourceTypeEnum.app}/${testAppId}/${testTmbId}/${chatId}/report.csv`; + const props = createMockProps( + { + chatId, + userContent: { + dataId: responseChatItemId, + obj: ChatRoleEnum.Human, + value: [{ text: { content: 'Generate a report' } }] + }, + aiContent: { + dataId: responseChatItemId, + obj: ChatRoleEnum.AI, + value: [ + { + tools: [ + { + id: 'call_file', + toolName: 'Sandbox/Get File URL', + toolAvatar: '', + functionName: 'sandbox_get_file_url', + params: '{"paths":["report.csv"]}', + response: JSON.stringify([{ fileUrl, filename: 'report.csv' }]), + fileRefs: [{ key: fileKey, filename: 'report.csv', url: fileUrl }] + } + ] + }, + { + text: { content: `[report.csv](${fileUrl})` } + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await MongoS3TTL.create({ + bucketName: S3Buckets.private, + minioKey: fileKey, + expiredTime: new Date(Date.now() + 2 * 60 * 60 * 1000) + }); + await MongoChat.create({ + appId: testAppId, + chatId, + teamId: testTeamId, + tmbId: testTmbId, + sourceType: ChatSourceTypeEnum.app, + source: props.source, + chatGenerateStatus: ChatGenerateStatusEnum.generating, + hasBeenRead: false + }); + await MongoChatItem.create([ + { + teamId: testTeamId, + tmbId: testTmbId, + sourceType: ChatSourceTypeEnum.app, + appId: testAppId, + chatId, + dataId: responseChatItemId, + obj: ChatRoleEnum.Human, + value: [] + }, + { + teamId: testTeamId, + tmbId: testTmbId, + sourceType: ChatSourceTypeEnum.app, + appId: testAppId, + chatId, + dataId: responseChatItemId, + obj: ChatRoleEnum.AI, + value: [] + } + ]); + + await finalizeChatRound(props); + + expect( + await MongoS3TTL.findOne({ + bucketName: S3Buckets.private, + minioKey: fileKey + }).lean() + ).toBeNull(); + const aiItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId, + obj: ChatRoleEnum.AI + }).lean(); + expect(aiItem?.value[0].tools?.[0].fileRefs).toEqual([ + { key: fileKey, filename: 'report.csv', url: fileUrl } + ]); + }); + it('should mark prepared round as error and keep ai placeholder', async () => { const responseChatItemId = 'prepared-ai-error'; const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); diff --git a/packages/service/test/core/chat/utils.test.ts b/packages/service/test/core/chat/utils.test.ts index ff0ab99df0f0..145507a44896 100644 --- a/packages/service/test/core/chat/utils.test.ts +++ b/packages/service/test/core/chat/utils.test.ts @@ -14,19 +14,23 @@ const { mockCreateGetChatFileURL } = vi.hoisted(() => ({ mockCreateGetChatFileURL: vi.fn() })); +type MockCreatePreviewOptions = { + expiredHours?: number; + mode?: 'short-proxy' | 'short-redirect' | 'presigned'; +}; + vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ getS3ChatSource: () => ({ createGetChatFileURL: mockCreateGetChatFileURL }), - createChatFilePreviewUrlGetter: - (options?: { expiredHours?: number; mode?: 'proxy' | 'presigned' }) => async (key: string) => { - const { url } = await mockCreateGetChatFileURL({ - key, - external: true, - ...options - }); - return url; - } + createChatFilePreviewUrlGetter: (options?: MockCreatePreviewOptions) => async (key: string) => { + const { url } = await mockCreateGetChatFileURL({ + key, + external: true, + ...options + }); + return url; + } })); describe('presignVariablesFileUrls', () => { @@ -298,4 +302,53 @@ describe('addPreviewUrlToChatItems', () => { external: true }); }); + + it('重新签发 Sandbox 工具文件链接并隐藏内部 fileRefs', async () => { + const oldUrl = 'https://old.example.com/file-token'; + const newUrl = 'https://new.example.com/file-token'; + mockCreateGetChatFileURL.mockResolvedValueOnce({ url: newUrl }); + const histories = [ + { + obj: 'AI', + value: [ + { + tools: [ + { + id: 'call_file', + toolName: 'Sandbox/Get File URL', + toolAvatar: '', + functionName: 'sandbox_get_file_url', + params: '{}', + response: JSON.stringify([{ fileUrl: oldUrl, filename: 'report.csv' }]), + fileRefs: [ + { + key: 'chat/app/app-1/user-1/chat-1/report.csv', + filename: 'report.csv', + url: oldUrl + } + ] + } + ] + }, + { + text: { + content: `[report.csv](${oldUrl})` + } + } + ] + } + ]; + + await addPreviewUrlToChatItems(histories as any, 'chatFlow'); + + expect(JSON.parse(histories[0].value[0].tools[0].response)).toEqual([ + { fileUrl: newUrl, filename: 'report.csv' } + ]); + expect(histories[0].value[0].tools[0]).not.toHaveProperty('fileRefs'); + expect(histories[0].value[1].text.content).toBe(`[report.csv](${newUrl})`); + expect(mockCreateGetChatFileURL).toHaveBeenCalledWith({ + key: 'chat/app/app-1/user-1/chat-1/report.csv', + external: true + }); + }); }); diff --git a/packages/service/test/core/dataset/data/controller.test.ts b/packages/service/test/core/dataset/data/controller.test.ts index 424ba05543da..d28a8c240dc8 100644 --- a/packages/service/test/core/dataset/data/controller.test.ts +++ b/packages/service/test/core/dataset/data/controller.test.ts @@ -1,9 +1,26 @@ -import { describe, expect, it } from 'vitest'; -import { formatDatasetDataValue } from '@fastgpt/service/core/dataset/data/controller'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatDatasetDataValue, + formatDatasetDataValues +} from '@fastgpt/service/core/dataset/data/controller'; + +const mockCreateS3DownloadAccessUrls = vi.hoisted(() => + vi.fn(async (params: Array<{ objectKey: string }>) => + params.map(({ objectKey }) => `https://files.test/${objectKey}`) + ) +); + +vi.mock('@fastgpt/service/common/s3/accessLink', () => ({ + createS3DownloadAccessUrls: mockCreateS3DownloadAccessUrls +})); describe('formatDatasetDataValue', () => { - it('should append image descriptions to markdown image alt text in question and answer', () => { - const result = formatDatasetDataValue({ + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should append image descriptions to markdown image alt text in question and answer', async () => { + const result = await formatDatasetDataValue({ q: 'Question ![cat]( https://example.com/cat.png ) and ![bird](https://example.com/bird.png)', a: 'Answer ![](https://example.com/dog.png)', imageDescMap: { @@ -17,4 +34,34 @@ describe('formatDatasetDataValue', () => { a: 'Answer ![dog desc](https://example.com/dog.png)' }); }); + + it('should batch duplicate keys across q, a and imageId', async () => { + const result = await formatDatasetDataValues([ + { + q: 'Question ![shared](dataset/team/shared.png)', + a: 'Answer [file](chat/app/file.pdf)' + }, + { + q: 'Image title', + imageId: 'dataset/team/shared.png' + } + ]); + + expect(mockCreateS3DownloadAccessUrls).toHaveBeenCalledTimes(1); + expect(mockCreateS3DownloadAccessUrls.mock.calls[0][0].map((item) => item.objectKey)).toEqual([ + 'dataset/team/shared.png', + 'chat/app/file.pdf' + ]); + expect(result).toEqual([ + { + q: 'Question ![shared](https://files.test/dataset/team/shared.png)', + a: 'Answer [file](https://files.test/chat/app/file.pdf)' + }, + { + q: '![Image title](https://files.test/dataset/team/shared.png)', + a: undefined, + imagePreivewUrl: 'https://files.test/dataset/team/shared.png' + } + ]); + }); }); diff --git a/packages/service/test/core/dataset/search/defaultRecall.test.ts b/packages/service/test/core/dataset/search/defaultRecall.test.ts index b5b8e983df38..21b525f5c5c6 100644 --- a/packages/service/test/core/dataset/search/defaultRecall.test.ts +++ b/packages/service/test/core/dataset/search/defaultRecall.test.ts @@ -17,6 +17,11 @@ const mockCountPromptTokens = vi.hoisted(() => vi.fn(async (prompt: string) => p const mockCountPromptTokensBatch = vi.hoisted(() => vi.fn(async (prompts: string[]) => prompts.map((prompt) => prompt.length)) ); +const mockCreateS3DownloadAccessUrls = vi.hoisted(() => + vi.fn(async (params: Array<{ objectKey: string }>) => + params.map(({ objectKey }) => `https://files.test/${objectKey}`) + ) +); const originalMultipleDataToBase64 = serviceEnv.MULTIPLE_DATA_TO_BASE64; @@ -40,7 +45,12 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ })); vi.mock('@fastgpt/service/common/file/image/utils', () => ({ - getImageBase64: mockGetImageBase64 + getImageBase64: mockGetImageBase64, + addEndpointToImageUrl: (text: string) => text +})); + +vi.mock('@fastgpt/service/common/s3/accessLink', () => ({ + createS3DownloadAccessUrls: mockCreateS3DownloadAccessUrls })); // defaultRecall 的结果过滤只关心 token 数的相对大小,测试里用稳定 mock @@ -368,4 +378,69 @@ describe('default recall dataset search', () => { expect(mockMongoDatasetDataTextAggregate).not.toHaveBeenCalled(); expect(result.searchRes).toEqual([]); }); + + it('should only batch-sign S3 keys from results that survive score filtering', async () => { + mockGetLLMModel.mockReturnValue(undefined); + mockIsImageEmbeddingModel.mockReturnValue(false); + mockGetVectors.mockResolvedValueOnce({ + tokens: 5, + vectors: [[0.1, 0.2]] + }); + mockRecallFromVectorStore.mockResolvedValueOnce({ + results: [ + { id: 'index-keep', collectionId: 'collection-1', score: 0.9 }, + { id: 'index-filtered', collectionId: 'collection-1', score: 0.1 } + ] + }); + mockMongoDatasetCollectionFind.mockImplementation((query: Record) => { + if (query?.forbid) return []; + return { + lean: vi.fn().mockResolvedValue([{ _id: 'collection-1', name: 'Source' }]) + }; + }); + mockMongoDatasetDataFind.mockReturnValueOnce({ + lean: vi.fn().mockResolvedValue([ + { + _id: 'data-keep', + datasetId: 'dataset-1', + collectionId: 'collection-1', + updateTime: new Date('2026-01-01'), + q: 'Keep ![image](dataset/team/keep.png)', + a: '', + chunkIndex: 0, + indexes: [{ dataId: 'index-keep' }] + }, + { + _id: 'data-filtered', + datasetId: 'dataset-1', + collectionId: 'collection-1', + updateTime: new Date('2026-01-01'), + q: 'Filtered ![image](dataset/team/filtered.png)', + a: '', + chunkIndex: 1, + indexes: [{ dataId: 'index-filtered' }] + } + ]) + }); + + const result = await searchDatasetData({ + histories: [], + teamId: 'team-1', + model: 'mock-embedding-model', + datasetIds: ['dataset-1'], + reRankQuery: 'query', + textQueries: ['query'], + limit: 5000, + similarity: 0.5, + searchMode: DatasetSearchModeEnum.embedding, + usingReRank: false + }); + + expect(result.searchRes).toHaveLength(1); + expect(result.searchRes[0]?.q).toContain('https://files.test/dataset/team/keep.png'); + expect(mockCreateS3DownloadAccessUrls).toHaveBeenCalledTimes(1); + expect(mockCreateS3DownloadAccessUrls.mock.calls[0][0].map((item) => item.objectKey)).toEqual([ + 'dataset/team/keep.png' + ]); + }); }); diff --git a/packages/service/test/core/dataset/utils.test.ts b/packages/service/test/core/dataset/utils.test.ts index 03a62521582a..12f5bd6c1d30 100644 --- a/packages/service/test/core/dataset/utils.test.ts +++ b/packages/service/test/core/dataset/utils.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { + createS3KeysPreviewUrlMap, getDatasetImageIndexCapability, getDatasetImageTrainingMode, + getS3ObjectKeysFromMarkdownTexts, + replaceS3KeysToPreviewUrls, replaceS3KeyToPreviewUrl } from '@fastgpt/service/core/dataset/utils'; import { @@ -15,6 +18,14 @@ import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; +const mockCreateS3DownloadAccessUrls = vi.hoisted(() => + vi.fn(async (params: Array<{ objectKey: string }>) => + params.map( + ({ objectKey }) => `https://example.com/api/system/file/d/mock-short-link-${objectKey}` + ) + ) +); + vi.mock('@fastgpt/service/common/s3/utils', () => ({ isS3ObjectKey: vi.fn((key: string, source: string) => { if (!key) return false; @@ -22,11 +33,8 @@ vi.mock('@fastgpt/service/common/s3/utils', () => ({ }) })); -vi.mock('@fastgpt/service/common/s3/security/token', () => ({ - jwtSignS3DownloadToken: vi.fn( - ({ objectKey }: { objectKey: string }) => - `https://example.com/api/system/file/download/mock-jwt-token-${objectKey}` - ) +vi.mock('@fastgpt/service/common/s3/accessLink', () => ({ + createS3DownloadAccessUrls: mockCreateS3DownloadAccessUrls })); vi.mock('@fastgpt/service/common/s3/contracts/type', () => ({ @@ -47,84 +55,84 @@ describe('replaceS3KeyToPreviewUrl', () => { }); describe('边界情况处理', () => { - it('空字符串应返回空字符串', () => { - const result = replaceS3KeyToPreviewUrl('', expiredTime); + it('空字符串应返回空字符串', async () => { + const result = await replaceS3KeyToPreviewUrl('', expiredTime); expect(result).toBe(''); }); - it('null 应返回 null', () => { - const result = replaceS3KeyToPreviewUrl(null as unknown as string, expiredTime); + it('null 应返回 null', async () => { + const result = await replaceS3KeyToPreviewUrl(null as unknown as string, expiredTime); expect(result).toBe(null); }); - it('undefined 应返回 undefined', () => { - const result = replaceS3KeyToPreviewUrl(undefined as unknown as string, expiredTime); + it('undefined 应返回 undefined', async () => { + const result = await replaceS3KeyToPreviewUrl(undefined as unknown as string, expiredTime); expect(result).toBe(undefined); }); - it('非字符串类型应原样返回', () => { - const result = replaceS3KeyToPreviewUrl(123 as unknown as string, expiredTime); + it('非字符串类型应原样返回', async () => { + const result = await replaceS3KeyToPreviewUrl(123 as unknown as string, expiredTime); expect(result).toBe(123); }); }); // 测试不包含 S3 链接的普通文本 describe('普通文本处理', () => { - it('纯文本不做任何替换', () => { + it('纯文本不做任何替换', async () => { const text = '这是一段普通文本,不包含任何图片链接'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toBe(text); }); - it('普通 HTTP 链接不做替换', () => { + it('普通 HTTP 链接不做替换', async () => { const text = '![image](https://example.com/image.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toBe(text); }); - it('普通 markdown 链接不做替换', () => { + it('普通 markdown 链接不做替换', async () => { const text = '[链接文本](https://example.com/page)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toBe(text); }); }); // 测试 dataset 前缀的 S3 链接替换 describe('dataset S3 链接替换', () => { - it('应替换 dataset 图片链接', () => { + it('应替换 dataset 图片链接', async () => { const text = '![image.png](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/aZos7D-214afce5-4d42-4356-9e05-8164d51c59ae.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); expect(result).toContain('dataset/68fee42e1d416bb5ddc85b19'); expect(result).toMatch(/!\[image\.png\]\(https:\/\/example\.com/); }); - it('应替换 dataset 普通链接(非图片)', () => { + it('应替换 dataset 普通链接(非图片)', async () => { const text = '[文档](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/document.pdf)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); expect(result).toMatch(/\[文档\]\(https:\/\/example\.com/); }); }); // 测试 chat 前缀的 S3 链接替换 describe('chat S3 链接替换', () => { - it('应替换 chat 图片链接', () => { + it('应替换 chat 图片链接', async () => { const text = '![screenshot.png](chat/691ae29d404d0468717dd747/68ad85a7463006c96379a07/jXfXy8yfGAFs9WJpcWRbAhV2/parsed/9a0f4fed-4edf-4613-a8d6-533af5ae51dc.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); expect(result).toContain('chat/691ae29d404d0468717dd747'); }); }); // 测试多个链接替换 describe('多个链接替换', () => { - it('应正确替换多个 S3 链接', () => { + it('应正确替换多个 S3 链接', async () => { const text = `这是一段包含多个图片的文本: ![图片1](dataset/team1/collection1/image1.png) 一些中间文字 @@ -132,11 +140,11 @@ describe('replaceS3KeyToPreviewUrl', () => { 更多文字 ![外部图片](https://external.com/image3.png)`; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); // dataset 和 chat 链接应被替换 - expect(result).toContain('mock-jwt-token-dataset/team1/collection1/image1.png'); - expect(result).toContain('mock-jwt-token-chat/app1/user1/chat1/image2.jpg'); + expect(result).toContain('mock-short-link-dataset/team1/collection1/image1.png'); + expect(result).toContain('mock-short-link-chat/app1/user1/chat1/image2.jpg'); // 外部链接不应被替换 expect(result).toContain('https://external.com/image3.png'); }); @@ -144,17 +152,17 @@ describe('replaceS3KeyToPreviewUrl', () => { // 测试不支持的 S3 前缀 describe('不支持的 S3 前缀', () => { - it('avatar 前缀不应被替换(只支持 dataset 和 chat)', () => { + it('avatar 前缀不应被替换(只支持 dataset 和 chat)', async () => { const text = '![头像](avatar/team1/user-avatar.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); // avatar 的 isS3ObjectKey 返回 false(因为只检查 dataset 和 chat) expect(result).toBe(text); }); - it('temp 前缀应被替换', () => { + it('temp 前缀应被替换', async () => { const text = '![临时文件](temp/team1/temp-file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); expect(result).toContain('temp/team1/temp-file.png'); }); }); @@ -162,280 +170,280 @@ describe('replaceS3KeyToPreviewUrl', () => { // 测试特殊字符处理 describe('特殊字符处理', () => { // 中文字符 - it('文件名包含中文应正常处理', () => { + it('文件名包含中文应正常处理', async () => { const text = '![中文图片名.png](dataset/team1/collection1/中文文件名.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); - it('alt 文本为空应正常处理', () => { + it('alt 文本为空应正常处理', async () => { const text = '![](dataset/team1/collection1/no-alt.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[\]\(https:\/\/example\.com/); }); // 日韩文字符 - it('文件名包含日文应正常处理', () => { + it('文件名包含日文应正常处理', async () => { const text = '![日本語ファイル](dataset/team1/日本語テスト.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/日本語テスト.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/日本語テスト.png'); }); - it('文件名包含韩文应正常处理', () => { + it('文件名包含韩文应正常处理', async () => { const text = '![한국어](dataset/team1/한국어파일.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/한국어파일.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/한국어파일.png'); }); // Emoji 表情符号 - it('文件名包含 emoji 应正常处理', () => { + it('文件名包含 emoji 应正常处理', async () => { const text = '![🎉 celebration](dataset/team1/🎉emoji🚀test.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/🎉emoji🚀test.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/🎉emoji🚀test.png'); }); - it('alt 文本包含多个 emoji 应正常处理', () => { + it('alt 文本包含多个 emoji 应正常处理', async () => { const text = '![🔥💯🎯](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[🔥💯🎯\]\(https:\/\/example\.com/); }); // 特殊符号 - it('文件名包含下划线和连字符应正常处理', () => { + it('文件名包含下划线和连字符应正常处理', async () => { const text = '![image](dataset/team1/my_file-name_v2.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/my_file-name_v2.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/my_file-name_v2.png'); }); - it('文件名包含 @ 符号应正常处理', () => { + it('文件名包含 @ 符号应正常处理', async () => { const text = '![email](dataset/team1/user@example.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/user@example.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/user@example.png'); }); - it('文件名包含 # 符号应正常处理', () => { + it('文件名包含 # 符号应正常处理', async () => { const text = '![hash](dataset/team1/file#1.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file#1.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file#1.png'); }); - it('文件名包含 $ 符号应正常处理', () => { + it('文件名包含 $ 符号应正常处理', async () => { const text = '![dollar](dataset/team1/price$100.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/price$100.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/price$100.png'); }); - it('文件名包含 % 符号应正常处理', () => { + it('文件名包含 % 符号应正常处理', async () => { const text = '![percent](dataset/team1/50%off.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/50%off.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/50%off.png'); }); - it('文件名包含 + 符号应正常处理', () => { + it('文件名包含 + 符号应正常处理', async () => { const text = '![plus](dataset/team1/a+b.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/a+b.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/a+b.png'); }); - it('文件名包含 = 符号应正常处理', () => { + it('文件名包含 = 符号应正常处理', async () => { const text = '![equals](dataset/team1/x=1.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/x=1.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/x=1.png'); }); // 多个点号 - it('文件名包含多个点号应正常处理', () => { + it('文件名包含多个点号应正常处理', async () => { const text = '![dots](dataset/team1/file.name.v1.2.3.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file.name.v1.2.3.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file.name.v1.2.3.png'); }); // 空格相关 - it('alt 文本包含空格应正常处理', () => { + it('alt 文本包含空格应正常处理', async () => { const text = '![image with spaces](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[image with spaces\]\(https:\/\/example\.com/); }); - it('文件名包含 URL 编码的空格 %20 应正常处理', () => { + it('文件名包含 URL 编码的空格 %20 应正常处理', async () => { const text = '![encoded](dataset/team1/file%20name.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file%20name.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file%20name.png'); }); // 括号类字符 - it('alt 文本包含转义方括号不匹配正则,不做替换', () => { + it('alt 文本包含转义方括号不匹配正则,不做替换', async () => { // 由于 markdown 正则 [^\]]* 不匹配包含 ] 的 alt 文本,这种情况不会被替换 const text = '![image \\[1\\]](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); // 预期不做替换 expect(result).toBe(text); }); - it('alt 文本包含圆括号应正常处理', () => { + it('alt 文本包含圆括号应正常处理', async () => { const text = '![image (1)](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); - it('文件名包含花括号应正常处理', () => { + it('文件名包含花括号应正常处理', async () => { const text = '![braces](dataset/team1/file{1}.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file{1}.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file{1}.png'); }); - it('文件名包含方括号应正常处理', () => { + it('文件名包含方括号应正常处理', async () => { const text = '![braces](dataset/team1/file[1].png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file[1].png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file[1].png'); }); // 引号 - it('alt 文本包含单引号应正常处理', () => { + it('alt 文本包含单引号应正常处理', async () => { const text = "![it's a test](dataset/team1/file.png)"; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[it's a test\]\(https:\/\/example\.com/); }); - it('alt 文本包含双引号应正常处理', () => { + it('alt 文本包含双引号应正常处理', async () => { const text = '![say "hello"](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); // 反斜杠 - it('alt 文本包含反斜杠应正常处理', () => { + it('alt 文本包含反斜杠应正常处理', async () => { const text = '![path\\to\\file](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); // 特殊 markdown 字符 - it('alt 文本包含星号应正常处理', () => { + it('alt 文本包含星号应正常处理', async () => { const text = '![*important*](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[\*important\*\]\(https:\/\/example\.com/); }); - it('alt 文本包含下划线强调应正常处理', () => { + it('alt 文本包含下划线强调应正常处理', async () => { const text = '![_emphasis_](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[_emphasis_\]\(https:\/\/example\.com/); }); - it('alt 文本包含反引号应正常处理', () => { + it('alt 文本包含反引号应正常处理', async () => { const text = '![`code`](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toMatch(/!\[`code`\]\(https:\/\/example\.com/); }); // 数字和字母混合 - it('文件名是纯 UUID 格式应正常处理', () => { + it('文件名是纯 UUID 格式应正常处理', async () => { const text = '![uuid](dataset/team1/550e8400-e29b-41d4-a716-446655440000.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toContain( - 'mock-jwt-token-dataset/team1/550e8400-e29b-41d4-a716-446655440000.png' + 'mock-short-link-dataset/team1/550e8400-e29b-41d4-a716-446655440000.png' ); }); - it('文件名是纯数字应正常处理', () => { + it('文件名是纯数字应正常处理', async () => { const text = '![numbers](dataset/team1/123456789.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/123456789.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/123456789.png'); }); // 超长文件名 - it('超长文件名应正常处理', () => { + it('超长文件名应正常处理', async () => { const longName = 'a'.repeat(200); const text = `![long](dataset/team1/${longName}.png)`; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain(`mock-jwt-token-dataset/team1/${longName}.png`); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain(`mock-short-link-dataset/team1/${longName}.png`); }); // 阿拉伯文和希伯来文(RTL 文字) - it('文件名包含阿拉伯文应正常处理', () => { + it('文件名包含阿拉伯文应正常处理', async () => { const text = '![عربي](dataset/team1/ملف.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/ملف.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/ملف.png'); }); // 俄文 - it('文件名包含俄文应正常处理', () => { + it('文件名包含俄文应正常处理', async () => { const text = '![русский](dataset/team1/файл.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/файл.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/файл.png'); }); // 泰文 - it('文件名包含泰文应正常处理', () => { + it('文件名包含泰文应正常处理', async () => { const text = '![ไทย](dataset/team1/ไฟล์.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/ไฟล์.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/ไฟล์.png'); }); // 特殊扩展名 - it('无扩展名的文件应正常处理', () => { + it('无扩展名的文件应正常处理', async () => { const text = '![noext](dataset/team1/README)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/README'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/README'); }); - it('双扩展名的文件应正常处理', () => { + it('双扩展名的文件应正常处理', async () => { const text = '![tarball](dataset/team1/archive.tar.gz)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/archive.tar.gz'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/archive.tar.gz'); }); // 管道符和其他 shell 特殊字符 - it('文件名包含管道符应正常处理', () => { + it('文件名包含管道符应正常处理', async () => { const text = '![pipe](dataset/team1/a|b.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/a|b.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/a|b.png'); }); - it('文件名包含波浪号应正常处理', () => { + it('文件名包含波浪号应正常处理', async () => { const text = '![tilde](dataset/team1/~user.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/~user.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/~user.png'); }); - it('文件名包含 & 符号应正常处理', () => { + it('文件名包含 & 符号应正常处理', async () => { const text = '![ampersand](dataset/team1/a&b.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/a&b.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/a&b.png'); }); // 换行符 - it('alt 文本不包含换行符时应正常处理', () => { + it('alt 文本不包含换行符时应正常处理', async () => { const text = '![single line](dataset/team1/file.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); // 特殊组合 - it('文件名包含多种特殊字符组合应正常处理', () => { + it('文件名包含多种特殊字符组合应正常处理', async () => { const text = '![complex](dataset/team1/file_v1.2-beta@test#1$100%off.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/file_v1.2-beta@test#1$100%off.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/file_v1.2-beta@test#1$100%off.png'); }); - it('中英文混合 alt 和文件名应正常处理', () => { + it('中英文混合 alt 和文件名应正常处理', async () => { const text = '![测试image图片](dataset/team1/test测试file文件.png)'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('mock-jwt-token-dataset/team1/test测试file文件.png'); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); + expect(result).toContain('mock-short-link-dataset/team1/test测试file文件.png'); }); }); // 测试链接格式边界情况 describe('链接格式边界情况', () => { - it('链接中有空格应正常处理', () => { + it('链接中有空格应正常处理', async () => { const text = '![image]( dataset/team1/collection1/image.png )'; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); - expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-'); + expect(result).toContain('https://example.com/api/system/file/d/mock-short-link-'); }); - it('混合文本和链接应只替换 S3 链接', () => { + it('混合文本和链接应只替换 S3 链接', async () => { const text = `# 标题 普通段落文字 ![S3图片](dataset/team1/file.png) 后续文字 @@ -446,10 +454,10 @@ describe('replaceS3KeyToPreviewUrl', () => { 代码块 \`\`\``; - const result = replaceS3KeyToPreviewUrl(text, expiredTime); + const result = await replaceS3KeyToPreviewUrl(text, expiredTime); expect(result).toContain( - 'https://example.com/api/system/file/download/mock-jwt-token-dataset/team1/file.png' + 'https://example.com/api/system/file/d/mock-short-link-dataset/team1/file.png' ); expect(result).toContain('https://google.com'); expect(result).toContain('# 标题'); @@ -457,8 +465,53 @@ describe('replaceS3KeyToPreviewUrl', () => { }); }); +describe('批量 S3 预览 URL 格式化', () => { + const expiredTime = new Date('2025-12-31'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('应跨多段文本按首次出现顺序提取并去重 key', () => { + expect( + getS3ObjectKeysFromMarkdownTexts([ + '![a](dataset/team/a.png) ![a2](dataset/team/a.png)', + '[b](chat/app/b.pdf) ![external](https://example.com/c.png)', + '![avatar](avatar/team/avatar.png)' + ]) + ).toEqual(['dataset/team/a.png', 'chat/app/b.pdf']); + }); + + it('多段文本中的重复 key 应只进入一次批量签发并保持文本顺序', async () => { + const result = await replaceS3KeysToPreviewUrls( + ['first ![a](dataset/team/a.png)', 'second [same](dataset/team/a.png) [b](chat/app/b.pdf)'], + expiredTime + ); + + expect(mockCreateS3DownloadAccessUrls).toHaveBeenCalledTimes(1); + expect(mockCreateS3DownloadAccessUrls.mock.calls[0][0].map((item) => item.objectKey)).toEqual([ + 'dataset/team/a.png', + 'chat/app/b.pdf' + ]); + expect(result).toEqual([ + 'first ![a](https://example.com/api/system/file/d/mock-short-link-dataset/team/a.png)', + 'second [same](https://example.com/api/system/file/d/mock-short-link-dataset/team/a.png) [b](https://example.com/api/system/file/d/mock-short-link-chat/app/b.pdf)' + ]); + }); + + it('超过批量上限时应分片且完整返回映射', async () => { + const objectKeys = Array.from({ length: 501 }, (_, index) => `dataset/team/${index}.png`); + const result = await createS3KeysPreviewUrlMap({ objectKeys, expiredTime }); + + expect(mockCreateS3DownloadAccessUrls).toHaveBeenCalledTimes(2); + expect(mockCreateS3DownloadAccessUrls.mock.calls[0][0]).toHaveLength(500); + expect(mockCreateS3DownloadAccessUrls.mock.calls[1][0]).toHaveLength(1); + expect(result.get('dataset/team/500.png')).toContain('mock-short-link-dataset/team/500.png'); + }); +}); + describe('matchDatasetDataMarkdownImageUrls', () => { - it('应提取统一的 markdown 图片节点结构', () => { + it('应提取统一的 markdown 图片节点结构', async () => { const result = matchDatasetDataMarkdownImages( '文字 ![猫]( dataset/team/cat.png ) 和 ![dog](https://example.com/dog.png)' ); @@ -479,7 +532,7 @@ describe('matchDatasetDataMarkdownImageUrls', () => { ]); }); - it('应提取 markdown 图片 URL 并忽略普通链接', () => { + it('应提取 markdown 图片 URL 并忽略普通链接', async () => { const result = matchDatasetDataMarkdownImageUrls( '![a](dataset/team/a.png) [普通链接](https://example.com) ![b](https://img.test/b.jpg)' ); @@ -487,7 +540,7 @@ describe('matchDatasetDataMarkdownImageUrls', () => { expect(result).toEqual(['dataset/team/a.png', 'https://img.test/b.jpg']); }); - it('应从多个文本字段按首次出现顺序去重图片 URL', () => { + it('应从多个文本字段按首次出现顺序去重图片 URL', async () => { const result = uniqueDatasetDataMarkdownImageUrls([ 'new ![a](dataset/team/a.png) ![a again](dataset/team/a.png)', undefined, @@ -499,7 +552,7 @@ describe('matchDatasetDataMarkdownImageUrls', () => { }); describe('getDatasetImageTrainingMode', () => { - it('有 VLM 且是图片数据时应走 imageParse', () => { + it('有 VLM 且是图片数据时应走 imageParse', async () => { expect( getDatasetImageTrainingMode({ supportVlm: true, @@ -510,7 +563,7 @@ describe('getDatasetImageTrainingMode', () => { ).toBe(TrainingModeEnum.imageParse); }); - it('有图片索引能力且正文有 markdown 图片时应走 image', () => { + it('有图片索引能力且正文有 markdown 图片时应走 image', async () => { expect( getDatasetImageTrainingMode({ supportVlm: false, @@ -520,7 +573,7 @@ describe('getDatasetImageTrainingMode', () => { ).toBe(TrainingModeEnum.image); }); - it('没有图片索引能力时应回退 chunk', () => { + it('没有图片索引能力时应回退 chunk', async () => { expect( getDatasetImageTrainingMode({ supportVlm: false, @@ -539,7 +592,7 @@ describe('getTrainingModeByCollection', () => { }; }); - it('图片自动索引有 VLM 或原生 embedding 图片索引能力时进入 image 队列', () => { + it('图片自动索引有 VLM 或原生 embedding 图片索引能力时进入 image 队列', async () => { expect( getTrainingModeByCollection({ trainingType: DatasetCollectionDataProcessModeEnum.chunk, @@ -574,7 +627,7 @@ describe('getDatasetImageIndexCapability', () => { }); }); - it('未配置 VLM 时不应自动回退到默认 VLM', () => { + it('未配置 VLM 时不应自动回退到默认 VLM', async () => { const result = getDatasetImageIndexCapability({ vectorModel: 'vision-embedding-model' }); @@ -585,7 +638,7 @@ describe('getDatasetImageIndexCapability', () => { expect(result.availableVlmModel).toBeUndefined(); }); - it('配置 VLM 时应同时返回 VLM 和多模态索引能力', () => { + it('配置 VLM 时应同时返回 VLM 和多模态索引能力', async () => { const result = getDatasetImageIndexCapability({ vectorModel: 'vision-embedding-model', vlmModel: 'dataset-vlm-model' diff --git a/packages/service/test/core/workflow/dispatch/ai/chat.test.ts b/packages/service/test/core/workflow/dispatch/ai/chat.test.ts index fbd82736664e..ca45872061a9 100644 --- a/packages/service/test/core/workflow/dispatch/ai/chat.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/chat.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import { chats2GPTMessages, runtimePrompt2ChatsValue } from '@fastgpt/global/core/chat/adapt'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { getAIChatFileContextConfig, + getInputFiles, rewriteChatMessagesWithFiles } from '../../../../../core/workflow/dispatch/ai/chat'; +import { runWithContext } from '../../../../../core/workflow/utils/context'; +import { loadRequestMessages } from '../../../../../core/ai/llm/utils'; +import { serviceEnv } from '../../../../../env'; const createHumanMessage = ({ text, @@ -75,6 +80,64 @@ describe('getAIChatFileContextConfig', () => { }); }); +describe('getInputFiles', () => { + it('keeps the original audio filename when the first-round url has no extension', async () => { + const url = 'https://files.example.com/opaque-short-token'; + let userFiles = [] as ReturnType; + + runWithContext( + { + queryUrlTypeMap: { [url]: ChatFileTypeEnum.audio }, + queryUrlFileMap: { + [url]: { + type: ChatFileTypeEnum.audio, + name: 'meeting.mp3', + url, + key: 'chat/meeting.mp3' + } + }, + mcpClientMemory: {} + }, + () => { + userFiles = getInputFiles({ fileLinks: [url] }); + } + ); + + expect(userFiles).toEqual([ + { + type: ChatFileTypeEnum.audio, + name: 'meeting.mp3', + url, + key: 'chat/meeting.mp3' + } + ]); + + const messages = chats2GPTMessages({ + messages: [ + { + obj: ChatRoleEnum.Human, + value: runtimePrompt2ChatsValue({ files: userFiles }) + } + ] + }); + const previousMultipleDataToBase64 = serviceEnv.MULTIPLE_DATA_TO_BASE64; + serviceEnv.MULTIPLE_DATA_TO_BASE64 = false; + const requestMessages = await loadRequestMessages({ messages, useAudio: true }).finally(() => { + serviceEnv.MULTIPLE_DATA_TO_BASE64 = previousMultipleDataToBase64; + }); + + expect(requestMessages[0]?.content).toEqual([ + { + type: 'input_audio', + input_audio: { + data: url, + format: 'mp3' + } + } + ]); + }); +}); + describe('rewriteChatMessagesWithFiles', () => { const parseFileFn = vi.fn(async (urls: string[]) => urls.map((url) => ({ diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts index b81601bd03db..76ffb459607b 100644 --- a/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/toolCall.test.ts @@ -848,4 +848,58 @@ describe('runToolCall compression node responses', () => { }) ]); }); + + it('attaches internal Sandbox file refs to the persisted tool response', async () => { + const call = { + id: 'call_file', + type: 'function', + function: { + name: 'sandbox_get_file_url', + arguments: '{"paths":["report.csv"]}' + } + }; + const fileRefs = [ + { + key: 'chat/app/app_1/user_1/chat_1/report.csv', + filename: 'report.csv', + url: 'https://files/report' + } + ]; + runAgentLoopMock.mockImplementation(async (options) => { + options.runtime.emitEvent({ + type: 'tool_run_end', + call, + rawResponse: '[{"fileUrl":"https://files/report","filename":"report.csv"}]', + response: '[{"fileUrl":"https://files/report","filename":"report.csv"}]', + seconds: 0.1, + fileRefs + }); + + return { + ...createLoopResult(), + assistantMessages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + content: null, + tool_calls: [call] + }, + { + role: ChatCompletionRequestMessageRoleEnum.Tool, + tool_call_id: call.id, + content: '[{"fileUrl":"https://files/report","filename":"report.csv"}]' + } + ] + }; + }); + + const result = await runToolCall(createProps()); + + expect(result.assistantResponses[0].tools?.[0]).toEqual( + expect.objectContaining({ + id: call.id, + functionName: call.function.name, + fileRefs + }) + ); + }); }); diff --git a/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts b/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts index 863df610f5f0..2e4b0fd4d595 100644 --- a/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts +++ b/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts @@ -12,19 +12,23 @@ const { mockCreateGetChatFileURL } = vi.hoisted(() => ({ mockCreateGetChatFileURL: vi.fn() })); +type MockCreatePreviewOptions = { + expiredHours?: number; + mode?: 'short-proxy' | 'short-redirect' | 'presigned'; +}; + vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ getS3ChatSource: () => ({ createGetChatFileURL: mockCreateGetChatFileURL }), - createChatFilePreviewUrlGetter: - (options?: { expiredHours?: number; mode?: 'proxy' | 'presigned' }) => async (key: string) => { - const { url } = await mockCreateGetChatFileURL({ - key, - external: true, - ...options - }); - return url; - } + createChatFilePreviewUrlGetter: (options?: MockCreatePreviewOptions) => async (key: string) => { + const { url } = await mockCreateGetChatFileURL({ + key, + external: true, + ...options + }); + return url; + } })); describe('dispatchFormInput', () => { diff --git a/packages/service/test/core/workflow/dispatch/plugin/runInput.test.ts b/packages/service/test/core/workflow/dispatch/plugin/runInput.test.ts index bb0a957ae0f1..ff66dc59980f 100644 --- a/packages/service/test/core/workflow/dispatch/plugin/runInput.test.ts +++ b/packages/service/test/core/workflow/dispatch/plugin/runInput.test.ts @@ -8,19 +8,23 @@ const { mockCreateGetChatFileURL } = vi.hoisted(() => ({ mockCreateGetChatFileURL: vi.fn() })); +type MockCreatePreviewOptions = { + expiredHours?: number; + mode?: 'short-proxy' | 'short-redirect' | 'presigned'; +}; + vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ getS3ChatSource: () => ({ createGetChatFileURL: mockCreateGetChatFileURL }), - createChatFilePreviewUrlGetter: - (options?: { expiredHours?: number; mode?: 'proxy' | 'presigned' }) => async (key: string) => { - const { url } = await mockCreateGetChatFileURL({ - key, - external: true, - ...options - }); - return url; - } + createChatFilePreviewUrlGetter: (options?: MockCreatePreviewOptions) => async (key: string) => { + const { url } = await mockCreateGetChatFileURL({ + key, + external: true, + ...options + }); + return url; + } })); describe('dispatchPluginInput', () => { diff --git a/packages/service/test/core/workflow/utils/context.test.ts b/packages/service/test/core/workflow/utils/context.test.ts index c94f22c04e0b..119edb84e2b1 100644 --- a/packages/service/test/core/workflow/utils/context.test.ts +++ b/packages/service/test/core/workflow/utils/context.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; import { + buildQueryUrlFileMap, + buildQueryUrlTypeMap, parseUrlToFileType, runWithContext, getWorkflowContext, @@ -13,6 +15,135 @@ const createWorkflowContext = (queryUrlTypeMap: Record }); describe('WorkflowContext', () => { + describe('buildQueryUrlTypeMap', () => { + it('should preserve media types for opaque first-round file urls', () => { + const audioUrl = '/api/system/file/d/audio-token-without-extension'; + const videoUrl = '/api/system/file/d/video-token-without-extension'; + const imageUrl = '/api/system/file/d/image-token-without-extension'; + const fileUrl = '/api/system/file/d/file-token-without-extension'; + + const queryUrlTypeMap = buildQueryUrlTypeMap([ + { + file: { + type: ChatFileTypeEnum.audio, + name: 'sample.mp3', + url: audioUrl, + key: 'chat/sample.mp3' + } + }, + { + file: { + type: ChatFileTypeEnum.video, + name: 'sample.mp4', + url: videoUrl, + key: 'chat/sample.mp4' + } + }, + { + file: { + type: ChatFileTypeEnum.image, + name: 'sample.png', + url: imageUrl, + key: 'chat/sample.png' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'sample.pdf', + url: fileUrl, + key: 'chat/sample.pdf' + } + }, + { + text: { + content: 'Describe these files' + } + } + ]); + + expect(queryUrlTypeMap).toEqual({ + [audioUrl]: ChatFileTypeEnum.audio, + [videoUrl]: ChatFileTypeEnum.video, + [imageUrl]: ChatFileTypeEnum.image, + [fileUrl]: ChatFileTypeEnum.file + }); + + runWithContext(createWorkflowContext(queryUrlTypeMap), () => { + expect(parseUrlToFileType(audioUrl)?.type).toBe(ChatFileTypeEnum.audio); + expect(parseUrlToFileType(videoUrl)?.type).toBe(ChatFileTypeEnum.video); + expect(parseUrlToFileType(imageUrl)?.type).toBe(ChatFileTypeEnum.image); + expect(parseUrlToFileType(fileUrl)?.type).toBe(ChatFileTypeEnum.file); + }); + }); + + it('should ignore query items without a file url', () => { + expect( + buildQueryUrlTypeMap([ + { + file: { + type: ChatFileTypeEnum.audio, + name: 'sample.mp3', + url: '', + key: 'chat/sample.mp3' + } + }, + { + text: { + content: 'Describe this file' + } + } + ]) + ).toEqual({}); + }); + }); + + describe('buildQueryUrlFileMap', () => { + it('should preserve the original filename for an opaque first-round audio url', () => { + const url = '/api/system/file/d/audio-token-without-extension'; + const queryUrlFileMap = buildQueryUrlFileMap([ + { + file: { + type: ChatFileTypeEnum.audio, + name: 'meeting.mp3', + url, + key: 'chat/meeting.mp3' + } + } + ]); + + runWithContext( + { + ...createWorkflowContext({ [url]: ChatFileTypeEnum.audio }), + queryUrlFileMap + }, + () => { + expect(parseUrlToFileType(url)).toEqual({ + type: ChatFileTypeEnum.audio, + name: 'meeting.mp3', + url, + key: 'chat/meeting.mp3' + }); + } + ); + }); + + it('should ignore query items without a file url', () => { + expect( + buildQueryUrlFileMap([ + { + file: { + type: ChatFileTypeEnum.audio, + name: 'meeting.mp3', + url: '', + key: 'chat/meeting.mp3' + } + } + ]) + ).toEqual({}); + }); + }); + describe('runWithContext / getWorkflowContext', () => { it('should provide context inside callback', () => { const ctx = createWorkflowContext({ diff --git a/packages/service/test/core/workflow/utils/file.test.ts b/packages/service/test/core/workflow/utils/file.test.ts index bab77ae227f9..9522291c30c7 100644 --- a/packages/service/test/core/workflow/utils/file.test.ts +++ b/packages/service/test/core/workflow/utils/file.test.ts @@ -8,6 +8,7 @@ const mockAddRawTextBuffer = vi.hoisted(() => vi.fn()); const mockIsInternalAddress = vi.hoisted(() => vi.fn()); const mockAxiosGet = vi.hoisted(() => vi.fn()); const mockReadFileContentByBuffer = vi.hoisted(() => vi.fn()); +const mockVerifyS3DownloadAccess = vi.hoisted(() => vi.fn()); vi.mock('@fastgpt/service/common/s3/sources/rawText', () => ({ getS3RawTextSource: () => ({ @@ -64,6 +65,14 @@ vi.mock('@fastgpt/service/common/file/read/utils', async (importOriginal) => { }; }); +vi.mock('@fastgpt/service/common/s3/accessLink', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyS3DownloadAccess: mockVerifyS3DownloadAccess + }; +}); + // 全局 s3 mock 把 S3ChatSource 替换成了 vi.fn(),丢失了静态方法。 // 这里用真实模块覆盖回来,让 parseChatUrl 静态方法可用。 vi.mock('@fastgpt/service/common/s3/sources/chat/index', async (importOriginal) => { @@ -225,6 +234,7 @@ describe('parseFileContentFromUrls (external fetch)', () => { mockGetRawTextBuffer.mockResolvedValue(undefined); mockIsInternalAddress.mockResolvedValue(false); mockReadFileContentByBuffer.mockResolvedValue({ rawText: 'parsed text' }); + mockVerifyS3DownloadAccess.mockReset(); }); it('内部地址命中时返回失败结果和 PRIVATE_URL_TEXT', async () => { @@ -318,6 +328,76 @@ describe('parseFileContentFromUrls (external fetch)', () => { }); }); + it('外部地址无后缀但 content-type 是 text/plain 时按 txt 解析并保留原文件名', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('MIT License\n\nPermission is hereby granted', 'utf8'), + headers: { + 'content-type': 'text/plain; charset=utf-8' + } + }); + + const result = await parseFileContentFromUrls({ + urls: ['https://raw.githubusercontent.com/nodejs/node/master/LICENSE'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'txt', + encoding: 'utf-8' + }) + ); + expect(result[0]).toMatchObject({ + success: true, + name: 'LICENSE', + url: 'https://raw.githubusercontent.com/nodejs/node/master/LICENSE' + }); + }); + + it('外部地址无后缀且无 content-type 但内容像文本时按 txt fallback', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('plain text without content type', 'utf8'), + headers: {} + }); + + await parseFileContentFromUrls({ + urls: ['http://example.com/README'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'txt' + }) + ); + }); + + it('外部地址显式不支持后缀时不使用文本 fallback 放行', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('{"ok":true}', 'utf8'), + headers: { + 'content-type': 'text/plain' + } + }); + + await parseFileContentFromUrls({ + urls: ['http://example.com/config.json'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'json' + }) + ); + }); + it('chat S3 key URL 走 S3ChatSource.parseChatUrl 解析路径', async () => { const chatUrl = 'http://example.com/fastgpt-private/chat/app1/u1/c1/abc123-doc.pdf'; mockAxiosGet.mockResolvedValue({ @@ -342,6 +422,108 @@ describe('parseFileContentFromUrls (external fetch)', () => { }); }); + it('短链解析时使用 alias 记录里的文件名推断后缀,不把短链 token 当文件名', async () => { + const signedAlias = 'itmq831ey0kc0xmklk1rik.hp436.0rpC9RtnHmPy_CVri9pBq8'; + const shortUrl = `/api/system/file/d/${signedAlias}`; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/random-key', + filename: 'report.pdf', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('%PDF-1.7'), + headers: { + 'content-type': 'application/octet-stream' + } + }); + + const result = await parseFileContentFromUrls({ + urls: [shortUrl], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledWith(signedAlias); + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ extension: 'pdf' }) + ); + expect(result[0]).toMatchObject({ + success: true, + name: 'report.pdf', + url: shortUrl + }); + }); + + it('nginx 根路径短链解析时也使用 alias 文件名,不把签名最后一段当后缀', async () => { + const signedAlias = 'vju2QnTFBVFMZouC.hp9bi.febMBKFxSy5_u780dAPDnI'; + const shortUrl = `http://localhost:8088/${signedAlias}`; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/license-key', + filename: 'LICENSE', + responseContentType: 'text/plain', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('MIT License\n\nPermission is hereby granted', 'utf8'), + headers: { + 'content-type': 'application/octet-stream' + } + }); + + const result = await parseFileContentFromUrls({ + urls: [shortUrl], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledWith(signedAlias); + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'txt' + }) + ); + expect(result[0]).toMatchObject({ + success: true, + name: 'LICENSE', + url: shortUrl + }); + }); + + it('短链没有 filename 时回退到 objectKey 文件名推断后缀', async () => { + const signedAlias = 'abc123def456ghi789.hp436.abcdefghijklmnop'; + const shortUrl = `http://localhost:3000/api/system/file/d/${signedAlias}`; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/readme.md', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('# Readme', 'utf8'), + headers: {} + }); + + const result = await parseFileContentFromUrls({ + urls: [shortUrl], + requestOrigin: 'http://localhost:3000', + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ extension: 'md' }) + ); + expect(result[0]).toMatchObject({ + success: true, + name: 'readme.md', + url: '/api/system/file/d/abc123def456ghi789.hp436.abcdefghijklmnop' + }); + }); + it('pathname 没有最后一段时,文件名回退为 "file"', async () => { mockAxiosGet.mockResolvedValue({ data: Buffer.from('payload'), @@ -388,6 +570,7 @@ describe('parseFileInfoFromUrls', () => { vi.clearAllMocks(); mockGetRawTextBuffer.mockResolvedValue(undefined); mockIsInternalAddress.mockResolvedValue(false); + mockVerifyS3DownloadAccess.mockReset(); }); it('缓存命中时返回文件名,不下载文件内容', async () => { @@ -447,6 +630,66 @@ describe('parseFileInfoFromUrls', () => { ]); }); + it('短链读取文件信息时使用 alias 文件名,避免把 token 暴露给 read_file 工具', async () => { + const signedAlias = 'fileinfoalias01.hp436.abcdefghijklmnop'; + const shortUrl = `/api/system/file/d/${signedAlias}`; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/random-key', + filename: 'analysis.csv', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('a,b\n1,2', 'utf8'), + headers: {} + }); + + const result = await parseFileInfoFromUrls({ + urls: [shortUrl], + maxFiles: 20, + teamId: 'team-1' + }); + + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledWith(signedAlias); + expect(result).toEqual([ + { + success: true, + name: 'analysis.csv', + url: shortUrl + } + ]); + }); + + it('nginx 带路径前缀短链读取文件信息时使用 alias 文件名', async () => { + const signedAlias = 'fileinfoalias01.hp436.abcdefghijklmnop'; + const shortUrl = `http://localhost:8088/f/${signedAlias}`; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/random-key', + filename: 'analysis.csv', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('a,b\n1,2', 'utf8'), + headers: {} + }); + + const result = await parseFileInfoFromUrls({ + urls: [shortUrl], + maxFiles: 20, + teamId: 'team-1' + }); + + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledWith(signedAlias); + expect(result).toEqual([ + { + success: true, + name: 'analysis.csv', + url: shortUrl + } + ]); + }); + it('内部地址返回失败项,并跳过下载', async () => { mockIsInternalAddress.mockResolvedValue(true); @@ -571,6 +814,50 @@ describe('formatUserQueryWithFiles', () => { expect(result).toBe(userQuery); }); + it('短链图片会在文档解析前恢复为 image 类型,不再进入 read_file 文档解析', async () => { + const signedAlias = 'xWqlYoPSsRIoFtHP.hpagc.bpCySSj7HPRrPtWbcAlqxd'; + const shortUrls = [`/api/system/file/d/${signedAlias}`, `http://localhost:8088/${signedAlias}`]; + mockVerifyS3DownloadAccess.mockResolvedValue({ + bucketName: 'fastgpt-private', + objectKey: 'chat/app/app1/u1/c1/photo.jpeg', + responseContentType: 'image/jpeg', + expiresAt: new Date('2099-01-01T00:00:00.000Z') + }); + + for (const shortUrl of shortUrls) { + mockVerifyS3DownloadAccess.mockClear(); + const parseFileFn = vi.fn(async () => []); + + const result = await formatUserQueryWithFiles({ + userQuery: [ + { text: { content: '图片内容' } }, + { + file: { + type: ChatFileTypeEnum.file, + name: '', + url: shortUrl + } + } + ], + parseFileFn + }); + + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledTimes(1); + expect(mockVerifyS3DownloadAccess).toHaveBeenCalledWith(signedAlias); + expect(parseFileFn).not.toHaveBeenCalled(); + expect(result).toEqual([ + { text: { content: '图片内容' } }, + { + file: { + type: ChatFileTypeEnum.image, + name: 'photo.jpeg', + url: shortUrl + } + } + ]); + } + }); + it('把 parseFileFn 返回的 id、sandboxPath 和 content 注入到文本 prompt', async () => { const parseFileFn = vi.fn(async () => [ { diff --git a/packages/service/test/env.test.ts b/packages/service/test/env.test.ts index 9fd22e4cce60..5aa190d0ae7e 100644 --- a/packages/service/test/env.test.ts +++ b/packages/service/test/env.test.ts @@ -6,6 +6,7 @@ const originalEnv = { SYSTEM_MAX_STRING_LENGTH_M: process.env.SYSTEM_MAX_STRING_LENGTH_M, AGENT_SANDBOX_DISK_MB: process.env.AGENT_SANDBOX_DISK_MB, FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY, + FILE_DOWNLOAD_PUBLIC_URL_PREFIX: process.env.FILE_DOWNLOAD_PUBLIC_URL_PREFIX, AES256_SECRET_KEY: process.env.AES256_SECRET_KEY, INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET, PRO_URL: process.env.PRO_URL, @@ -32,6 +33,7 @@ describe('serviceEnv', () => { vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', originalEnv.SYSTEM_MAX_STRING_LENGTH_M); vi.stubEnv('AGENT_SANDBOX_DISK_MB', originalEnv.AGENT_SANDBOX_DISK_MB); vi.stubEnv('FILE_TOKEN_KEY', originalEnv.FILE_TOKEN_KEY); + vi.stubEnv('FILE_DOWNLOAD_PUBLIC_URL_PREFIX', originalEnv.FILE_DOWNLOAD_PUBLIC_URL_PREFIX); vi.stubEnv('AES256_SECRET_KEY', originalEnv.AES256_SECRET_KEY); vi.stubEnv('INVOKE_TOKEN_SECRET', originalEnv.INVOKE_TOKEN_SECRET); vi.stubEnv('PRO_URL', originalEnv.PRO_URL); @@ -117,6 +119,19 @@ describe('serviceEnv', () => { }); }); + it('normalizes FILE_DOWNLOAD_PUBLIC_URL_PREFIX during service env init', async () => { + vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); + vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); + vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret); + vi.stubEnv('FILE_DOWNLOAD_PUBLIC_URL_PREFIX', 'https://files.example.com/f/'); + + await expect(importServiceEnv()).resolves.toMatchObject({ + serviceEnv: { + FILE_DOWNLOAD_PUBLIC_URL_PREFIX: 'https://files.example.com/f' + } + }); + }); + it('uses PRO_TOKEN only when configured or running tests', async () => { vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); diff --git a/packages/service/test/support/invoke/invoke.test.ts b/packages/service/test/support/invoke/invoke.test.ts index ed7337587fae..08a2fe7de588 100644 --- a/packages/service/test/support/invoke/invoke.test.ts +++ b/packages/service/test/support/invoke/invoke.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PluginPermissionEnum } from '@fastgpt/global/sdk/fastgpt-plugin'; -import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { ChatFileTypeEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; const mockGetToolFilePrefix = vi.hoisted(() => vi.fn()); const mockUploadChatFile = vi.hoisted(() => vi.fn()); const mockCreateUploadChatFileURL = vi.hoisted(() => vi.fn()); +const mockRemoveS3TTL = vi.hoisted(() => vi.fn()); vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ getS3ChatSource: () => ({ @@ -14,6 +15,10 @@ vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ }) })); +vi.mock('@fastgpt/service/common/s3/utils', () => ({ + removeS3TTL: mockRemoveS3TTL +})); + import { InvokeProcessor } from '@fastgpt/service/support/invoke/invoke'; const createProcessor = () => @@ -31,6 +36,7 @@ describe('InvokeProcessor.handleFileUpload', () => { vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + mockRemoveS3TTL.mockResolvedValue(undefined); mockGetToolFilePrefix.mockReturnValue('chat/app-1/user-1/chat-1'); mockUploadChatFile.mockResolvedValue({ key: 'chat/app-1/user-1/chat-1/image.png', @@ -65,8 +71,16 @@ describe('InvokeProcessor.handleFileUpload', () => { expiredTime: new Date('2026-01-16T05:00:00.000Z') }); expect(mockCreateUploadChatFileURL).not.toHaveBeenCalled(); + expect(mockRemoveS3TTL).toHaveBeenCalledWith({ + key: 'chat/app-1/user-1/chat-1/image.png', + bucketName: 'private' + }); expect(result).toEqual({ - url: 'https://example.com/api/system/file/download/token?filename=image.png' + url: 'https://example.com/api/system/file/download/token?filename=image.png', + key: 'chat/app-1/user-1/chat-1/image.png', + filename: 'image.png', + contentType: 'image/png', + type: ChatFileTypeEnum.image }); }); @@ -80,5 +94,6 @@ describe('InvokeProcessor.handleFileUpload', () => { expect(mockUploadChatFile).not.toHaveBeenCalled(); expect(mockCreateUploadChatFileURL).not.toHaveBeenCalled(); + expect(mockRemoveS3TTL).not.toHaveBeenCalled(); }); }); diff --git a/packages/service/test/tsconfig.json b/packages/service/test/tsconfig.json index 2b02decbf6ef..f074a79da147 100644 --- a/packages/service/test/tsconfig.json +++ b/packages/service/test/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "paths": { "@/*": ["../../../projects/app/src/*"], + "@fastgpt-sdk/storage/access-link": ["../../../sdk/storage/src/access-link/index.ts"], "@fastgpt-sdk/storage": ["../../../sdk/storage/src/index.ts"], "@fastgpt-sdk/otel": ["../../../sdk/otel/src/index.ts"], "@fastgpt-sdk/otel/logger": ["../../../sdk/otel/src/logger-entry.ts"], diff --git a/packages/service/vitest.config.ts b/packages/service/vitest.config.ts index e37d815a7b76..d7538295faf0 100644 --- a/packages/service/vitest.config.ts +++ b/packages/service/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve('../../projects/app/src'), + '@fastgpt-sdk/storage/access-link': resolve('../../sdk/storage/src/access-link/index.ts'), '@fastgpt-sdk/storage': resolve('../../sdk/storage/src/index.ts'), '@fastgpt-sdk/otel/logger': resolve('../../sdk/otel/src/logger-entry.ts'), '@fastgpt-sdk/otel/metrics': resolve('../../sdk/otel/src/metrics-entry.ts'), diff --git a/packages/web/common/file/utils.ts b/packages/web/common/file/utils.ts index 5628ebb49455..9076b71c8c67 100644 --- a/packages/web/common/file/utils.ts +++ b/packages/web/common/file/utils.ts @@ -7,7 +7,7 @@ import { parseS3UploadError } from '@fastgpt/global/common/error/s3'; export const loadFile2Buffer = ({ file, onError }: { file: File; onError?: (err: any) => void }) => new Promise((resolve, reject) => { try { - let reader = new FileReader(); + const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = async ({ target }) => { if (!target?.result) { @@ -43,7 +43,7 @@ export const readFileRawText = ({ }) => { return new Promise((resolve, reject) => { try { - let reader = new FileReader(); + const reader = new FileReader(); reader.onload = async ({ target }) => { if (!target?.result) { onError?.('Load file error'); @@ -88,7 +88,7 @@ async function detectFileEncoding(file: File): Promise { const buffer = await loadFile2Buffer({ file }); const encoding = (() => { const encodings = ['utf-8', 'iso-8859-1', 'windows-1252']; - for (let encoding of encodings) { + for (const encoding of encodings) { try { const decoder = new TextDecoder(encoding, { fatal: true }); decoder.decode(buffer); @@ -130,6 +130,7 @@ export const putFileToS3 = async ({ file, onSuccess, onUploadProgress, + signal, maxSize, t }: { @@ -138,6 +139,7 @@ export const putFileToS3 = async ({ file: File; onSuccess?: () => void; onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; + signal?: AbortSignal; maxSize?: number; t: any; }) => { @@ -147,12 +149,24 @@ export const putFileToS3 = async ({ ...headers }, onUploadProgress, + signal, timeout: 5 * 60 * 1000 }); if (res.status === 200) { onSuccess?.(); } } catch (error) { + if ( + axios.isCancel(error) || + (typeof error === 'object' && + error !== null && + ((error as { name?: string }).name === 'AbortError' || + (error as { name?: string }).name === 'CanceledError' || + (error as { code?: string }).code === 'ERR_CANCELED')) + ) { + return Promise.reject(error); + } + return Promise.reject(parseS3UploadError({ t, error, maxSize })); } }; diff --git a/pro b/pro index 461a61430a92..0f4f74a25998 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 461a61430a9282342c4e938a20f5cc4ad65bbee5 +Subproject commit 0f4f74a2599875544dcdf5b9aa4b3c8bfda2d23d diff --git a/projects/app/.env.template b/projects/app/.env.template index 1dbc3822d20f..20105d02bc54 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -17,7 +17,6 @@ INVOKE_TOKEN_SECRET=fastgpt_invoke_token_secret_32_chars_min # root key(最高权限) ROOT_KEY=fdafasd - # ==================== 服务地址与集成 ==================== # 商业版地址 # PRO_URL= @@ -89,6 +88,10 @@ STORAGE_PUBLIC_BUCKET=fastgpt-public STORAGE_PRIVATE_BUCKET=fastgpt-private STORAGE_EXTERNAL_ENDPOINT= STORAGE_S3_CDN_ENDPOINT= +# 下载链接模式:short-proxy | short-redirect | presigned +STORAGE_DOWNLOAD_URL_MODE=short-redirect +# short-redirect 模式下临时 S3 预签名下载链接 TTL(秒) +STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS=300 STORAGE_S3_ENDPOINT=http://localhost:9000 STORAGE_S3_FORCE_PATH_STYLE=true STORAGE_S3_MAX_RETRIES=3 @@ -105,7 +108,8 @@ REDIS_URL=redis://default:mypassword@localhost:6379 # STREAM_RESUME_REDIS_MEMORY_CHECK_INTERVAL_MS=5000 # MongoDB 连接参数;本地开发连接远程数据库时,可能需要添加 directConnection=true 才能连接 -MONGODB_URI=mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin&directConnection=true +MONGODB_URI=mongodb://myusername:mypassword@localhost:27017/fastgpt?authSource=admin & +directConnection=true # 日志库 MONGODB_LOG_URI= diff --git a/projects/app/src/components/core/app/FileSelector/index.tsx b/projects/app/src/components/core/app/FileSelector/index.tsx index cbd4bc05ca47..50912a3be1c2 100644 --- a/projects/app/src/components/core/app/FileSelector/index.tsx +++ b/projects/app/src/components/core/app/FileSelector/index.tsx @@ -29,7 +29,11 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; import MyDivider from '@fastgpt/web/components/common/MyDivider'; import MyAvatar from '@fastgpt/web/components/common/Avatar'; import z from 'zod'; -import { getPresignedChatFileGetUrl, getUploadChatFilePresignedUrl } from '@/web/common/file/api'; +import { + getPresignedChatFileGetUrl, + getUploadChatFilePresignedUrl, + getUploadDraftChatFilePresignedUrl +} from '@/web/common/file/api'; import { useContextSelector } from 'use-context-selector'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { formatFileSize } from '@fastgpt/global/common/file/tools'; @@ -188,6 +192,7 @@ const FileSelector = ({ const chatTarget = useChatApiTarget(sourceTarget); const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId); const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData); + const fileUploadMode = useContextSelector(WorkflowRuntimeContext, (v) => v.fileUploadMode); const chatAuthTarget = useMemo( () => getChatAuthTargetInput({ ...chatTarget, outLinkAuthData }), [chatTarget, outLinkAuthData] @@ -380,12 +385,20 @@ const FileSelector = ({ try { // Get Upload Post Presigned URL - const { url, key, headers, previewUrl } = await getUploadChatFilePresignedUrl({ + const uploadParams = { filename: file.rawFile.name, + contentType: file.rawFile.type || undefined, + size: file.rawFile.size, ...chatAuthTarget, - chatId, - fileSelectConfig - }); + chatId + }; + const { url, key, headers, previewUrl } = + fileUploadMode === 'draft' + ? await getUploadDraftChatFilePresignedUrl({ + ...uploadParams, + fileSelectConfig + }) + : await getUploadChatFilePresignedUrl(uploadParams); await putFileToS3({ url, @@ -426,7 +439,16 @@ const FileSelector = ({ }) ); }, - [handleChangeFiles, setFileUploadingCount, chatAuthTarget, chatId, fileSelectConfig, t, maxSize] + [ + handleChangeFiles, + setFileUploadingCount, + chatAuthTarget, + chatId, + fileSelectConfig, + fileUploadMode, + t, + maxSize + ] ); // Selector props diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx index 469245b2c104..7fd584c089fa 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx @@ -19,9 +19,10 @@ import { useContextSelector } from 'use-context-selector'; import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext'; import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; -import { documentFileType } from '@fastgpt/global/common/file/constants'; import FilePreview from '../../components/FilePreview'; import { useFileUpload } from '../hooks/useFileUpload'; +import { getFileUploadId } from '../utils/uploadTask'; +import { isChatFileAllowedBySelectConfig } from '../utils/file'; import ComplianceTip from '@/components/common/ComplianceTip/index'; import { useToast } from '@fastgpt/web/hooks/useToast'; import VoiceInput, { type VoiceInputComponentRef } from './VoiceInput'; @@ -32,13 +33,6 @@ import { ChatGenerateStatusEnum, ChatSourceTypeEnum } from '@fastgpt/global/core const InputGuideBox = dynamic(() => import('./InputGuideBox')); const PLACEHOLDER_APP_NAME_TOKEN = '__APP_NAME__'; -const fileTypeFilter = (file: File) => { - return ( - file.type.includes('image') || - documentFileType.split(',').some((type) => file.name.endsWith(type.trim())) - ); -}; - type ChatInputProps = BoxProps & { lastInteractive?: WorkflowInteractiveResponseType; onSendMessage: SendPromptFnType; @@ -134,8 +128,8 @@ const ChatInput = ({ showSelectVideo, showSelectAudio, showSelectCustomFileExtension, - removeFiles, - replaceFiles, + cancelUploadFile, + clearFiles, hasFileUploading } = useFileUpload({ fileSelectConfig, @@ -152,6 +146,10 @@ const ChatInput = ({ showSelectVideo || showSelectAudio || showSelectCustomFileExtension; + const isFileTypeAllowed = useCallback( + (file: File) => isChatFileAllowedBySelectConfig({ file, fileSelectConfig }), + [fileSelectConfig] + ); const canUseInputGuide = enableInputGuide && sourceTarget.sourceType === ChatSourceTypeEnum.app && @@ -179,9 +177,9 @@ const ChatInput = ({ interactive: lastInteractive, clearInput: true }); - replaceFiles([]); + clearFiles(); }, - [inputValue, lastInteractive, canSendMessage, fileList, onSendMessage, replaceFiles] + [inputValue, lastInteractive, canSendMessage, fileList, onSendMessage, clearFiles] ); const { runAsync: handleStop, loading: isStopping } = useRequest(async () => { try { @@ -292,7 +290,7 @@ const ChatInput = ({ const files = Array.from(items) .map((item) => (item.kind === 'file' ? item.getAsFile() : undefined)) .filter((file) => { - return file && fileTypeFilter(file); + return file && isFileTypeAllowed(file); }) as File[]; onSelectFile({ files }); @@ -347,13 +345,13 @@ const ChatInput = ({ appNamePlaceholderParts.suffix, placeholderAppName, isPc, - t, inputValue, onFocus, offFocus, setValue, handleSend, canUploadFile, + isFileTypeAllowed, onSelectFile ] ); @@ -509,15 +507,20 @@ const ChatInput = ({ if (!canUploadFile) return; const files = Array.from(e.dataTransfer.files); - const droppedFiles = files.filter((file) => fileTypeFilter(file)); + const droppedFiles: File[] = []; + const invalidFiles: File[] = []; + files.forEach((file) => { + if (isFileTypeAllowed(file)) { + droppedFiles.push(file); + } else { + invalidFiles.push(file); + } + }); if (droppedFiles.length > 0) { onSelectFile({ files: droppedFiles }); } - const invalidFileName = files - .filter((file) => !fileTypeFilter(file)) - .map((file) => file.name) - .join(', '); + const invalidFileName = invalidFiles.map((file) => file.name).join(', '); if (invalidFileName) { toast({ status: 'warning', @@ -568,7 +571,11 @@ const ChatInput = ({ {/* file preview */} {(!mobilePreSpeak || isPc || inputValue) && ( - + cancelUploadFile(getFileUploadId(file))} + pt={0} + /> )} @@ -583,7 +590,7 @@ const ChatInput = ({ autoTTSResponse, clearInput: true }); - replaceFiles([]); + clearFiles(); }} resetInputVal={(val) => { setMobilePreSpeak(false); diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Provider.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Provider.tsx index 8a98765d8fd7..fe7c4df7e819 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Provider.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Provider.tsx @@ -33,6 +33,7 @@ import { toChatAuthApiTarget } from '@/web/core/chat/utils'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { resolveChatFileUploadMode } from './utils/file'; export type ChatProviderProps = { /** 标准内部 chat target。ChatBox 不再接收 appId/skillId raw 形态。 */ @@ -195,6 +196,10 @@ const Provider = ({ ChatItemContext, (v) => v.chatBoxData?.app?.chatConfig?.fileSelectConfig ?? defaultAppSelectFileConfig ); + const fileUploadMode = resolveChatFileUploadMode({ + chatType, + sourceType: sourceTarget.sourceType + }); const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords); const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords); @@ -301,6 +306,7 @@ const Provider = ({ sourceTarget={sourceTarget} chatId={chatId} outLinkAuthData={formatOutLinkAuth} + fileUploadMode={fileUploadMode} > {children} diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx index 6832ef8f9bd8..2b8cb4d2852e 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx @@ -306,7 +306,7 @@ const ChatItem = (props: Props) => { {renderCommonFooter()} diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/HumanChatBubble/EditForm.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/HumanChatBubble/EditForm.tsx index 53f621aeb243..7f825f447b4c 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/HumanChatBubble/EditForm.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/HumanChatBubble/EditForm.tsx @@ -11,6 +11,7 @@ import { WorkflowRuntimeContext } from '../../../context/workflowRuntimeContext' import { ChatBoxContext } from '../../Provider'; import type { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from '../../type'; import { useFileUpload } from '../../hooks/useFileUpload'; +import { getFileUploadId } from '../../utils/uploadTask'; const textareaLineHeight = 24; const textareaVisibleRows = 4; @@ -70,7 +71,7 @@ const HumanChatBubbleEditForm = ({ showSelectVideo, showSelectAudio, showSelectCustomFileExtension, - removeFiles, + cancelUploadFile, hasFileUploading } = useFileUpload({ fileSelectConfig, @@ -122,7 +123,10 @@ const HumanChatBubbleEditForm = ({ overflow={'hidden'} > - + cancelUploadFile(getFileUploadId(file))} + />