Skip to content

Commit 81d7b93

Browse files
authored
feat(s3): add short access links and SDK core (#7281)
* feat(s3): add short access links * refactor(s3): extract access link sdk core * fix(s3): resolve access link sdk alias in pro tests * fix: persist invoke uploaded files * feat: support s3 short redirect download mode * docs: add s3 refactor design docs * refactor: improve s3 upload policy handling * fix: support aborting chat file uploads * feat: support public download shortlink prefix * fix: resolve public shortlink file metadata * fix: restore shortlink media in chat context * fix: persist sandbox files with chat lifecycle * fix: align dropped files with upload config * fix: reject mismatched explicit upload types * fix: await dataset preview access links * fix: align pro chat file persistence * fix(chat): secure draft file upload policies * fix(chat): preserve first-turn media file types * perf(s3): reduce download alias lease writes * fix(chat): preserve first-turn media metadata * perf(s3): batch download access link issuance * fix(s3): release aborted proxy download streams * test(s3): align checks with stream lifecycle * fix: resolve shortlink CI build failures * docs: consolidate s3 refactor design
1 parent e3717e5 commit 81d7b93

167 files changed

Lines changed: 10371 additions & 1427 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/design/api/s3-object-key-authorization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ FastGPT 的私有对象存储 key 是 bucket 内的全局路径字符串,例
5050
2. 先完成业务资源鉴权,拿到可信的 `teamId``appId``datasetId``uid``userId`
5151
3. 使用对应 `isAuthorized*FileS3Key` helper 绑定 key 与可信上下文。
5252
4. 绑定失败时返回通用未授权错误,不暴露 key 是否存在。
53-
5. 只有通过绑定后,才允许调用 `createExternalUrl``createGet*URL``jwtSignS3DownloadToken``downloadObject``getDatasetFileRawText``isObjectExists` 等存储层能力。
53+
5. 只有通过绑定后,才允许调用 `createExternalUrl``createGet*URL``createS3DownloadAccessUrl``downloadObject``getDatasetFileRawText``isObjectExists` 等存储层能力。
5454

5555
## 底层防线
5656

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# S3 文件访问重构设计
2+
3+
## 1. 状态与范围
4+
5+
本设计记录当前已经落地的 S3 文件访问方案,覆盖以下能力:
6+
7+
1. 用短链替代模型可见的长 JWT 上传、下载链接。
8+
2. 将上传校验拆成 `policy + hint + evidence`,支持无后缀文件和显式自定义扩展名。
9+
3. ChatBox 删除上传占位时真正中止预签和 PUT 请求,并阻止异步结果回写。
10+
4. 支持 `short-proxy``short-redirect``presigned` 三种下载模式。
11+
5. 抽取 `@fastgpt-sdk/storage/access-link`,让协议状态机与 FastGPT/Mongo 适配解耦。
12+
6. 保持文件对象、临时 TTL、聊天删除和短链记录的原有清理语义。
13+
14+
不在本次范围内:重做所有文件资产的事务模型、让 Plugin Server 承接主项目文件下载、把业务资源权限下沉到匿名短链路由。
15+
16+
## 2. 总体架构
17+
18+
```mermaid
19+
flowchart LR
20+
C["Chat / Workflow / Admin"] --> B["S3BaseBucket"]
21+
B --> A["FastGPT access-link adapter"]
22+
A --> S["storage SDK access-link core"]
23+
S --> M["Mongo stores"]
24+
A --> U["/api/system/file/u/:token"]
25+
A --> D["/api/system/file/d/:signedAlias"]
26+
U --> P["上传 policy 校验"]
27+
P --> O["Object Storage"]
28+
D -->|short-proxy| O
29+
D -->|short-redirect| R["302 临时 presigned URL"]
30+
R --> O
31+
```
32+
33+
分层职责:
34+
35+
|| 职责 |
36+
| --- | --- |
37+
| SDK core | HMAC、过期分桶、alias 去重、token hash、verify/revoke 状态机和 store port |
38+
| Service adapter | Mongo schema/store、环境变量、URL builder、日志和 FastGPT 错误映射 |
39+
| App route | HTTP 方法、Zod 入参、上传/下载流、302、状态码和客户端断开处理 |
40+
| 业务入口 | 在签发前完成 team/app/chat/dataset 等资源鉴权,短链路由不重复业务鉴权 |
41+
42+
## 3. 短链协议
43+
44+
### 3.1 下载
45+
46+
默认 URL:
47+
48+
```text
49+
/api/system/file/d/<aliasId>.<expMinute36>.<sig>
50+
```
51+
52+
- `aliasId`:16 位随机 Base64URL ID,用于查询对象映射。
53+
- `expMinute36`:Unix 分钟的 36 进制编码,过期时间直接参与签名。
54+
- `sig``HMAC-SHA256(FILE_TOKEN_KEY, version + aliasId + expiry)` 的前 22 位 Base64URL。
55+
- `.` 是 URL path-safe 分隔符,三段长度固定且易于严格解析。
56+
57+
Mongo 集合 `s3_download_aliases` 只保存对象映射,不保存可直接使用的 bearer URL:
58+
59+
```text
60+
aliasId, aliasKey, bucketName, objectKey,
61+
filename?, responseContentType?,
62+
lastIssuedAt, purgeAt, disabledAt?
63+
```
64+
65+
`aliasKey``bucketName + objectKey + filename + responseContentType` 的 HMAC。同一对象变体重复签发会复用 alias,只有过期时间和签名变化,因此文档数量接近对象变体数,不随页面刷新次数线性增长。
66+
67+
签发时按 TTL 向上分桶:短期 15 分钟、中期 1 小时、长期 24 小时。批量签发先对 aliasKey 去重,再使用 `$in` 查询和批量创建;只有 `purgeAt` 接近新链接有效期边界时才低频续租,避免每次签发产生 Mongo 写入。
68+
69+
校验顺序:格式 -> 过期 -> 常量时间 HMAC -> alias 查询 -> revoke 状态 -> payload。任何失败都不能暴露 bucket、objectKey 或 alias 是否存在等内部细节。
70+
71+
### 3.2 上传
72+
73+
URL:
74+
75+
```text
76+
/api/system/file/u/<shortToken>
77+
```
78+
79+
- token 为 22 位随机 Base64URL;Mongo 只保存 HMAC hash,不保存明文。
80+
- `s3_upload_sessions` 保存 bucket/object、大小限制、policy、hint、metadata、`expiresAt/usedAt/revokedAt`
81+
- 当前使用策略为 `mark-used`:验证成功时记录 `usedAt`;过期记录由 Mongo TTL 索引删除。
82+
- 上传仍由 App proxy 承接,以便执行大小、内容类型、metadata 和中止处理;不能把公开下载前缀用于上传。
83+
84+
### 3.3 索引与清理
85+
86+
| 集合 | 关键索引 | 清理 |
87+
| --- | --- | --- |
88+
| `s3_download_aliases` | `aliasId unique``aliasKey unique``purgeAt TTL``bucketName + objectKey` | 链接到期后加 24 小时 grace 自动删除;对象删除时按 key 主动删除 alias |
89+
| `s3_upload_sessions` | `tokenHash unique``expiresAt TTL``bucketName + objectKey` | session 到期后自动删除 |
90+
91+
Mongo 是跨实例一致性的必要状态,不使用进程内缓存。性能优化优先采用批量查询、alias 复用、索引和低频续租;DBG 日志分别记录 HMAC、find/create/touch lease 和总耗时。
92+
93+
## 4. 下载模式与域名
94+
95+
`STORAGE_DOWNLOAD_URL_MODE`
96+
97+
| 模式 | 对外 URL | 文件字节链路 | 适用场景 |
98+
| --- | --- | --- | --- |
99+
| `short-proxy` | FastGPT 短链 | App 代理对象流 | 默认;私有 S3、最稳定 |
100+
| `short-redirect` | FastGPT 短链 | App 校验后 302,客户端直连 S3/CDN | 有外部 endpoint,降低 App 带宽 |
101+
| `presigned` | S3 长链接 | 客户端直连 S3/CDN | 仅显式兼容旧行为 |
102+
103+
`short-redirect` 必须先完成短链校验,再生成不超过短链剩余寿命的临时 presigned URL。已经 302 给客户端的 URL 在其短 TTL 内无法被 alias revoke 立即收回,这是该模式的明确代价。
104+
105+
环境变量:
106+
107+
| 变量 | 说明 |
108+
| --- | --- |
109+
| `FILE_TOKEN_KEY` | 上传 token hash 与下载 HMAC 密钥;生产环境必须稳定且足够随机,轮换会使存量链接失效 |
110+
| `STORAGE_DOWNLOAD_URL_MODE` | 默认 `short-proxy` |
111+
| `STORAGE_DOWNLOAD_REDIRECT_TTL_SECONDS` | 302 临时 S3 URL TTL,默认 300 秒 |
112+
| `FILE_DOMAIN` | 完整 FastGPT 文件 API 域名,同时影响上传和下载 URL |
113+
| `FILE_DOWNLOAD_PUBLIC_URL_PREFIX` | 可选下载短链公开前缀,只影响下载 |
114+
| `STORAGE_EXTERNAL_ENDPOINT` / `STORAGE_S3_CDN_ENDPOINT` | redirect/presigned 使用的客户端可访问地址 |
115+
116+
使用 nginx 缩短公开下载路径时,可配置:
117+
118+
```nginx
119+
location ~ ^/([A-Za-z0-9_-]{12,32}\.[0-9a-z]{1,8}\.[A-Za-z0-9_-]{16,64})$ {
120+
proxy_pass http://host.docker.internal:3000/api/system/file/d/$1;
121+
proxy_set_header Host $host;
122+
proxy_set_header X-Forwarded-Proto $scheme;
123+
}
124+
```
125+
126+
同时设置 `FILE_DOWNLOAD_PUBLIC_URL_PREFIX=http://localhost:8088`。Plugin、Pro 和浏览器拿到的仍是主 App 生成的 URL,不需要各自配置或实现文件下载 public API。
127+
128+
## 5. 上传策略与内容校验
129+
130+
### 5.1 三类输入
131+
132+
| 输入 | 含义 | 信任级别 |
133+
| --- | --- | --- |
134+
| `UploadPolicy` | 服务端允许的扩展名、MIME、fallback 和验证方式 | 权威规则 |
135+
| `UploadFileHint` | 文件名、声明后缀、Content-Type、来源和大小 | 线索,不是安全事实 |
136+
| `UploadFileEvidence` | magic bytes、Office ZIP marker、文本特征或 unknown | 内容证据 |
137+
138+
预签阶段只构建 policy,并拒绝“明确存在且不在白名单”的后缀;缺少文件名或后缀不会在预签阶段直接失败。上传阶段读取有限前缀生成 evidence,再由 `resolveUploadFile` 做最终裁决。
139+
140+
### 5.2 扩展名规则
141+
142+
每个扩展名对应一种验证方式:
143+
144+
| verification | 规则 |
145+
| --- | --- |
146+
| `content` | 必须由 magic/Office 内容证明;显式后缀与检测内容不一致时拒绝 |
147+
| `text` | 允许文本特征作为弱证据;无后缀文本可落到 policy 指定的文本 fallback |
148+
| `opaque` | 用于 `.dat/.bin/.exe` 等无法稳定由 magic 区分的自定义类型;必须显式声明允许的扩展名,最终使用 `application/octet-stream` |
149+
150+
因此:
151+
152+
- 将可检测的 EXE 改名为 `.png` 时,magic 与显式后缀不匹配,必须拒绝。
153+
- 业务明确允许 `.exe/.dat` 时,可以将其配置为 custom opaque 上传;这是业务准入,不代表系统证明了文件安全。
154+
- 无后缀文件若能检测出 magic 或文本,可在 policy 内推断;无后缀 unknown binary 且存在 allow-list 时拒绝,调用方应提供可信的声明后缀。
155+
- 第三方 URL 的 Content-Type 可以作为 hint,不能单独覆盖内容证据;调用方已知业务类型时应显式传 `declaredExtension/declaredFilename`
156+
157+
Chat 正式上传接口必须读取服务端已发布配置,不能信任客户端 `fileSelectConfig`。只有编辑态测试使用独立 draft 接口并要求写权限:
158+
159+
| 接口 | 策略来源 |
160+
| --- | --- |
161+
| `presignChatFilePostUrl` | App 最新发布版本、Home Chat 固定配置或 Helper 固定配置 |
162+
| `presignDraftChatFilePostUrl` | 客户端临时配置,仅 App/Skill 编辑态且需写权限 |
163+
164+
文件选择器和拖拽入口共用同一允许规则,避免音视频只可点击上传、不可拖拽的问题。
165+
166+
## 6. ChatBox 上传中止
167+
168+
每个本地文件创建稳定 `uploadId` 和独立 `AbortController`,任务保存在 `Map<uploadId, task>`
169+
170+
1. 删除占位时先标记 `canceled`,再 `abort()` 预签或 PUT,最后从 field array 删除。
171+
2. 进度、成功和失败回调每次写回前,必须按 `uploadId` 重新定位并检查任务未取消。
172+
3. 取消错误不显示上传失败 toast。
173+
4. 清空文件或组件卸载时中止全部任务并清空 Map。
174+
5. UI 排序后的 index 不可用于删除真实 field array,必须使用 `uploadId`
175+
176+
中止是客户端到 App、App 到对象存储的协作行为;若对象存储已完整接收数据,不能承诺物理回滚,后续仍由既有临时 TTL/业务清理兜底。下载 proxy 同样将客户端断开转换为 abort signal,并在完成、错误或 close 时释放上游流。
177+
178+
## 7. 文件生命周期边界
179+
180+
短链只改变访问凭证,不改变对象所有权:
181+
182+
- Chat 文件 key 为 `chat/<sourceType>/<sourceId>/<uid>/<chatId>/<filename>`;旧 App key 继续兼容读取和删除。
183+
- 工具、Sandbox 生成的临时文件在对话保存时由 `persistChatFiles` 校验 source/chat 归属并取消临时 TTL,使其随对话保留。
184+
- 删除对话或 source 时,仍按对应 Chat prefix 删除私有/公开 bucket 文件;对象删除任务同时清理相关 download alias。
185+
- 未被对话认领的临时文件继续由 `s3_ttls` 清理。
186+
- alias/session 的 Mongo TTL 只清理访问凭证记录,不删除 S3 对象;对象生命周期不能由短链 TTL 推导。
187+
188+
本设计不引入新的全局资产表或跨 Mongo/S3 事务。后续若要治理历史孤儿文件、软删除 GC 或可靠删除 intent,应作为独立生命周期项目实施。
189+
190+
## 8. 错误边界
191+
192+
| 场景 | HTTP | 对外语义 |
193+
| --- | --- | --- |
194+
| token/alias 格式错误、过期、签名错误、missing、revoked | 403 | 无文件访问权限,不泄露内部原因 |
195+
| 对象不存在 | 404 | 文件不存在 |
196+
| 文件过大、扩展名不允许、内容不匹配 | 400 | 稳定业务错误,可由上游展示 |
197+
| 存储、Mongo 或配置异常 | 500 | 通用内部错误,详细信息只写服务端日志 |
198+
| 不支持的 HTTP 方法 | 405 | Method not allowed |
199+
200+
Route 必须捕获可预期文件代理错误并映射状态码,不能全部落入 `NextAPI` 默认 500。SDK 只抛稳定的 access-link error code,不依赖 FastGPT 全局错误枚举;上传 policy 错误继续由 FastGPT S3 业务错误处理。
201+
202+
## 9. 代码组织
203+
204+
| 路径 | 主要职责 |
205+
| --- | --- |
206+
| `sdk/storage/src/access-link/` | 短链协议、crypto、日期分桶、store ports、状态机 |
207+
| `packages/service/common/s3/accessLink/` | Mongo adapter、schema、URL、日志和 service 实例 |
208+
| `packages/service/common/s3/uploadPolicy/` | policy/hint/evidence 类型与最终裁决 |
209+
| `packages/service/common/s3/buckets/base.ts` | 统一签发上传短链和三种下载 URL |
210+
| `projects/app/src/pages/api/system/file/u/` | 新上传短链入口 |
211+
| `projects/app/src/pages/api/system/file/d/` | 新下载短链入口和 proxy/redirect 分流 |
212+
| `projects/app/src/service/common/s3/proxy.ts` | 上传/下载代理、流释放和错误响应 |
213+
| `projects/app/.../ChatBox/hooks/useFileUpload.tsx` | 上传任务、取消和 UI 写回 |
214+
215+
`/api/system/file/upload/:token``download/:token` 在兼容期保留;新代码统一使用 `/u``/d`
216+
217+
## 10. P0 验收清单
218+
219+
- [ ] 同一对象变体重复签发复用 alias;批量签发只进行聚合 Mongo 操作。
220+
- [ ] 下载短链正常、过期、篡改、撤销、对象不存在的状态码正确。
221+
- [ ] `short-proxy` 支持 GET/HEAD、range、客户端断开且不泄漏流。
222+
- [ ] `short-redirect` 先验签再 302,Location TTL 不超过短链剩余寿命。
223+
- [ ] `presigned` 仅在显式模式下返回长 S3 URL。
224+
- [ ] 上传 token 过期、撤销、重复使用、大小限制和 metadata 行为正确。
225+
- [ ] 无后缀 magic/text 文件按 policy 处理;unknown binary 无安全 fallback 时拒绝。
226+
- [ ] `.exe` 改名 `.png` 被拒绝;显式允许的 opaque 自定义扩展名可以上传。
227+
- [ ] 正式聊天不能篡改 `fileSelectConfig`;draft 上传要求资源写权限。
228+
- [ ] 点击删除、清空或卸载会 abort 请求,迟到回调不会恢复文件占位。
229+
- [ ] 点击选择、拖拽和粘贴对图片、音频、视频、自定义扩展名行为一致。
230+
- [ ] 对话保存后工具/Sandbox 文件不再按临时 TTL 误删;删除对话仍清理文件。
231+
- [ ] App、Pro Admin、Plugin invoke 返回的主项目文件链接 host 和下载模式符合环境配置。
232+
- [ ] Mongo TTL 索引存在,alias 数量不随重复签发线性增长,DBG timing 无异常写放大。
233+
234+
## 11. 升级与兼容
235+
236+
1. 上线前固定 `FILE_TOKEN_KEY`,确认 Mongo 用户具备创建 TTL/unique 索引权限。
237+
2. 默认使用 `short-proxy`;外部 S3/CDN 部署可切换 `short-redirect`;仅兼容旧消费者时使用 `presigned`
238+
3. nginx 根路径短链是可选展示层,App 的 `/api/system/file/d/*` 必须始终可达。
239+
4. 历史 JWT 路由和历史 Chat key 在兼容期继续工作;新写入统一使用短链和 source-aware key。
240+
5. 回滚前必须评估已写入消息、dataset 或工具结果的新 `/d` 链接;直接移除新路由会使历史内容不可访问。

0 commit comments

Comments
 (0)