Skip to content

Commit 6157882

Browse files
committed
feat(s3): add proxy transfer mode with tokenized upload/download
1 parent 0a50763 commit 6157882

File tree

20 files changed

+536
-69
lines changed

20 files changed

+536
-69
lines changed

document/content/docs/self-host/config/object-storage.en.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ This guide covers environment variable configuration for object storage provider
2121
- `STORAGE_SECRET_ACCESS_KEY` Secret Access Key for the service credentials
2222
- `STORAGE_PUBLIC_BUCKET` FastGPT public resource bucket name
2323
- `STORAGE_PRIVATE_BUCKET` FastGPT private resource bucket name
24+
- `STORAGE_TRANSFER_MODE` File transfer mode. Options:
25+
- `proxy` (default): uploads and downloads are proxied by FastGPT backend; browser direct access to object storage is not required.
26+
- `presigned`: uploads and downloads use object-storage presigned URLs (legacy-compatible mode).
2427

2528
### Self-Hosted MinIO and AWS S3
2629

2730
> MinIO has strong AWS S3 protocol support, so MinIO and AWS S3 configurations are nearly identical — differences come from provider-specific or self-hosted requirements.
2831
> In theory, any object storage with S3 protocol support comparable to MinIO will work, such as SeaweedFS, RustFS, etc.
2932
3033
- `STORAGE_S3_ENDPOINT` Internal connection address. Can be a container ID, e.g., `http://fastgpt-minio:9000`
31-
- `STORAGE_EXTERNAL_ENDPOINT` An address accessible by both **server** and **client** to reach the bucket. Use a fixed host IP or domain name — don't use `127.0.0.1` or `localhost` (containers can't access loopback addresses). This address is used when generating signed file upload URLs.
34+
- `STORAGE_EXTERNAL_ENDPOINT` An address accessible by both **server** and **client** to reach the bucket. Use a fixed host IP or domain name — don't use `127.0.0.1` or `localhost` (containers can't access loopback addresses). When `STORAGE_TRANSFER_MODE=presigned`, this is used to generate upload/download presigned URLs.
3235
- `STORAGE_S3_FORCE_PATH_STYLE` [Optional] Virtual-hosted-style or path-style routing. If vendor is `minio`, this is fixed to `true`.
3336
- `STORAGE_S3_MAX_RETRIES` [Optional] Maximum request retry attempts. Default: 3
3437

@@ -43,6 +46,7 @@ STORAGE_ACCESS_KEY_ID=your_access_key
4346
STORAGE_SECRET_ACCESS_KEY=your_secret_key
4447
STORAGE_PUBLIC_BUCKET=fastgpt-public
4548
STORAGE_PRIVATE_BUCKET=fastgpt-private
49+
STORAGE_TRANSFER_MODE=proxy
4650
STORAGE_EXTERNAL_ENDPOINT=http://127.0.0.1:9000
4751
STORAGE_S3_ENDPOINT=http://127.0.0.1:9000
4852
STORAGE_S3_FORCE_PATH_STYLE=true

document/content/docs/self-host/config/object-storage.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ import FastGPTLink from '@/components/docs/linkFastGPT';
2121
- `STORAGE_SECRET_ACCESS_KEY` 服务访问密钥的 Secret Access Key
2222
- `STORAGE_PUBLIC_BUCKET` FastGPT 公开资源存储桶桶名
2323
- `STORAGE_PRIVATE_BUCKET` FastGPT 私有资源存储桶桶名
24+
- `STORAGE_TRANSFER_MODE` 文件传输模式,可选值:
25+
- `proxy`(默认):上传和下载都通过 FastGPT 后端代理,不要求浏览器直连对象存储。
26+
- `presigned`:上传和下载都通过对象存储预签名 URL,兼容旧模式。
2427

2528
### 自部署的 MinIO 和 AWS S3
2629

2730
> MinIO 这类产品对 AWS S3 协议支持比较完整,因此使用 Minio 和AWS S3 配置几乎可以是相同的,只是因为服务商提供和自部署的区别,会有额外的配置。
2831
> 因此理论上任何对 AWS S3 协议的支持程度至少和 MinIO 相当的对象存储服务都可以使用,比如 SeaweedFS、RustFS 等。
2932
3033
- `STORAGE_S3_ENDPOINT` 内网连接地址,可以是容器 id 连接,比如 `http://fastgpt-minio:9000`
31-
- `STORAGE_EXTERNAL_ENDPOINT` 一个**服务器****客户端**均可访问到存储桶的地址,可以是固定的宿主机 IP 或者域名,注意不要填写成 127.0.0.1 或者 localhost 等本地回环地址(因为容器里无法使用)。该地址用于签发文件上传 URL 时使用
34+
- `STORAGE_EXTERNAL_ENDPOINT` 一个**服务器****客户端**均可访问到存储桶的地址,可以是固定的宿主机 IP 或者域名,注意不要填写成 127.0.0.1 或者 localhost 等本地回环地址(因为容器里无法使用)。`STORAGE_TRANSFER_MODE=presigned` 时,该配置用于签发上传/下载预签名 URL
3235
- `STORAGE_S3_FORCE_PATH_STYLE` 【可选】虚拟主机风格路由或路径路由风格,其中如果厂商填写了 `minio` 的话,该值被固定为 `true`
3336
- `STORAGE_S3_MAX_RETRIES` 【可选】请求最大尝试次数,默认为 3 次
3437

@@ -43,6 +46,7 @@ STORAGE_ACCESS_KEY_ID=your_access_key
4346
STORAGE_SECRET_ACCESS_KEY=your_secret_key
4447
STORAGE_PUBLIC_BUCKET=fastgpt-public
4548
STORAGE_PRIVATE_BUCKET=fastgpt-private
49+
STORAGE_TRANSFER_MODE=proxy
4650
STORAGE_EXTERNAL_ENDPOINT=http://127.0.0.1:9000
4751
STORAGE_S3_ENDPOINT=http://127.0.0.1:9000
4852
STORAGE_S3_FORCE_PATH_STYLE=true

packages/global/common/error/s3.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ export function parseS3UploadError({
2525
if (error?.response?.data) {
2626
const data = error.response.data;
2727

28+
if (typeof data === 'object' && data !== null) {
29+
const msg = `${data.message || ''}`.trim();
30+
const statusText = `${data.statusText || ''}`.trim();
31+
32+
if (msg.includes('EntityTooLarge') || statusText.includes('EntityTooLarge')) {
33+
return t('common:error:s3_upload_file_too_large', { max: maxSizeStr });
34+
}
35+
if (
36+
msg.includes('unAuthFile') ||
37+
statusText.includes('unAuthFile') ||
38+
msg.includes('unAuthorization')
39+
) {
40+
return t('common:error:s3_upload_auth_failed');
41+
}
42+
}
43+
2844
// Try to parse XML error response
2945
if (typeof data === 'string') {
3046
if (data.includes('EntityTooLarge')) {

packages/global/common/string/tools.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,23 @@ export const sliceStrStartEnd = (str: string | null = '', start: number, end: nu
188188
=> pdf
189189
*/
190190
export const parseFileExtensionFromUrl = (url = '') => {
191+
// Prefer explicit filename in query params for proxy links:
192+
// e.g. /api/system/file/download/<token>?filename=image.jpg
193+
try {
194+
const parsedUrl = new URL(url, 'http://localhost');
195+
const queryFilename =
196+
parsedUrl.searchParams.get('filename') || parsedUrl.searchParams.get('name');
197+
if (queryFilename) {
198+
const extFromQuery = path.extname(decodeURIComponent(queryFilename));
199+
if (extFromQuery.startsWith('.')) {
200+
return extFromQuery.slice(1).toLowerCase();
201+
}
202+
}
203+
} catch {
204+
// noop
205+
// fallback to legacy parser below
206+
}
207+
191208
// Remove query params and hash first
192209
const urlWithoutQuery = url.split('?')[0].split('#')[0];
193210
const extension = path.extname(urlWithoutQuery);

packages/service/common/file/read/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ export const readFileContentByBuffer = async ({
228228
}
229229
})();
230230
rawText = rawText.replace(item.uuid, src);
231-
// rawText = rawText.replace(item.uuid, jwtSignS3ObjectKey(src, addDays(new Date(), 90)));
232231
if (formatText) {
233232
formatText = formatText.replace(item.uuid, src);
234233
}

packages/service/common/s3/buckets/base.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type createPreviewUrlParams,
1414
CreateGetPresignedUrlParamsSchema
1515
} from '../type';
16-
import { getSystemMaxFileSize, Mimes } from '../constants';
16+
import { storageTransferMode, getSystemMaxFileSize, Mimes } from '../constants';
1717
import path from 'node:path';
1818
import { MongoS3TTL } from '../schema';
1919
import { addHours, addMinutes, differenceInSeconds } from 'date-fns';
@@ -22,6 +22,7 @@ import { addS3DelJob } from '../mq';
2222
import { type UploadFileByBufferParams, UploadFileByBufferSchema } from '../type';
2323
import type { createStorage } from '@fastgpt-sdk/storage';
2424
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
25+
import { jwtSignS3DownloadToken, jwtSignS3UploadToken } from '../token';
2526

2627
const logger = getLogger(LogCategories.INFRA.S3);
2728

@@ -139,18 +140,12 @@ export class S3BaseBucket {
139140
const ext = path.extname(filename).toLowerCase();
140141
const contentType = Mimes[ext as keyof typeof Mimes] ?? 'application/octet-stream';
141142
const expiredSeconds = differenceInSeconds(addMinutes(new Date(), 10), new Date());
142-
143-
const { metadata, url } = await this.externalClient.generatePresignedPutUrl({
144-
key: params.rawKey,
145-
expiredSeconds,
146-
contentType,
147-
metadata: {
148-
contentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`,
149-
originFilename: encodeURIComponent(filename),
150-
uploadTime: new Date().toISOString(),
151-
...params.metadata
152-
}
153-
});
143+
const metadata = {
144+
contentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`,
145+
originFilename: encodeURIComponent(filename),
146+
uploadTime: new Date().toISOString(),
147+
...params.metadata
148+
};
154149

155150
if (expiredHours) {
156151
await MongoS3TTL.create({
@@ -160,11 +155,36 @@ export class S3BaseBucket {
160155
});
161156
}
162157

158+
if (storageTransferMode === 'proxy') {
159+
return {
160+
url: jwtSignS3UploadToken({
161+
objectKey: params.rawKey,
162+
bucketName: this.bucketName,
163+
expiredTime: addMinutes(new Date(), Math.ceil(expiredSeconds / 60)),
164+
maxSize: formatMaxFileSize,
165+
contentType,
166+
metadata
167+
}),
168+
key: params.rawKey,
169+
headers: {
170+
'content-type': contentType
171+
},
172+
maxSize: formatMaxFileSize
173+
};
174+
}
175+
176+
const { metadata: uploadHeaders, url } = await this.externalClient.generatePresignedPutUrl({
177+
key: params.rawKey,
178+
expiredSeconds,
179+
contentType,
180+
metadata
181+
});
182+
163183
return {
164184
url: url,
165185
key: params.rawKey,
166186
headers: {
167-
...metadata
187+
...uploadHeaders
168188
},
169189
maxSize: formatMaxFileSize
170190
};
@@ -184,6 +204,19 @@ export class S3BaseBucket {
184204
const { key, expiredHours } = parsed;
185205
const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟
186206

207+
if (storageTransferMode === 'proxy') {
208+
return {
209+
bucket: this.bucketName,
210+
key,
211+
url: jwtSignS3DownloadToken({
212+
objectKey: key,
213+
bucketName: this.bucketName,
214+
expiredTime: addMinutes(new Date(), Math.ceil(expires / 60)),
215+
filename: path.basename(key)
216+
})
217+
};
218+
}
219+
187220
return await this.externalClient.generatePresignedGetUrl({ key, expiredSeconds: expires });
188221
}
189222

packages/service/common/s3/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
IOssStorageOptions,
55
IStorageOptions
66
} from '@fastgpt-sdk/storage';
7+
import { env } from '../../env';
78

89
export const Mimes = {
910
'.gif': 'image/gif',
@@ -33,6 +34,8 @@ export const S3Buckets = {
3334
private: process.env.STORAGE_PRIVATE_BUCKET || 'fastgpt-private'
3435
} as const;
3536

37+
export const storageTransferMode = env.STORAGE_TRANSFER_MODE;
38+
3639
export const getSystemMaxFileSize = () => {
3740
const config = global.feConfigs.uploadFileMaxSize || 1024; // MB, default 1024MB
3841
return config; // bytes

0 commit comments

Comments
 (0)