Commit 99673b1f by Xianquan Committed by GitHub

fix: use proxy download links for files (#7217)

parent 6b26bee6
......@@ -12,7 +12,7 @@
**工具返回格式**
```json
{
"url": "https://xxx.s3.amazonaws.com/...",
"url": "https://app.xxx.com/api/system/file/download/<token>?filename=output.csv",
"expired": "2 hours",
"filename": "output.csv"
}
......@@ -33,11 +33,12 @@
| `packages/service/common/s3/sources/chat/index.ts` | 修改 | `uploadChatFile` 透传 `filename``expiredTime` |
| `packages/service/common/s3/type.ts` | 修改 | `UploadFileByBodySchema` 新增 `filename``expiredTime` 字段 |
| `packages/service/common/s3/sources/chat/type.ts` | 修改 | `UploadChatFileSchema` 新增 `expiredTime` 字段 |
| `projects/app/src/pages/api/system/file/[jwt].ts` | 修改 | 下载接口支持 `Content-Disposition` 响应头 |
| `packages/service/common/s3/security/token.ts` | 修改 | 旧 `jwtSignS3ObjectKey` 签名入口兼容保留,但新生成链接统一走 download token |
| `projects/app/src/pages/api/system/file/download/[token].ts` | 使用 | 代理下载接口支持 `Content-Disposition` 响应头和 inline 预览 |
> **架构说明**:新增 `callSandboxTool` 作为纯执行层,不绑定业务响应格式。两条调用链路(普通工作流 / Agent 模式)均复用该层,消除重复逻辑。
> **URL 生成**:文件上传到 S3 后,通过 `jwtSignS3ObjectKey(key, expiredAt)` 生成 JWT 签名的内网访问 URL(有效期 2 小时),不依赖 S3 签名 URL
> **URL 生成**:文件上传到 S3 后,通过 `chatBucket.createGetChatFileURL({ key, expiredHours: 2, external: true, mode: 'proxy' })` 生成代理下载 URL(有效期 2 小时),不依赖 S3 签名 URL。新生成的下载/预览链接统一使用 `/api/system/file/download/[token]`,旧 `/api/system/file/[jwt]` 仅保留历史链接兼容
---
......@@ -121,18 +122,18 @@ export const callSandboxTool = async (params: SandboxToolCallParams): Promise<Sa
- 通过 `instance.provider.readFileStream(filePath)` 流式读取文件内容
- 聚合 chunks 为 `Buffer`
- 调用 `chatBucket.uploadChatFile({ ..., expiredTime: addHours(now, 2) })` 上传,TTL 设为 2 小时
-`jwtSignS3ObjectKey(key, addHours(now, 2))` 生成 JWT 签名访问 URL
-`chatBucket.createGetChatFileURL({ key, expiredHours: 2, external: true, mode: 'proxy' })` 生成代理下载 URL
3. 返回 `Array<{ fileUrl: string, filename: string }>`
**工具返回格式**(JSON 序列化后作为 response):
```json
[
{ "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "output.csv" },
{ "fileUrl": "https://app.xxx.com/api/system/file/eyJ...", "filename": "report.txt" }
{ "fileUrl": "https://app.xxx.com/api/system/file/download/eyJ...?filename=output.csv", "filename": "output.csv" },
{ "fileUrl": "https://app.xxx.com/api/system/file/download/eyJ...?filename=report.txt", "filename": "report.txt" }
]
```
> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是通过 `jwtSignS3ObjectKey` 生成 JWT 签名的内部访问 URL,由 `/api/system/file/[jwt]` 接口代理下载
> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是生成内部代理下载 URL,由 `/api/system/file/download/[token]` 接口代理下载/预览。`/api/system/file/[jwt]` 仅用于历史 objectKey token 链接兼容,新增链路不得再生成该地址
---
......@@ -276,7 +277,8 @@ sequenceDiagram
CallLayer->>S3: uploadChatFile({ filename, buffer, expiredTime: +2h })
S3->>DB: MongoS3TTL.create(expiredTime = now + 2h)
S3-->>CallLayer: { key }
CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) → fileUrl
CallLayer->>S3: createGetChatFileURL({ key, expiredHours: 2, mode: 'proxy' })
S3-->>CallLayer: { url: fileUrl }
end
CallLayer-->>Handler: { input, response: JSON([{fileUrl, filename}...]), durationSeconds }
Handler-->>Tool: response + flowResponse
......@@ -300,7 +302,8 @@ sequenceDiagram
SandboxProvider-->>CallLayer: stream chunks
CallLayer->>S3: uploadChatFile({ expiredTime: +2h })
S3-->>CallLayer: { key }
CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h)
CallLayer->>S3: createGetChatFileURL({ key, expiredHours: 2, mode: 'proxy' })
S3-->>CallLayer: { url: fileUrl }
CallLayer-->>Dispatch: { input, response, durationSeconds }
Dispatch-->>Handler: { response, nodeResponse }
Handler-->>Agent: { response, usages }
......@@ -374,5 +377,5 @@ await this.client.uploadObject({ key, body: stream, contentType: '...' });
- [x] `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`:合并拦截逻辑,复用 `callSandboxTool`
- [x] `packages/service/core/workflow/dispatch/ai/agent/master/call.ts`:新增 `SANDBOX_GET_FILE_URL_TOOL_NAME` 拦截逻辑
- [x] `packages/service/common/s3/type.ts``buckets/base.ts``sources/chat/index.ts``sources/chat/type.ts`:扩展 `filename``expiredTime` 参数
- [x] `projects/app/src/pages/api/system/file/[jwt].ts`:支持 `Content-Disposition` 下载头
- [x] `projects/app/src/pages/api/system/file/download/[token].ts`:支持 `Content-Disposition` 下载/预览响应头;旧 `/api/system/file/[jwt]` 仅保留历史链接兼容
- [ ] 大文件限制或流式上传优化(见六、待优化)
......@@ -7,7 +7,6 @@ import { serviceEnv } from '../../../env';
/* ==================== 路由与类型 ==================== */
const FileApiPath = {
legacyFile: '/api/system/file',
proxyDownload: '/api/system/file/download',
proxyUpload: '/api/system/file/upload'
} as const;
......@@ -127,11 +126,21 @@ const isS3UploadTokenPayload = (value: unknown): value is S3UploadTokenPayload =
);
};
/* ==================== 旧版文件链接 token ==================== */
/* ==================== 旧版 objectKey token 兼容 ==================== */
/**
* 兼容旧调用方的文件链接签名入口。
*
* 历史实现会生成 `/api/system/file/[jwt]` 链接;现在统一签发代理下载 token,
* 避免新增下载/预览链接继续落到旧接口。旧 objectKey token 的验证能力仍保留,
* 用于兼容已经发出的历史链接。
*/
export function jwtSignS3ObjectKey(objectKey: string, expiredTime: Date) {
const token = signToken({ objectKey } satisfies S3ObjectKeyTokenPayload, expiredTime);
return buildFileApiUrl(FileApiPath.legacyFile, token);
return jwtSignS3DownloadToken({
objectKey,
bucketName: serviceEnv.STORAGE_PRIVATE_BUCKET,
expiredTime,
filename: path.basename(objectKey)
});
}
export function jwtVerifyS3ObjectKey(token: string) {
......
......@@ -9,7 +9,6 @@ import { Readable } from 'stream';
import { addHours } from 'date-fns';
import { defineTool } from './type';
import { getS3ChatSource } from '../../../../../common/s3/sources/chat';
import { jwtSignS3ObjectKey } from '../../../../../common/s3/utils';
import { SANDBOX_GET_FILE_URL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools';
const SandboxGetFileUrlToolSchema = z.object({
......@@ -36,7 +35,12 @@ export const sandboxGetFileUrlTool = defineTool({
body: readable,
expiredTime
});
const fileUrl = jwtSignS3ObjectKey(key, expiredTime);
const { url: fileUrl } = await chatBucket.createGetChatFileURL({
key,
expiredHours: 2,
external: true,
mode: 'proxy'
});
return { fileUrl, filename };
})
......
......@@ -34,12 +34,16 @@ describe('s3 token validation', () => {
vi.restoreAllMocks();
});
it('accepts legacy object key tokens that do not include a type', async () => {
const { jwtSignS3ObjectKey, jwtVerifyS3ObjectKey } = await loadTokenModule();
it('signs object key urls with proxy download tokens', async () => {
const { jwtSignS3ObjectKey, jwtVerifyS3DownloadToken } = await loadTokenModule();
const objectKey = 'chat/appId/userId/chatId/file.txt';
const token = extractTokenFromUrl(jwtSignS3ObjectKey(objectKey, getExpiredTime()));
await expect(jwtVerifyS3ObjectKey(token)).resolves.toMatchObject({ objectKey });
await expect(jwtVerifyS3DownloadToken(token)).resolves.toMatchObject({
objectKey,
bucketName: 'fastgpt-private',
type: 'download'
});
});
it('rejects upload tokens when verifying legacy object key tokens', async () => {
......@@ -81,6 +85,8 @@ describe('s3 token validation', () => {
const { jwtSignS3ObjectKey } = await loadTokenModule();
const url = jwtSignS3ObjectKey('chat/appId/userId/chatId/file.txt', getExpiredTime());
expect(url).toMatch(/^https:\/\/files\.example\.com\/fastgpt\/api\/system\/file\/[^/?#]+$/);
expect(url).toMatch(
/^https:\/\/files\.example\.com\/fastgpt\/api\/system\/file\/download\/[^/?#]+\?filename=file\.txt$/
);
});
});
......@@ -4,19 +4,16 @@ import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
const s3Mock = vi.hoisted(() => ({
uploadChatFile: vi.fn(),
jwtSignS3ObjectKey: vi.fn()
createGetChatFileURL: vi.fn()
}));
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
uploadChatFile: s3Mock.uploadChatFile
uploadChatFile: s3Mock.uploadChatFile,
createGetChatFileURL: s3Mock.createGetChatFileURL
})
}));
vi.mock('@fastgpt/service/common/s3/utils', () => ({
jwtSignS3ObjectKey: s3Mock.jwtSignS3ObjectKey
}));
import { sandboxGetFileUrlTool } from '@fastgpt/service/core/ai/sandbox/application/toolCall/getFileUrl.tool';
const createSandboxInstance = () =>
......@@ -30,7 +27,7 @@ describe('sandboxGetFileUrlTool', () => {
beforeEach(() => {
vi.clearAllMocks();
s3Mock.uploadChatFile.mockResolvedValue({ key: 'chat/file.txt' });
s3Mock.jwtSignS3ObjectKey.mockReturnValue('signed-url');
s3Mock.createGetChatFileURL.mockResolvedValue({ url: 'signed-url' });
});
it('uploads sandbox files and returns signed urls', async () => {
......@@ -56,6 +53,11 @@ describe('sandboxGetFileUrlTool', () => {
filename: 'file.txt'
})
);
expect(s3Mock.jwtSignS3ObjectKey).toHaveBeenCalledWith('chat/file.txt', expect.any(Date));
expect(s3Mock.createGetChatFileURL).toHaveBeenCalledWith({
key: 'chat/file.txt',
expiredHours: 2,
external: true,
mode: 'proxy'
});
});
});
......@@ -21,7 +21,7 @@ const mirrorMock = vi.hoisted(() => ({
const s3Mock = vi.hoisted(() => ({
uploadChatFile: vi.fn(),
jwtSignS3ObjectKey: vi.fn()
createGetChatFileURL: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/sandbox/application/runtime/client', () => ({
......@@ -38,14 +38,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/application/runtime/mirrors', () => ({
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
uploadChatFile: s3Mock.uploadChatFile
uploadChatFile: s3Mock.uploadChatFile,
createGetChatFileURL: s3Mock.createGetChatFileURL
})
}));
vi.mock('@fastgpt/service/common/s3/utils', () => ({
jwtSignS3ObjectKey: s3Mock.jwtSignS3ObjectKey
}));
import {
getSandboxToolInfo,
prepareSandboxToolRuntime,
......@@ -71,7 +68,7 @@ describe('sandbox toolCall index', () => {
vi.clearAllMocks();
runtimeMock.getSandboxClient.mockResolvedValue(createSandboxInstance());
s3Mock.uploadChatFile.mockResolvedValue({ key: 'chat/file.txt' });
s3Mock.jwtSignS3ObjectKey.mockReturnValue('signed-url');
s3Mock.createGetChatFileURL.mockResolvedValue({ url: 'signed-url' });
});
it('executes known tools through a fetched sandbox client', async () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment