Commit 81d7b93f by Xianquan Committed by GitHub

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
parent e3717e53
...@@ -50,7 +50,7 @@ FastGPT 的私有对象存储 key 是 bucket 内的全局路径字符串,例 ...@@ -50,7 +50,7 @@ FastGPT 的私有对象存储 key 是 bucket 内的全局路径字符串,例
2. 先完成业务资源鉴权,拿到可信的 `teamId``appId``datasetId``uid``userId` 2. 先完成业务资源鉴权,拿到可信的 `teamId``appId``datasetId``uid``userId`
3. 使用对应 `isAuthorized*FileS3Key` helper 绑定 key 与可信上下文。 3. 使用对应 `isAuthorized*FileS3Key` helper 绑定 key 与可信上下文。
4. 绑定失败时返回通用未授权错误,不暴露 key 是否存在。 4. 绑定失败时返回通用未授权错误,不暴露 key 是否存在。
5. 只有通过绑定后,才允许调用 `createExternalUrl``createGet*URL``jwtSignS3DownloadToken``downloadObject``getDatasetFileRawText``isObjectExists` 等存储层能力。 5. 只有通过绑定后,才允许调用 `createExternalUrl``createGet*URL``createS3DownloadAccessUrl``downloadObject``getDatasetFileRawText``isObjectExists` 等存储层能力。
## 底层防线 ## 底层防线
......
...@@ -109,7 +109,7 @@ export const sliceStrStartEnd = (str: string | null = '', start: number, end: nu ...@@ -109,7 +109,7 @@ export const sliceStrStartEnd = (str: string | null = '', start: number, end: nu
*/ */
export const parseFileExtensionFromUrl = (url = '') => { export const parseFileExtensionFromUrl = (url = '') => {
// Prefer explicit filename in query params for proxy links: // Prefer explicit filename in query params for proxy links:
// e.g. /api/system/file/download/<token>?filename=image.jpg // e.g. /api/system/file/d/<alias>, or a legacy proxy URL carrying filename in query.
try { try {
const parsedUrl = new URL(url, 'http://localhost'); const parsedUrl = new URL(url, 'http://localhost');
const queryFilename = const queryFilename =
......
...@@ -5,3 +5,16 @@ export const SandboxImageConfigSchema = z.object({ ...@@ -5,3 +5,16 @@ export const SandboxImageConfigSchema = z.object({
tag: z.string().optional() tag: z.string().optional()
}); });
export type SandboxImageConfigType = z.infer<typeof SandboxImageConfigSchema>; export type SandboxImageConfigType = z.infer<typeof SandboxImageConfigSchema>;
/**
* Sandbox 生成文件的服务端持久化引用。
*
* url 只用于在历史读取时定位并替换旧签名链接;key 才是文件的稳定身份。
* 该结构不会作为工具响应发送给模型或通过 SSE 暴露给前端。
*/
export const SandboxFileRefSchema = z.object({
key: z.string(),
filename: z.string(),
url: z.string()
});
export type SandboxFileRef = z.infer<typeof SandboxFileRefSchema>;
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: []
};
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
AgentPlanStatusSchema AgentPlanStatusSchema
} from '../ai/agent/type'; } from '../ai/agent/type';
import { ObjectIdSchema } from '../../common/type/mongo'; import { ObjectIdSchema } from '../../common/type/mongo';
import { SandboxFileRefSchema } from '../ai/sandbox/type';
export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({ export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({
nodeId: z.string(), nodeId: z.string(),
...@@ -40,7 +41,8 @@ export const ToolModuleResponseItemSchema = z.object({ ...@@ -40,7 +41,8 @@ export const ToolModuleResponseItemSchema = z.object({
toolAvatar: z.string(), toolAvatar: z.string(),
params: z.string(), params: z.string(),
response: z.string().nullish(), response: z.string().nullish(),
functionName: z.string() functionName: z.string(),
fileRefs: z.array(SandboxFileRefSchema).optional()
}); });
export type ToolModuleResponseItemType = z.infer<typeof ToolModuleResponseItemSchema>; export type ToolModuleResponseItemType = z.infer<typeof ToolModuleResponseItemSchema>;
......
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat'; import { OutLinkChatAuthSchema } from '../../../../support/permission/chat';
import { AppFileSelectConfigTypeSchema } from '../../../../core/app/type/config.schema'; import { AppFileSelectConfigTypeSchema } from '../../../../core/app/type/config.schema';
import z from 'zod'; import z from 'zod';
import { createOutLinkChatTargetInputSchema, transformChatAuthTargetInput } from '../api'; import {
createChatTargetInputSchema,
createOutLinkChatTargetInputSchema,
transformChatAuthTargetInput,
transformChatTargetInput
} from '../api';
import { IntSchema } from '../../../../common/zod';
/* ============ chat file ============ */ /* ============ chat file ============ */
const withChatFileTarget = <T extends z.ZodRawShape>(shape: T) => const withChatFileTarget = <T extends z.ZodRawShape>(shape: T) =>
createOutLinkChatTargetInputSchema(shape).transform(transformChatAuthTargetInput); createOutLinkChatTargetInputSchema(shape).transform(transformChatAuthTargetInput);
const withInternalChatFileTarget = <T extends z.ZodRawShape>(shape: T) =>
createChatTargetInputSchema(shape).transform(transformChatTargetInput);
const ChatFileDownloadModeSchema = z
.enum(['short-proxy', 'short-redirect', 'presigned'])
.optional()
.describe('下载链接模式');
export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema({ export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema({
key: z.string().min(1).describe('文件key'), key: z.string().min(1).describe('文件key'),
chatId: z.string().min(1).describe('对话ID'), chatId: z.string().min(1).describe('对话ID'),
mode: z.enum(['proxy', 'presigned']).optional().describe('下载方式'), mode: ChatFileDownloadModeSchema,
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
}).meta({ }).meta({
example: { example: {
...@@ -25,25 +37,28 @@ export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema ...@@ -25,25 +37,28 @@ export const PresignChatFileGetUrlRawSchema = createOutLinkChatTargetInputSchema
export const PresignChatFileGetUrlSchema = withChatFileTarget({ export const PresignChatFileGetUrlSchema = withChatFileTarget({
key: z.string().min(1).describe('文件key'), key: z.string().min(1).describe('文件key'),
chatId: z.string().min(1).describe('对话ID'), chatId: z.string().min(1).describe('对话ID'),
mode: z.enum(['proxy', 'presigned']).optional().describe('下载方式'), mode: ChatFileDownloadModeSchema,
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
}); });
export type PresignChatFileGetUrlParams = z.input<typeof PresignChatFileGetUrlSchema>; export type PresignChatFileGetUrlParams = z.input<typeof PresignChatFileGetUrlSchema>;
export type PresignChatFileGetUrlRuntimeParams = z.output<typeof PresignChatFileGetUrlSchema>; export type PresignChatFileGetUrlRuntimeParams = z.output<typeof PresignChatFileGetUrlSchema>;
export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchema({ const ChatFileUploadHintShape = {
filename: z.string().min(1).describe('文件名'), 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'), chatId: z.string().min(1).describe('对话ID'),
fileSelectConfig: AppFileSelectConfigTypeSchema.describe('本次上传控件的文件选择配置'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
}).meta({ }).meta({
example: { example: {
filename: '1234567890', filename: 'report.pdf',
chatId: '1234567890', chatId: '1234567890',
fileSelectConfig: {
canSelectFile: true,
customFileExtensionList: ['.txt']
},
outLinkAuthData: { outLinkAuthData: {
shareId: '1234567890', shareId: '1234567890',
outLinkUid: '1234567890' outLinkUid: '1234567890'
...@@ -51,10 +66,34 @@ export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchem ...@@ -51,10 +66,34 @@ export const PresignChatFilePostUrlRawSchema = createOutLinkChatTargetInputSchem
} }
}); });
export const PresignChatFilePostUrlSchema = withChatFileTarget({ export const PresignChatFilePostUrlSchema = withChatFileTarget({
filename: z.string().min(1).describe('文件名'), ...ChatFileUploadHintShape,
chatId: z.string().min(1).describe('对话ID'), chatId: z.string().min(1).describe('对话ID'),
fileSelectConfig: AppFileSelectConfigTypeSchema.describe('本次上传控件的文件选择配置'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据') outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
}); });
export type PresignChatFilePostUrlParams = z.input<typeof PresignChatFilePostUrlSchema>; export type PresignChatFilePostUrlParams = z.input<typeof PresignChatFilePostUrlSchema>;
export type PresignChatFilePostUrlRuntimeParams = z.output<typeof PresignChatFilePostUrlSchema>; export type PresignChatFilePostUrlRuntimeParams = z.output<typeof PresignChatFilePostUrlSchema>;
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<typeof PresignDraftChatFilePostUrlSchema>;
export type PresignDraftChatFilePostUrlRuntimeParams = z.output<
typeof PresignDraftChatFilePostUrlSchema
>;
import type { OpenAPIPath } from '../../../type'; import type { OpenAPIPath } from '../../../type';
import { DevApiTagsMap } from '../../../tag'; import { DevApiTagsMap } from '../../../tag';
import { PresignChatFilePostUrlRawSchema, PresignChatFileGetUrlRawSchema } from './api'; import {
PresignChatFilePostUrlRawSchema,
PresignDraftChatFilePostUrlRawSchema,
PresignChatFileGetUrlRawSchema
} from './api';
import { CreatePostPresignedUrlResponseSchema } from '../../../../common/file/s3/type'; import { CreatePostPresignedUrlResponseSchema } from '../../../../common/file/s3/type';
import { z } from 'zod'; import { z } from 'zod';
...@@ -29,6 +33,30 @@ export const ChatFilePath: OpenAPIPath = { ...@@ -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': { '/core/chat/file/presignChatFileGetUrl': {
post: { post: {
summary: '获取文件预览地址', summary: '获取文件预览地址',
......
...@@ -59,7 +59,7 @@ export const UpdateDatasetDataBodySchema = UpdateDatasetDataPropsSchema; ...@@ -59,7 +59,7 @@ export const UpdateDatasetDataBodySchema = UpdateDatasetDataPropsSchema;
export type UpdateDatasetDataBody = z.infer<typeof UpdateDatasetDataBodySchema>; export type UpdateDatasetDataBody = z.infer<typeof UpdateDatasetDataBodySchema>;
export const UpdateDatasetDataResponseSchema = z.object({ export const UpdateDatasetDataResponseSchema = z.object({
q: z.string().optional().meta({ 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 图片会替换为签名访问地址' description: '展示态问题/主文本,内部 S3 图片会替换为签名访问地址'
}), }),
a: z.string().optional().meta({ a: z.string().optional().meta({
......
import z from 'zod'; import z from 'zod';
import { ChatFileTypeEnum } from '../../core/chat/constants';
/* ============================================================================ /* ============================================================================
* API: 获取反向调用用户信息 * API: 获取反向调用用户信息
...@@ -46,9 +47,7 @@ export const InvokeWecomCorpTokenResponseSchema = z.object({ ...@@ -46,9 +47,7 @@ export const InvokeWecomCorpTokenResponseSchema = z.object({
export type InvokeWecomCorpTokenBodyType = z.infer<typeof InvokeWecomCorpTokenBodySchema>; export type InvokeWecomCorpTokenBodyType = z.infer<typeof InvokeWecomCorpTokenBodySchema>;
export type InvokeWecomCorpTokenQueryType = z.infer<typeof InvokeWecomCorpTokenQuerySchema>; export type InvokeWecomCorpTokenQueryType = z.infer<typeof InvokeWecomCorpTokenQuerySchema>;
export type InvokeWecomCorpTokenResponseType = z.infer< export type InvokeWecomCorpTokenResponseType = z.infer<typeof InvokeWecomCorpTokenResponseSchema>;
typeof InvokeWecomCorpTokenResponseSchema
>;
/* ============================================================================ /* ============================================================================
* API: 反向调用文件上传 * API: 反向调用文件上传
...@@ -62,7 +61,26 @@ export const InvokeFileUploadBodySchema = z.object({}); ...@@ -62,7 +61,26 @@ export const InvokeFileUploadBodySchema = z.object({});
export const InvokeFileUploadQuerySchema = z.object({}); export const InvokeFileUploadQuerySchema = z.object({});
export const InvokeFileUploadResponseSchema = 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<typeof InvokeFileUploadBodySchema>; export type InvokeFileUploadBodyType = z.infer<typeof InvokeFileUploadBodySchema>;
......
...@@ -18,7 +18,11 @@ import { ResumeStreamParamsSchema } from '../../../../openapi/core/ai/api'; ...@@ -18,7 +18,11 @@ import { ResumeStreamParamsSchema } from '../../../../openapi/core/ai/api';
import { ChatSourceTypeEnum } from '../../../../core/chat/constants'; import { ChatSourceTypeEnum } from '../../../../core/chat/constants';
import { StopV2ChatSchema } from '../../../../openapi/core/chat/controler/api'; import { StopV2ChatSchema } from '../../../../openapi/core/chat/controler/api';
import { InitOutLinkChatQuerySchema } from '../../../../openapi/core/chat/outLink/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 { SandboxCheckExistBodySchema } from '../../../../openapi/core/ai/sandbox/api';
import { CreateQuestionGuideV2BodySchema } from '../../../../openapi/core/ai/agent/api'; import { CreateQuestionGuideV2BodySchema } from '../../../../openapi/core/ai/agent/api';
import { ExportCollectionBodySchema } from '../../../../openapi/core/dataset/collection/api'; import { ExportCollectionBodySchema } from '../../../../openapi/core/dataset/collection/api';
...@@ -259,6 +263,61 @@ describe('openapi/core/chat target schema', () => { ...@@ -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', () => { it('parses outLink init query string auth data for GET requests', () => {
const result = InitOutLinkChatQuerySchema.parse({ const result = InitOutLinkChatQuerySchema.parse({
chatId: 'chat-1', chatId: 'chat-1',
......
/**
* 粗略判断 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;
};
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'
});
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;
import { MongoS3DownloadAlias } from './schema';
import type { S3DownloadAliasType } from '../type';
type CreateS3DownloadAliasData = Omit<S3DownloadAliasType, 'createTime' | 'updateTime'> & {
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 }
});
};
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<S3DownloadAliasType>(
S3DownloadAliasCollectionName,
S3DownloadAliasMongoSchema
);
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<VerifiedS3DownloadAccess> => {
return VerifiedS3DownloadAccessSchema.parse(
await s3AccessLinkService.verifyDownloadAlias(signedAlias)
);
};
export const revokeS3DownloadAlias = (aliasId: string) => {
return s3AccessLinkService.revokeDownloadAlias(aliasId);
};
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 }
});
}
};
export {
S3AccessLinkErrCode,
S3AccessLinkError,
isS3AccessLinkError,
type S3AccessLinkErrorCode
} from '@fastgpt-sdk/storage';
export * from './constants';
export * from './error';
export * from './type';
export * from './utils';
export * from './downloadAlias/entity';
export * from './downloadAlias/service';
export * from './uploadSession/service';
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<typeof S3DownloadAliasSchema>;
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<typeof CreateS3DownloadAccessUrlParamsSchema>;
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<typeof ParsedS3SignedDownloadAliasSchema>;
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<typeof S3ProxyDownloadPayloadSchema>;
export const VerifiedS3DownloadAccessSchema = S3ProxyDownloadPayloadSchema.extend({
expiresAt: z.coerce.date()
});
export type VerifiedS3DownloadAccess = z.infer<typeof VerifiedS3DownloadAccessSchema>;
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<typeof S3UploadSessionSchema>;
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<typeof CreateS3UploadAccessUrlParamsSchema>;
export const S3ProxyUploadPayloadSchema = S3UploadSessionSchema.pick({
bucketName: true,
objectKey: true,
maxSize: true,
uploadConstraints: true,
uploadPolicy: true,
fileHint: true,
metadata: true
});
export type S3ProxyUploadPayload = z.infer<typeof S3ProxyUploadPayloadSchema>;
export const S3DownloadAccessRouteQuerySchema = z.object({
signedAlias: S3SignedDownloadAliasValueSchema
});
export const S3UploadAccessRouteQuerySchema = z.object({
token: S3UploadTokenSchema
});
import { MongoS3UploadSession } from './schema';
import type { S3UploadSessionType } from '../type';
type CreateS3UploadSessionData = Omit<S3UploadSessionType, 'createTime'> & {
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()
}
}
);
};
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<S3UploadSessionType>(
S3UploadSessionCollectionName,
S3UploadSessionMongoSchema
);
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<S3ProxyUploadPayload> => {
return S3ProxyUploadPayloadSchema.parse(await s3AccessLinkService.verifyUploadToken(token));
};
export const revokeS3UploadSessionToken = (token: string) => {
return s3AccessLinkService.revokeUploadToken(token);
};
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
}
}
);
}
};
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}`;
};
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 };
...@@ -5,10 +5,11 @@ import { ...@@ -5,10 +5,11 @@ import {
type createPreviewUrlParams, type createPreviewUrlParams,
CreateGetPresignedUrlParamsSchema, CreateGetPresignedUrlParamsSchema,
CreatePostPresignedUrlOptionsSchema, CreatePostPresignedUrlOptionsSchema,
CreatePostPresignedUrlParamsSchema,
type CreatePostPresignedUrlResult type CreatePostPresignedUrlResult
} from '../contracts/type'; } from '../contracts/type';
import { import {
storageDownloadMode, storageDownloadUrlMode,
getSystemMaxFileSize, getSystemMaxFileSize,
replaceS3UrlWithCdnEndpoint replaceS3UrlWithCdnEndpoint
} from '../config/constants'; } from '../config/constants';
...@@ -23,7 +24,11 @@ import { type UploadFileByBufferParams, UploadFileByBodySchema } from '../contra ...@@ -23,7 +24,11 @@ import { type UploadFileByBufferParams, UploadFileByBodySchema } from '../contra
import type { createStorage } from '@fastgpt-sdk/storage'; import type { createStorage } from '@fastgpt-sdk/storage';
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools'; import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
import { getContentDisposition } from '@fastgpt/global/common/file/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); const logger = getLogger(LogCategories.INFRA.S3);
...@@ -143,6 +148,17 @@ export class S3BaseBucket { ...@@ -143,6 +148,17 @@ export class S3BaseBucket {
}); });
throw err; 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<Parameters<typeof addS3DelJob>[0], 'bucketName'>) { addDeleteJob(params: Omit<Parameters<typeof addS3DelJob>[0], 'bucketName'>) {
...@@ -165,45 +181,64 @@ export class S3BaseBucket { ...@@ -165,45 +181,64 @@ export class S3BaseBucket {
maxFileSize = getSystemMaxFileSize(), maxFileSize = getSystemMaxFileSize(),
uploadConstraints uploadConstraints
} = CreatePostPresignedUrlOptionsSchema.parse(options); } = CreatePostPresignedUrlOptionsSchema.parse(options);
const parsedParams = CreatePostPresignedUrlParamsSchema.parse(params);
const formatMaxFileSize = maxFileSize * 1024 * 1024; const formatMaxFileSize = maxFileSize * 1024 * 1024;
const filename = params.filename; const filename = parsedParams.filename;
const resolvedUploadConstraints = createUploadConstraints({ const resolvedFilename = parsedParams.declaredFilename || filename;
const fileHint = {
filename, 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 uploadConstraints
}); });
const expiredSeconds = differenceInSeconds(addMinutes(new Date(), 10), new Date()); const expiredSeconds = differenceInSeconds(addMinutes(new Date(), 10), new Date());
const metadata = { const metadata = {
contentDisposition: getContentDisposition({ filename, type: 'attachment' }), contentDisposition: getContentDisposition({
originFilename: encodeURIComponent(filename), filename: resolvedFilename,
type: 'attachment'
}),
originFilename: encodeURIComponent(resolvedFilename),
uploadTime: new Date().toISOString(), uploadTime: new Date().toISOString(),
...params.metadata ...parsedParams.metadata
}; };
if (expiredHours) { if (expiredHours) {
await MongoS3TTL.create({ await MongoS3TTL.create({
minioKey: params.rawKey, minioKey: parsedParams.rawKey,
bucketName: this.bucketName, bucketName: this.bucketName,
expiredTime: addHours(new Date(), expiredHours) expiredTime: addHours(new Date(), expiredHours)
}); });
} }
const { url: previewUrl } = await this.createExternalUrl({ const { url: previewUrl } = await this.createExternalUrl({
key: params.rawKey, key: parsedParams.rawKey,
expiredHours expiredHours
}); });
return { return {
url: jwtSignS3UploadToken({ url: await createS3UploadAccessUrl({
objectKey: params.rawKey, objectKey: parsedParams.rawKey,
bucketName: this.bucketName, bucketName: this.bucketName,
expiredTime: addMinutes(new Date(), Math.ceil(expiredSeconds / 60)), expiredTime: addMinutes(new Date(), Math.ceil(expiredSeconds / 60)),
maxSize: formatMaxFileSize, maxSize: formatMaxFileSize,
uploadConstraints: resolvedUploadConstraints, uploadConstraints: resolvedUploadPolicy,
uploadPolicy: resolvedUploadPolicy,
fileHint,
metadata metadata
}), }),
key: params.rawKey, key: parsedParams.rawKey,
headers: { headers: {
'content-type': resolvedUploadConstraints.defaultContentType 'content-type': resolvedUploadPolicy.defaultContentType
}, },
previewUrl, previewUrl,
maxSize: formatMaxFileSize maxSize: formatMaxFileSize
...@@ -246,15 +281,16 @@ export class S3BaseBucket { ...@@ -246,15 +281,16 @@ export class S3BaseBucket {
const { key, expiredHours, mode, responseContentType } = parsed; const { key, expiredHours, mode, responseContentType } = parsed;
const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟 const expires = expiredHours ? expiredHours * 60 * 60 : 30 * 60; // expires 的单位是秒 默认 30 分钟
if ((mode || storageDownloadMode) === 'proxy') { if ((mode ?? storageDownloadUrlMode) !== 'presigned') {
return { return {
bucket: this.bucketName, bucket: this.bucketName,
key, key,
url: jwtSignS3DownloadToken({ url: await createS3DownloadAccessUrl({
objectKey: key, objectKey: key,
bucketName: this.bucketName, bucketName: this.bucketName,
expiredTime: addMinutes(new Date(), Math.ceil(expires / 60)), expiredTime: addMinutes(new Date(), Math.ceil(expires / 60)),
filename: path.basename(key) filename: path.basename(key),
responseContentType
}) })
}; };
} }
...@@ -336,8 +372,11 @@ export class S3BaseBucket { ...@@ -336,8 +372,11 @@ export class S3BaseBucket {
}; };
} }
async getFileStream(key: string) { async getFileStream(key: string, options?: { abortSignal?: AbortSignal }) {
const downloadResponse = await this.client.downloadObject({ key }); const downloadResponse = await this.client.downloadObject({
key,
...(options?.abortSignal ? { abortSignal: options.abortSignal } : {})
});
if (!downloadResponse) return; if (!downloadResponse) return;
return downloadResponse.body; return downloadResponse.body;
......
...@@ -5,6 +5,7 @@ import type { ...@@ -5,6 +5,7 @@ import type {
IStorageOptions IStorageOptions
} from '@fastgpt-sdk/storage'; } from '@fastgpt-sdk/storage';
import { serviceEnv } from '../../../env'; import { serviceEnv } from '../../../env';
import { StorageDownloadUrlModeSchema } from '../contracts/type';
export const S3Buckets = { export const S3Buckets = {
public: serviceEnv.STORAGE_PUBLIC_BUCKET, public: serviceEnv.STORAGE_PUBLIC_BUCKET,
...@@ -22,10 +23,19 @@ type BucketStorageOptions = { ...@@ -22,10 +23,19 @@ type BucketStorageOptions = {
}; };
const storageRegion = serviceEnv.STORAGE_REGION; const storageRegion = serviceEnv.STORAGE_REGION;
const storageVendor = serviceEnv.STORAGE_VENDOR;
const storageExternalEndpoint = serviceEnv.STORAGE_EXTERNAL_ENDPOINT; const storageExternalEndpoint = serviceEnv.STORAGE_EXTERNAL_ENDPOINT;
export const storageS3CdnEndpoint = serviceEnv.STORAGE_S3_CDN_ENDPOINT; export const storageS3CdnEndpoint = serviceEnv.STORAGE_S3_CDN_ENDPOINT;
const storageS3Endpoint = serviceEnv.STORAGE_S3_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 storagePublicAccessExtraSubPath = serviceEnv.STORAGE_PUBLIC_ACCESS_EXTRA_SUB_PATH;
const bucketStorageOptions = { const bucketStorageOptions = {
......
import z from 'zod'; import z from 'zod';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import {
UploadExtensionRuleSchema,
UploadFileHintSchema,
UploadPolicySchema
} from '../uploadPolicy/type';
export const S3MetadataSchema = z.object({ export const S3MetadataSchema = z.object({
filename: z.string(), filename: z.string(),
...@@ -14,15 +19,13 @@ export type S3Metadata = z.infer<typeof S3MetadataSchema>; ...@@ -14,15 +19,13 @@ export type S3Metadata = z.infer<typeof S3MetadataSchema>;
export type ContentType = string; export type ContentType = string;
export type ExtensionType = `.${string}`; export type ExtensionType = `.${string}`;
export const UploadConstraintsSchema = z.object({ export const UploadConstraintsSchema = UploadPolicySchema;
defaultContentType: z.string().nonempty(),
allowedExtensions: z.array(z.string().nonempty()).optional()
});
export type UploadConstraints = z.infer<typeof UploadConstraintsSchema>; export type UploadConstraints = z.infer<typeof UploadConstraintsSchema>;
export const UploadConstraintsInputSchema = z.object({ export const UploadConstraintsInputSchema = z.object({
defaultContentType: z.string().nonempty().optional(), 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<typeof UploadConstraintsInputSchema>; export type UploadConstraintsInput = z.infer<typeof UploadConstraintsInputSchema>;
...@@ -30,12 +33,20 @@ export const S3SourcesSchema = z.enum(['avatar', 'chat', 'dataset', 'temp', 'raw ...@@ -30,12 +33,20 @@ export const S3SourcesSchema = z.enum(['avatar', 'chat', 'dataset', 'temp', 'raw
export const S3Sources = S3SourcesSchema.enum; export const S3Sources = S3SourcesSchema.enum;
export type S3SourceType = z.infer<typeof S3SourcesSchema>; export type S3SourceType = z.infer<typeof S3SourcesSchema>;
export const DownloadModeSchema = z.enum(['proxy', 'presigned']); export const StorageDownloadUrlModeSchema = z.enum(['short-proxy', 'short-redirect', 'presigned']);
export type StorageDownloadUrlMode = z.infer<typeof StorageDownloadUrlModeSchema>;
export const DownloadModeSchema = StorageDownloadUrlModeSchema;
export type DownloadMode = z.infer<typeof DownloadModeSchema>; export type DownloadMode = z.infer<typeof DownloadModeSchema>;
export const CreatePostPresignedUrlParamsSchema = z.object({ export const CreatePostPresignedUrlParamsSchema = z.object({
filename: z.string().min(1), filename: z.string().min(1),
rawKey: 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() metadata: z.record(z.string(), z.string()).optional()
}); });
export type CreatePostPresignedUrlParams = z.infer<typeof CreatePostPresignedUrlParamsSchema>; export type CreatePostPresignedUrlParams = z.infer<typeof CreatePostPresignedUrlParamsSchema>;
......
...@@ -2,6 +2,7 @@ import { getQueue, getWorker, QueueNames } from '../../bullmq'; ...@@ -2,6 +2,7 @@ import { getQueue, getWorker, QueueNames } from '../../bullmq';
import { getLogger, LogCategories } from '../../logger'; import { getLogger, LogCategories } from '../../logger';
import path from 'path'; import path from 'path';
import { batchRun } from '@fastgpt/global/common/system/utils'; import { batchRun } from '@fastgpt/global/common/system/utils';
import { deleteS3DownloadAliasByObjects } from '../accessLink';
const logger = getLogger(LogCategories.INFRA.S3); const logger = getLogger(LogCategories.INFRA.S3);
...@@ -64,6 +65,17 @@ export const executeS3DeleteJob = async ({ prefix, bucketName, key, keys }: S3MQ ...@@ -64,6 +65,17 @@ export const executeS3DeleteJob = async ({ prefix, bucketName, key, keys }: S3MQ
| undefined; | undefined;
assertNoFailedKeys(result?.keys, 'keys'); 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) => { await batchRun(keys, async (key) => {
if (key.includes('-parsed/')) return; if (key.includes('-parsed/')) return;
const fileParsedPrefix = `${path.dirname(key)}/${path.basename(key, path.extname(key))}-parsed`; const fileParsedPrefix = `${path.dirname(key)}/${path.basename(key, path.extname(key))}-parsed`;
......
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { differenceInSeconds } from 'date-fns';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import type { UploadConstraints } from '../contracts/type'; import type { UploadConstraints } from '../contracts/type';
import path from 'path';
import { serviceEnv } from '../../../env'; import { serviceEnv } from '../../../env';
/* ==================== 路由与类型 ==================== */
const FileApiPath = {
proxyDownload: '/api/system/file/download',
proxyUpload: '/api/system/file/upload'
} as const;
export type S3ObjectKeyTokenPayload = { export type S3ObjectKeyTokenPayload = {
objectKey: string; objectKey: string;
}; };
...@@ -30,27 +22,7 @@ type S3UploadTokenPayload = { ...@@ -30,27 +22,7 @@ type S3UploadTokenPayload = {
type: 'upload'; 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<string, string>;
};
/* ==================== 通用工具函数 ==================== */ /* ==================== 通用工具函数 ==================== */
const getExpiresIn = (expiredTime: Date) => {
return Math.max(1, differenceInSeconds(expiredTime, new Date()));
};
const isRecord = (val: unknown): val is Record<string, unknown> => const isRecord = (val: unknown): val is Record<string, unknown> =>
!!val && typeof val === 'object' && !Array.isArray(val); !!val && typeof val === 'object' && !Array.isArray(val);
...@@ -60,10 +32,6 @@ const isStringArray = (val: unknown): val is string[] => ...@@ -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 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 = <T>(payload: unknown, checker: (value: unknown) => value is T): T => { const parsePayload = <T>(payload: unknown, checker: (value: unknown) => value is T): T => {
if (!checker(payload)) { if (!checker(payload)) {
throw ERROR_ENUM.unAuthFile; throw ERROR_ENUM.unAuthFile;
...@@ -71,12 +39,6 @@ const parsePayload = <T>(payload: unknown, checker: (value: unknown) => value is ...@@ -71,12 +39,6 @@ const parsePayload = <T>(payload: unknown, checker: (value: unknown) => value is
return payload; return payload;
}; };
const signToken = <T extends object>(payload: T, expiredTime: Date) => {
return jwt.sign(payload, serviceEnv.FILE_TOKEN_KEY, {
expiresIn: getExpiresIn(expiredTime)
});
};
export const verifyToken = <T>(token: string, checker: (value: unknown) => value is T) => { export const verifyToken = <T>(token: string, checker: (value: unknown) => value is T) => {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
jwt.verify(token, serviceEnv.FILE_TOKEN_KEY, (err, payload) => { jwt.verify(token, serviceEnv.FILE_TOKEN_KEY, (err, payload) => {
...@@ -127,55 +89,11 @@ const isS3UploadTokenPayload = (value: unknown): value is S3UploadTokenPayload = ...@@ -127,55 +89,11 @@ const isS3UploadTokenPayload = (value: unknown): value is S3UploadTokenPayload =
}; };
/* ==================== 代理下载 token ==================== */ /* ==================== 代理下载 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) { export function jwtVerifyS3DownloadToken(token: string) {
return verifyToken<S3DownloadTokenPayload>(token, isS3DownloadTokenPayload); return verifyToken<S3DownloadTokenPayload>(token, isS3DownloadTokenPayload);
} }
/* ==================== 代理上传 token ==================== */ /* ==================== 代理上传 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) { export function jwtVerifyS3UploadToken(token: string) {
return verifyToken<S3UploadTokenPayload>(token, isS3UploadTokenPayload); return verifyToken<S3UploadTokenPayload>(token, isS3UploadTokenPayload);
} }
import { S3PrivateBucket } from '../../buckets/private'; import { S3PrivateBucket } from '../../buckets/private';
import { S3Sources } from '../../contracts/type'; import { type DownloadMode, S3Sources } from '../../contracts/type';
import { import {
type CheckChatFileKeys, type CheckChatFileKeys,
type DelChatFileByPrefixParams, type DelChatFileByPrefixParams,
...@@ -76,7 +76,7 @@ export class S3ChatSource extends S3PrivateBucket { ...@@ -76,7 +76,7 @@ export class S3ChatSource extends S3PrivateBucket {
key: string; key: string;
expiredHours?: number; expiredHours?: number;
external: boolean; external: boolean;
mode?: 'proxy' | 'presigned'; mode?: DownloadMode;
}) { }) {
const { key, expiredHours = 1, external = false, mode } = params; // 默认一个小时 const { key, expiredHours = 1, external = false, mode } = params; // 默认一个小时
...@@ -93,18 +93,31 @@ export class S3ChatSource extends S3PrivateBucket { ...@@ -93,18 +93,31 @@ export class S3ChatSource extends S3PrivateBucket {
chatId, chatId,
uId, uId,
filename, filename,
contentType,
declaredExtension,
declaredFilename,
size,
expiredTime, expiredTime,
maxFileSize, maxFileSize,
allowedExtensions allowedExtensions,
extensionRules
} = ChatFileUploadSchema.parse(params); } = ChatFileUploadSchema.parse(params);
const { fileKey } = getChatFileS3Key({ sourceType, sourceId, chatId, uId, filename }); const { fileKey } = getChatFileS3Key({ sourceType, sourceId, chatId, uId, filename });
return await this.createPresignedPutUrl( 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, expiredHours: expiredTime ? differenceInHours(expiredTime, new Date()) : 1,
maxFileSize, maxFileSize,
uploadConstraints: { uploadConstraints: {
allowedExtensions allowedExtensions,
extensionRules
} }
} }
); );
...@@ -174,7 +187,7 @@ export function getS3ChatSource() { ...@@ -174,7 +187,7 @@ export function getS3ChatSource() {
export const createChatFilePreviewUrlGetter = (options?: { export const createChatFilePreviewUrlGetter = (options?: {
expiredHours?: number; expiredHours?: number;
mode?: 'proxy' | 'presigned'; mode?: DownloadMode;
}) => { }) => {
const s3ChatSource = getS3ChatSource(); const s3ChatSource = getS3ChatSource();
......
...@@ -65,10 +65,36 @@ export function isAuthorizedChatFileS3Key({ ...@@ -65,10 +65,36 @@ export function isAuthorizedChatFileS3Key({
const parsedKey = parseChatFileS3Key(key); const parsedKey = parseChatFileS3Key(key);
return ( 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 &&
parsedKey.sourceType === sourceType && parsedKey.sourceType === sourceType &&
String(parsedKey.sourceId) === String(sourceId) && String(parsedKey.sourceId) === String(sourceId) &&
String(parsedKey.uid) === String(uid) &&
(chatId === undefined || String(parsedKey.chatId) === String(chatId)) (chatId === undefined || String(parsedKey.chatId) === String(chatId))
); );
} }
...@@ -2,6 +2,7 @@ import z from 'zod'; ...@@ -2,6 +2,7 @@ import z from 'zod';
import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo'; import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
import { UploadFileByBodySchema } from '../../contracts/type'; import { UploadFileByBodySchema } from '../../contracts/type';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { UploadExtensionRuleSchema, UploadFileHintSchema } from '../../uploadPolicy/type';
export const ChatS3SourceTypeSchema = z.enum(ChatSourceTypeEnum); export const ChatS3SourceTypeSchema = z.enum(ChatSourceTypeEnum);
export type ChatS3SourceType = z.infer<typeof ChatS3SourceTypeSchema>; export type ChatS3SourceType = z.infer<typeof ChatS3SourceTypeSchema>;
...@@ -12,9 +13,14 @@ export const ChatFileUploadSchema = z.object({ ...@@ -12,9 +13,14 @@ export const ChatFileUploadSchema = z.object({
chatId: z.string().nonempty(), chatId: z.string().nonempty(),
uId: z.string().nonempty(), uId: z.string().nonempty(),
filename: 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(), expiredTime: z.coerce.date().optional(),
maxFileSize: z.number().positive().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<typeof ChatFileUploadSchema>; export type CheckChatFileKeys = z.input<typeof ChatFileUploadSchema>;
......
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<typeof UploadExtensionRuleSchema>;
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<typeof UploadPolicySchema>;
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<typeof UploadFileHintSchema>;
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<typeof UploadFileEvidenceSchema>;
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<typeof ResolvedUploadFileSchema>;
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<typeof UploadRejectReasonSchema>;
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<ReadonlySet<string>> = [
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<UploadExtensionRule>((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<string, UploadExtensionRule>();
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]
);
};
...@@ -4,10 +4,14 @@ import { ...@@ -4,10 +4,14 @@ import {
type FileExtensionKeyType type FileExtensionKeyType
} from '@fastgpt/global/core/app/constants'; } from '@fastgpt/global/core/app/constants';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; 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 type { UploadConstraintsInput, UploadConstraints } from '../contracts/type';
import { DEFAULT_CONTENT_TYPE, normalizeMimeType, resolveMimeType } from './mime'; import {
import path from 'node:path'; createUploadExtensionRulesFromFileSelectConfig,
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../uploadPolicy/utils';
import { createUploadPolicy } from '../uploadPolicy/service';
const uploadConfigKeys: FileExtensionKeyType[] = [ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectFile', 'canSelectFile',
...@@ -17,24 +21,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [ ...@@ -17,24 +21,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectCustomFileExtension' 'canSelectCustomFileExtension'
]; ];
export const normalizeFileExtension = (extension?: string) => { export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions };
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 avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']); export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']);
export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType); export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType);
...@@ -55,30 +42,35 @@ export const getAllowedExtensionsFromFileSelectConfig = (config?: AppFileSelectC ...@@ -55,30 +42,35 @@ export const getAllowedExtensionsFromFileSelectConfig = (config?: AppFileSelectC
return normalizeAllowedExtensions(extensions); return normalizeAllowedExtensions(extensions);
}; };
export const getUploadExtensionRulesFromFileSelectConfig =
createUploadExtensionRulesFromFileSelectConfig;
export const createUploadConstraints = ({ export const createUploadConstraints = ({
filename, filename,
uploadConstraints uploadConstraints,
contentType,
declaredExtension,
declaredFilename,
source,
size
}: { }: {
filename: string; filename: string;
uploadConstraints?: UploadConstraintsInput; uploadConstraints?: UploadConstraintsInput;
contentType?: string;
declaredExtension?: string;
declaredFilename?: string;
source?: 'local-file' | 'remote-url' | 'server-generated';
size?: number;
}): UploadConstraints => { }): UploadConstraints => {
const allowedExtensions = normalizeAllowedExtensions(uploadConstraints?.allowedExtensions); return createUploadPolicy({
const fileExtension = normalizeFileExtension(path.extname(filename)); hint: {
filename,
if ( ...(contentType ? { contentType } : {}),
allowedExtensions.length > 0 && ...(declaredExtension ? { declaredExtension } : {}),
(!fileExtension || !allowedExtensions.includes(fileExtension)) ...(declaredFilename ? { declaredFilename } : {}),
) { ...(source ? { source } : {}),
throw new Error(S3ErrEnum.invalidUploadFileType); ...(size ? { size } : {})
} },
uploadConstraints
const defaultContentType = normalizeMimeType( });
uploadConstraints?.defaultContentType || resolveMimeType([filename], DEFAULT_CONTENT_TYPE),
DEFAULT_CONTENT_TYPE
);
return {
defaultContentType,
...(allowedExtensions.length > 0 ? { allowedExtensions } : {})
};
}; };
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 type { UploadConstraints } from '../contracts/type';
import { DEFAULT_CONTENT_TYPE, resolveMimeType } from '../utils/mime'; import type { UploadFileHint, UploadPolicy } from '../uploadPolicy/type';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../utils/uploadConstraints'; import {
import { serviceEnv } from '../../../env'; createUploadPolicy,
detectUploadFileEvidence,
const defaultInspectBytes = 8192; getUploadInspectBytes as getPolicyUploadInspectBytes,
const officeZipInspectBytes = 64 * 1024; resolveUploadFile
const textLikeMimePrefixes = ['text/']; } from '../uploadPolicy/service';
const textLikeMimeSet = new Set([
'application/json', export const getUploadInspectBytes = (
'application/javascript', filenameOrParams?:
'application/xml', | string
'image/svg+xml' | {
]); hint?: UploadFileHint;
const officeZipFormats = [ policy?: UploadPolicy;
{ }
extension: '.docx', ) => {
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', if (typeof filenameOrParams === 'string' || filenameOrParams === undefined) {
markers: ['word/', 'word/document.xml'] return getPolicyUploadInspectBytes({
}, hint: filenameOrParams ? { filename: filenameOrParams } : undefined
{ });
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}`;
} }
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: * 校验上传文件内容并返回最终写入 metadata 的文件信息。
* 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(模糊检测)。 * 兼容旧调用方的 `filename + uploadConstraints` 入参;新短上传链路应优先传
* .m4a:lookup → audio/mp4(RFC),file-type(ftyp M4A)→ audio/x-m4a。 * `fileHint + uploadPolicy`,避免把客户端 hint、服务端策略和内容 evidence 混在一起。
* 比较前统一小写(忽略参数、大小写差异)。
*/ */
const MIME_EQUIVALENCE_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
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({ export async function validateUploadFile({
buffer, buffer,
filename, filename,
uploadConstraints uploadConstraints,
uploadPolicy,
fileHint
}: { }: {
buffer: Buffer; buffer: Buffer;
filename?: string; filename?: string;
uploadConstraints: UploadConstraints; uploadConstraints: UploadConstraints;
uploadPolicy?: UploadPolicy;
fileHint?: UploadFileHint;
}) { }) {
const normalizedFileName = decodeFileName(filename); const hint = fileHint || {
const extension = normalizeFileExtension(path.extname(normalizedFileName)); filename: filename || 'file'
const allowedExtensions = normalizeAllowedExtensions(uploadConstraints.allowedExtensions); };
const policy =
if (allowedExtensions.length > 0 && (!extension || !allowedExtensions.includes(extension))) { uploadPolicy ||
throw new Error(S3ErrEnum.invalidUploadFileType); createUploadPolicy({
} hint,
uploadConstraints
const expectedMime = resolveExpectedMime({ });
filename: normalizedFileName, const evidence = await detectUploadFileEvidence({ buffer });
extension,
uploadConstraints return resolveUploadFile({
}); hint,
policy,
if (serviceEnv.SKIP_FILE_TYPE_CHECK) { evidence
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 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);
} }
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
import type { AgentAskPayload } from './systemTool/ask'; import type { AgentAskPayload } from './systemTool/ask';
import type { AgentLoopUsage } from './usage'; import type { AgentLoopUsage } from './usage';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
export type AgentLoopToolResponseCompress = { export type AgentLoopToolResponseCompress = {
response: string; response: string;
...@@ -84,6 +85,7 @@ export type AgentLoopEvent = ...@@ -84,6 +85,7 @@ export type AgentLoopEvent =
errorMessage?: string; errorMessage?: string;
seconds: number; seconds: number;
usages?: AgentLoopUsage[]; usages?: AgentLoopUsage[];
fileRefs?: SandboxFileRef[];
toolResponseCompress?: AgentLoopToolResponseCompress; toolResponseCompress?: AgentLoopToolResponseCompress;
/** executeTool 返回的 opaque metadata,agent-loop 不解释其业务结构。 */ /** executeTool 返回的 opaque metadata,agent-loop 不解释其业务结构。 */
metadata?: unknown; metadata?: unknown;
......
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
import type { SandboxClient } from '../../../sandbox/interface/runtime'; import type { SandboxClient } from '../../../sandbox/interface/runtime';
import type { AgentLoopDatasetSearchExecutor } from './systemTool/datasetSearch'; import type { AgentLoopDatasetSearchExecutor } from './systemTool/datasetSearch';
import type { AgentLoopUsage } from './usage'; import type { AgentLoopUsage } from './usage';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
export type AgentLoopToolCatalog = { export type AgentLoopToolCatalog = {
runtimeTools: ChatCompletionTool[]; runtimeTools: ChatCompletionTool[];
...@@ -30,6 +31,7 @@ export type AgentLoopToolExecutionResult<TChildrenResponse = unknown> = { ...@@ -30,6 +31,7 @@ export type AgentLoopToolExecutionResult<TChildrenResponse = unknown> = {
stop?: boolean; stop?: boolean;
skipResponseCompress?: boolean; skipResponseCompress?: boolean;
errorMessage?: string; errorMessage?: string;
fileRefs?: SandboxFileRef[];
/** 由调用方透传并在 agent-loop 外部解释的工具运行元数据。 */ /** 由调用方透传并在 agent-loop 外部解释的工具运行元数据。 */
metadata?: unknown; metadata?: unknown;
}; };
......
...@@ -23,6 +23,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; ...@@ -23,6 +23,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import { batchRun } from '@fastgpt/global/common/system/utils'; import { batchRun } from '@fastgpt/global/common/system/utils';
import { normalizeToolResponseContent } from '@fastgpt/global/core/ai/llm/utils'; import { normalizeToolResponseContent } from '@fastgpt/global/core/ai/llm/utils';
import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
type RunAgentCallProps<TChildrenResponse = unknown> = { type RunAgentCallProps<TChildrenResponse = unknown> = {
maxRunAgentTimes: number; maxRunAgentTimes: number;
...@@ -87,6 +88,7 @@ type RunAgentCallProps<TChildrenResponse = unknown> = { ...@@ -87,6 +88,7 @@ type RunAgentCallProps<TChildrenResponse = unknown> = {
errorMessage?: string; errorMessage?: string;
seconds: number; seconds: number;
usages?: AgentLoopUsage[]; usages?: AgentLoopUsage[];
fileRefs?: SandboxFileRef[];
metadata?: unknown; metadata?: unknown;
toolResponseCompress?: { toolResponseCompress?: {
response: string; response: string;
...@@ -290,6 +292,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -290,6 +292,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({
interactive, interactive,
stop, stop,
errorMessage, errorMessage,
fileRefs,
metadata metadata
} = await (async () => { } = await (async () => {
try { try {
...@@ -317,6 +320,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -317,6 +320,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({
errorMessage: interactiveToolErrorMessage || errorMessage, errorMessage: interactiveToolErrorMessage || errorMessage,
seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2), seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2),
usages: toolUsages, usages: toolUsages,
fileRefs,
metadata metadata
}); });
...@@ -526,6 +530,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -526,6 +530,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({
stop: stopLoop, stop: stopLoop,
skipResponseCompress, skipResponseCompress,
errorMessage, errorMessage,
fileRefs,
metadata metadata
} = await (async () => { } = await (async () => {
try { try {
...@@ -589,6 +594,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -589,6 +594,7 @@ export const runAgentLoop = async <TChildrenResponse = unknown>({
: {}), : {}),
seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2), seconds: +((Date.now() - toolStartTime) / 1000).toFixed(2),
usages: [...toolUsages, ...(toolResponseCompress ? [toolResponseCompress.usage] : [])], usages: [...toolUsages, ...(toolResponseCompress ? [toolResponseCompress.usage] : [])],
fileRefs,
toolResponseCompress, toolResponseCompress,
metadata metadata
}); });
......
...@@ -353,6 +353,7 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({ ...@@ -353,6 +353,7 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({
seconds, seconds,
errorMessage, errorMessage,
usages, usages,
fileRefs,
toolResponseCompress, toolResponseCompress,
metadata metadata
}) => { }) => {
...@@ -368,6 +369,7 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({ ...@@ -368,6 +369,7 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({
seconds, seconds,
errorMessage, errorMessage,
usages, usages,
fileRefs,
toolResponseCompress, toolResponseCompress,
metadata metadata
}); });
...@@ -458,7 +460,8 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({ ...@@ -458,7 +460,8 @@ export const runFastAgentMainLoop = async <TChildrenResponse = unknown>({
}); });
return createToolResponse(sandboxResult.response, { return createToolResponse(sandboxResult.response, {
skipResponseCompress: true, skipResponseCompress: true,
errorMessage: sandboxResult.success ? undefined : sandboxResult.response errorMessage: sandboxResult.success ? undefined : sandboxResult.response,
fileRefs: sandboxResult.fileRefs
}); });
} }
......
...@@ -305,6 +305,7 @@ export const runPiAgentLoop = async <TChildrenResponse = unknown>({ ...@@ -305,6 +305,7 @@ export const runPiAgentLoop = async <TChildrenResponse = unknown>({
assistantMessages: result.assistantMessages, assistantMessages: result.assistantMessages,
usages: result.usages, usages: result.usages,
errorMessage: result.errorMessage, errorMessage: result.errorMessage,
fileRefs: result.fileRefs,
metadata: result.metadata, metadata: result.metadata,
seconds: +((Date.now() - startedAt) / 1000).toFixed(2) seconds: +((Date.now() - startedAt) / 1000).toFixed(2)
}); });
......
...@@ -33,6 +33,7 @@ import { ...@@ -33,6 +33,7 @@ import {
import { runSandboxTools } from '../../../../../sandbox/interface/toolCall'; import { runSandboxTools } from '../../../../../sandbox/interface/toolCall';
import { createToolCall, normalizeToolArgs, stringifyJson } from '../message'; import { createToolCall, normalizeToolArgs, stringifyJson } from '../message';
import { getPiAgentRuntimeTools } from './catalog'; import { getPiAgentRuntimeTools } from './catalog';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
type PlanOperationEvent = Extract<AgentLoopEvent, { type: 'plan_operation' }>; type PlanOperationEvent = Extract<AgentLoopEvent, { type: 'plan_operation' }>;
...@@ -104,6 +105,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({ ...@@ -104,6 +105,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({
interactive?: TChildrenResponse; interactive?: TChildrenResponse;
stop?: boolean; stop?: boolean;
errorMessage?: string; errorMessage?: string;
fileRefs?: SandboxFileRef[];
metadata?: unknown; metadata?: unknown;
}>; }>;
}) => { }) => {
...@@ -131,6 +133,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({ ...@@ -131,6 +133,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({
assistantMessages, assistantMessages,
usages, usages,
errorMessage: result.errorMessage, errorMessage: result.errorMessage,
fileRefs: result.fileRefs,
metadata: result.metadata, metadata: result.metadata,
seconds: +((Date.now() - startedAt) / 1000).toFixed(2) seconds: +((Date.now() - startedAt) / 1000).toFixed(2)
}); });
...@@ -293,6 +296,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({ ...@@ -293,6 +296,7 @@ export const buildPiAgentTools = async <TChildrenResponse = unknown>({
response: sandboxResult.response, response: sandboxResult.response,
assistantMessages: [], assistantMessages: [],
usages: [], usages: [],
fileRefs: sandboxResult.fileRefs,
errorMessage: sandboxResult.success ? undefined : sandboxResult.response errorMessage: sandboxResult.success ? undefined : sandboxResult.response
}; };
} }
......
...@@ -44,15 +44,20 @@ export const sandboxGetFileUrlTool = defineTool({ ...@@ -44,15 +44,20 @@ export const sandboxGetFileUrlTool = defineTool({
const { url: fileUrl } = await chatBucket.createGetChatFileURL({ const { url: fileUrl } = await chatBucket.createGetChatFileURL({
key, key,
expiredHours: 2, expiredHours: 2,
external: true, external: true
mode: 'presigned'
}); });
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)
};
} }
}); });
......
...@@ -22,6 +22,7 @@ import { getSandboxRuntimeProfile } from '../../infrastructure/provider/runtimeP ...@@ -22,6 +22,7 @@ import { getSandboxRuntimeProfile } from '../../infrastructure/provider/runtimeP
import { preparePackageMirrors, prepareSandbox } from '../runtime/prepare'; import { preparePackageMirrors, prepareSandbox } from '../runtime/prepare';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getRunningSandboxId } from '../../utils/id'; import { getRunningSandboxId } from '../../utils/id';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
const ToolMap = { const ToolMap = {
...editFileToolMap, ...editFileToolMap,
...@@ -38,6 +39,7 @@ export type SandboxToolCallResult = { ...@@ -38,6 +39,7 @@ export type SandboxToolCallResult = {
success: boolean; success: boolean;
input: Record<string, any>; input: Record<string, any>;
response: string; response: string;
fileRefs?: SandboxFileRef[];
durationSeconds: number; durationSeconds: number;
}; };
...@@ -89,6 +91,7 @@ export const runSandboxTools = async ({ ...@@ -89,6 +91,7 @@ export const runSandboxTools = async ({
success: true, success: true,
input: parsedArgs.data, input: parsedArgs.data,
response: result.response, response: result.response,
...(result.fileRefs?.length ? { fileRefs: result.fileRefs } : {}),
durationSeconds: getDuration() durationSeconds: getDuration()
}; };
}; };
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
*/ */
import type { z } from 'zod'; import type { z } from 'zod';
import type { SandboxClient } from '../runtime/client'; import type { SandboxClient } from '../runtime/client';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
type ToolExecuteContext<P> = { type ToolExecuteContext<P> = {
sandboxInstance: SandboxClient; sandboxInstance: SandboxClient;
...@@ -18,7 +19,9 @@ type ToolExecuteContext<P> = { ...@@ -18,7 +19,9 @@ type ToolExecuteContext<P> = {
*/ */
export type ToolDefinition<S extends z.ZodTypeAny = z.ZodTypeAny> = { export type ToolDefinition<S extends z.ZodTypeAny = z.ZodTypeAny> = {
zodSchema: S; zodSchema: S;
execute: (ctx: ToolExecuteContext<z.infer<S>>) => Promise<{ response: string }>; execute: (
ctx: ToolExecuteContext<z.infer<S>>
) => Promise<{ response: string; fileRefs?: SandboxFileRef[] }>;
}; };
/** /**
......
...@@ -42,6 +42,7 @@ import { ...@@ -42,6 +42,7 @@ import {
stripUserContentFileUrls stripUserContentFileUrls
} from './utils/prepare'; } from './utils/prepare';
import { buildChatSourceQuery, buildChatSourceWriteFields, type ChatSourceParams } from './source'; import { buildChatSourceQuery, buildChatSourceWriteFields, type ChatSourceParams } from './source';
import { isChatFileS3KeyForChat } from '../../common/s3/sources/chat/key';
const logger = getLogger(LogCategories.MODULE.CHAT); const logger = getLogger(LogCategories.MODULE.CHAT);
...@@ -85,8 +86,12 @@ export const persistChatFiles = async ({ ...@@ -85,8 +86,12 @@ export const persistChatFiles = async ({
contents, contents,
variables, variables,
variableList, variableList,
sourceType,
sourceId,
chatId,
session session
}: { }: ChatSourceParams & {
chatId: string;
contents: (UserChatItemType | AIChatItemType)[]; contents: (UserChatItemType | AIChatItemType)[];
variables?: Record<string, any>; variables?: Record<string, any>;
variableList?: VariableItemType[]; variableList?: VariableItemType[];
...@@ -105,7 +110,26 @@ export const persistChatFiles = async ({ ...@@ -105,7 +110,26 @@ export const persistChatFiles = async ({
keys.push(valueItem.file.key); 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) { if ('text' in valueItem && valueItem.text?.content) {
try { try {
const parsed = JSON.parse(valueItem.text.content); const parsed = JSON.parse(valueItem.text.content);
...@@ -380,6 +404,8 @@ export const finalizeChatRound = async (props: Props) => { ...@@ -380,6 +404,8 @@ export const finalizeChatRound = async (props: Props) => {
contents: processedContent, contents: processedContent,
variables, variables,
variableList, variableList,
...chatSource,
chatId,
session session
}); });
...@@ -616,6 +642,8 @@ export const pushChatRecords = async (props: Props) => { ...@@ -616,6 +642,8 @@ export const pushChatRecords = async (props: Props) => {
contents: processedContent, contents: processedContent,
variables, variables,
variableList, variableList,
...chatSource,
chatId,
session session
}); });
...@@ -1013,6 +1041,8 @@ export const updateInteractiveChat = async ({ ...@@ -1013,6 +1041,8 @@ export const updateInteractiveChat = async ({
contents: [userContent, aiContent], contents: [userContent, aiContent],
variables, variables,
variableList, variableList,
...chatSource,
chatId,
session session
}); });
}); });
......
...@@ -36,6 +36,56 @@ export const addPreviewUrlToChatItems = async ( ...@@ -36,6 +36,56 @@ export const addPreviewUrlToChatItems = async (
type: 'chatFlow' | 'workflowTool' type: 'chatFlow' | 'workflowTool'
) => { ) => {
const getPreviewUrl = createChatFilePreviewUrlGetter(); const getPreviewUrl = createChatFilePreviewUrlGetter();
const sandboxUrlReplacements = new Map<string, string>();
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[]) { async function addPreviewUrlToFileValue(files: ChatFileValueWithPreview[]) {
await Promise.all( await Promise.all(
...@@ -107,6 +157,9 @@ export const addPreviewUrlToChatItems = async ( ...@@ -107,6 +157,9 @@ export const addPreviewUrlToChatItems = async (
); );
} }
// 先刷新工具产出文件,确保后续历史上下文不会继续引用过期链接。
await refreshSandboxToolFiles();
// Presign file urls // Presign file urls
await Promise.all( await Promise.all(
histories.map(async (item) => { histories.map(async (item) => {
......
import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils'; import {
createS3KeysPreviewUrlMap,
getS3ObjectKeysFromMarkdownTexts,
replaceS3KeysWithPreviewUrlMap
} from '../../../core/dataset/utils';
import { addEndpointToImageUrl } from '../../../common/file/image/utils'; import { addEndpointToImageUrl } from '../../../common/file/image/utils';
import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { isS3ObjectKey } from '../../../common/s3/utils'; import { isS3ObjectKey } from '../../../common/s3/utils';
import { jwtSignS3DownloadToken } from '../../../common/s3/security/token';
import { S3Buckets } from '../../../common/s3/config/constants';
import { matchDatasetDataMarkdownImages } from './utils'; import { matchDatasetDataMarkdownImages } from './utils';
export const formatDatasetDataValue = ({ type FormatDatasetDataValueProps = {
q,
a,
imageId,
imageDescMap
}: {
q: string; q: string;
a?: string; a?: string;
imageId?: string; imageId?: string;
imageDescMap?: Record<string, string>; imageDescMap?: Record<string, string>;
}): { };
type FormattedDatasetDataValue = {
q: string; q: string;
a?: string; a?: string;
imagePreivewUrl?: string; imagePreivewUrl?: string;
} => { };
/**
* 整理数据块的图片描述和图片 endpoint,不签发访问链接。
* 搜索候选阶段使用该函数,确保去重、相似度和 token 过滤前没有 Mongo IO。
*/
export const formatDatasetDataTextValue = ({
q,
a,
imageDescMap
}: Pick<FormatDatasetDataValueProps, 'q' | 'a' | 'imageDescMap'>) => {
// Add image description to image markdown // Add image description to image markdown
if (imageDescMap) { if (imageDescMap) {
// Helper function to replace image markdown with description // Helper function to replace image markdown with description
...@@ -58,36 +67,70 @@ export const formatDatasetDataValue = ({ ...@@ -58,36 +67,70 @@ export const formatDatasetDataValue = ({
a = addEndpointToImageUrl(a); a = addEndpointToImageUrl(a);
} }
if (!imageId) { return { q, a };
};
/**
* 批量格式化数据块,并让 q、a 与 imageId 中的重复对象键共用一次短链签发。
*/
export const formatDatasetDataValues = async (
items: FormatDatasetDataValueProps[]
): Promise<FormattedDatasetDataValue[]> => {
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 { return {
q: replaceS3KeyToPreviewUrl(q, addDays(new Date(), 90)), q: `![${q.replaceAll('\n', '')}](${imagePreivewUrl})`,
a: a ? replaceS3KeyToPreviewUrl(a, addDays(new Date(), 90)) : undefined 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})`, export const formatDatasetDataValue = async (
a, item: FormatDatasetDataValueProps
imagePreivewUrl ): Promise<FormattedDatasetDataValue> => {
}; const [result] = await formatDatasetDataValues([item]);
return result!;
}; };
export const getFormatDatasetCiteList = (list: DatasetDataSchemaType[]) => { export const getFormatDatasetCiteList = async (list: DatasetDataSchemaType[]) => {
return list.map((item) => ({ const formattedValues = await formatDatasetDataValues(
_id: item._id, list.map((item) => ({
...formatDatasetDataValue({
q: item.q, q: item.q,
a: item.a, a: item.a,
imageId: item.imageId imageId: item.imageId
}), }))
);
return list.map((item, index) => ({
_id: item._id,
...formattedValues[index]!,
history: item.history, history: item.history,
updateTime: item.updateTime, updateTime: item.updateTime,
index: item.chunkIndex index: item.chunkIndex
......
...@@ -245,36 +245,39 @@ export const embeddingRecall = async ({ ...@@ -245,36 +245,39 @@ export const embeddingRecall = async ({
image: [] image: []
}; };
recallResults.forEach((recallResult, taskIndex) => { for (const [taskIndex, recallResult] of recallResults.entries()) {
const task = tasks[taskIndex]; const task = tasks[taskIndex];
const set = new Set<string>(); const set = new Set<string>();
const list = recallResult.results const list = (
.map((item, index) => { await Promise.all(
const collection = collectionMaps.get(String(item.collectionId)); recallResult.results.map((item, index) => {
if (!collection) { const collection = collectionMaps.get(String(item.collectionId));
logger.warn('Dataset collection not found during recall', { if (!collection) {
collectionId: item.collectionId, logger.warn('Dataset collection not found during recall', {
dataId: item.id collectionId: item.collectionId,
}); dataId: item.id
return; });
} return;
}
const data = dataMaps.get(String(item.id?.trim())); const data = dataMaps.get(String(item.id?.trim()));
if (!data) { if (!data) {
logger.warn('Dataset data not found during recall', { logger.warn('Dataset data not found during recall', {
dataId: item.id, dataId: item.id,
collectionId: item.collectionId collectionId: item.collectionId
}); });
return; return;
} }
return buildSearchResultItem({ return buildSearchResultItem({
data, data,
collection, collection,
score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }] score: [{ type: SearchScoreTypeEnum.embedding, value: item?.score || 0, index }]
}); });
}) })
)
)
.filter((item) => { .filter((item) => {
if (!item) return false; if (!item) return false;
if (set.has(item.id)) return false; if (set.has(item.id)) return false;
...@@ -289,7 +292,7 @@ export const embeddingRecall = async ({ ...@@ -289,7 +292,7 @@ export const embeddingRecall = async ({
}) as SearchDataResponseItemType[]; }) as SearchDataResponseItemType[];
groupedRecallLists[task.source].push(list); groupedRecallLists[task.source].push(list);
}); }
return { return {
textEmbeddingRecallResults: concatRecallLists(groupedRecallLists.text, limit), textEmbeddingRecallResults: concatRecallLists(groupedRecallLists.text, limit),
......
...@@ -159,41 +159,44 @@ export const fullTextRecall = async ({ ...@@ -159,41 +159,44 @@ export const fullTextRecall = async ({
imageCaption: [] imageCaption: []
}; };
recallResults.forEach((recallResult, taskIndex) => { for (const [taskIndex, recallResult] of recallResults.entries()) {
const task = queryTasks[taskIndex]; const task = queryTasks[taskIndex];
const list = recallResult const list = (
.map((item, index) => { await Promise.all(
const collection = collectionMaps.get(String(item.collectionId)); recallResult.map((item, index) => {
if (!collection) { const collection = collectionMaps.get(String(item.collectionId));
logger.warn('Dataset collection not found during full-text recall', { if (!collection) {
collectionId: item.collectionId, logger.warn('Dataset collection not found during full-text recall', {
dataId: item.dataId collectionId: item.collectionId,
}); dataId: item.dataId
return; });
} return;
}
const data = dataMaps.get(String(item.dataId)); const data = dataMaps.get(String(item.dataId));
if (!data) { if (!data) {
logger.warn('Dataset data not found during full-text recall', { logger.warn('Dataset data not found during full-text recall', {
dataId: item.dataId, dataId: item.dataId,
collectionId: item.collectionId collectionId: item.collectionId
}); });
return; return;
} }
return buildSearchResultItem({ return buildSearchResultItem({
data, data,
collection, collection,
includeIndexes: true, includeIndexes: true,
score: [ score: [
{ {
type: SearchScoreTypeEnum.fullText, type: SearchScoreTypeEnum.fullText,
value: item.score || 0, value: item.score || 0,
index index
} }
] ]
}); });
}) })
)
)
.filter((item) => { .filter((item) => {
if (!item) return false; if (!item) return false;
return true; return true;
...@@ -206,7 +209,7 @@ export const fullTextRecall = async ({ ...@@ -206,7 +209,7 @@ export const fullTextRecall = async ({
}) as SearchDataResponseItemType[]; }) as SearchDataResponseItemType[];
groupedRecallLists[task.source].push(list); groupedRecallLists[task.source].push(list);
}); }
return { return {
textFullTextRecallResults: concatRecallLists(groupedRecallLists.text, limit), textFullTextRecallResults: concatRecallLists(groupedRecallLists.text, limit),
......
...@@ -2,11 +2,10 @@ import { ...@@ -2,11 +2,10 @@ import {
DatasetSearchModeEnum, DatasetSearchModeEnum,
DatasetSearchModeMap DatasetSearchModeMap
} from '@fastgpt/global/core/dataset/constants'; } from '@fastgpt/global/core/dataset/constants';
import { addDays } from 'date-fns';
import { getDefaultRerankModel } from '../../../ai/model'; import { getDefaultRerankModel } from '../../../ai/model';
import { pushTrack } from '../../../../common/middle/tracks/utils'; import { pushTrack } from '../../../../common/middle/tracks/utils';
import { replaceS3KeyToPreviewUrl } from '../../../../core/dataset/utils';
import type { SearchDatasetDataProps, SearchDatasetDataResponse } from '../type'; import type { SearchDatasetDataProps, SearchDatasetDataResponse } from '../type';
import { formatDatasetDataValues } from '../../data/controller';
import { getImageCaptionQueries } from './imageCaption'; import { getImageCaptionQueries } from './imageCaption';
import { multiQueryRecall } from './multiQueryRecall'; import { multiQueryRecall } from './multiQueryRecall';
import { reRankSearchResults } from './rerank'; import { reRankSearchResults } from './rerank';
...@@ -169,11 +168,18 @@ export async function searchDatasetData( ...@@ -169,11 +168,18 @@ export async function searchDatasetData(
}); });
const filterMaxTokensResult = await filterDatasetDataByMaxTokens(scoreFilter, maxTokens); const filterMaxTokensResult = await filterDatasetDataByMaxTokens(scoreFilter, maxTokens);
// Step 8: 返回前把 q 中的内部图片 key 转为可预览 URL。 // Step 8: 返回前一次收集最终结果中的 q、a、imageId,并批量签发唯一对象 key。
// 只在最终结果处理,避免中间召回和去重阶段混入带过期时间的动态 URL。 // 被前面过滤掉的候选不会产生 alias 查询;最终输出也不暴露仅供格式化使用的 imageId。
const finalResult = filterMaxTokensResult.map((item) => { const formattedValues = await formatDatasetDataValues(
item.q = replaceS3KeyToPreviewUrl(item.q, addDays(new Date(), 90)); filterMaxTokensResult.map(({ q, a, imageId }) => ({ q, a, imageId }))
return item; );
const finalResult = filterMaxTokensResult.map((item, index) => {
const result = { ...item };
delete result.imageId;
return {
...result,
...formattedValues[index]!
};
}); });
pushTrack.datasetSearch({ datasetIds, teamId }); pushTrack.datasetSearch({ datasetIds, teamId });
......
...@@ -7,7 +7,7 @@ import type { ...@@ -7,7 +7,7 @@ import type {
DatasetDataSchemaType, DatasetDataSchemaType,
SearchDataResponseItemType SearchDataResponseItemType
} from '@fastgpt/global/core/dataset/type'; } from '@fastgpt/global/core/dataset/type';
import { formatDatasetDataValue } from '../../data/controller'; import { formatDatasetDataTextValue } from '../../data/controller';
/** /**
* 把召回命中的 data 与 collection 统一整理成搜索结果。 * 把召回命中的 data 与 collection 统一整理成搜索结果。
...@@ -23,22 +23,26 @@ export const buildSearchResultItem = ({ ...@@ -23,22 +23,26 @@ export const buildSearchResultItem = ({
collection: DatasetCollectionSchemaType; collection: DatasetCollectionSchemaType;
score: SearchDataResponseItemType['score']; score: SearchDataResponseItemType['score'];
includeIndexes?: boolean; includeIndexes?: boolean;
}): SearchDataResponseItemType => ({ }): SearchDataResponseItemType => {
id: String(data._id), const formattedValue = formatDatasetDataTextValue({
updateTime: data.updateTime,
...formatDatasetDataValue({
q: data.q, q: data.q,
a: data.a, a: data.a,
imageId: data.imageId,
imageDescMap: data.imageDescMap imageDescMap: data.imageDescMap
}), });
chunkIndex: data.chunkIndex,
...(includeIndexes ? { indexes: data.indexes } : {}), return {
datasetId: String(data.datasetId), id: String(data._id),
collectionId: String(data.collectionId), updateTime: data.updateTime,
...getCollectionSourceData(collection), ...formattedValue,
score 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) => { export const concatRecallLists = (lists: SearchDataResponseItemType[][], limit: number) => {
return datasetSearchResultConcat(lists.map((list) => ({ weight: 1, list }))).slice(0, limit); return datasetSearchResultConcat(lists.map((list) => ({ weight: 1, list }))).slice(0, limit);
......
...@@ -2,13 +2,118 @@ import { authDatasetByTmbId } from '../../support/permission/dataset/auth'; ...@@ -2,13 +2,118 @@ import { authDatasetByTmbId } from '../../support/permission/dataset/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { S3Sources } from '../../common/s3/contracts/type'; import { S3Sources } from '../../common/s3/contracts/type';
import { isS3ObjectKey } from '../../common/s3/utils'; import { isS3ObjectKey } from '../../common/s3/utils';
import { jwtSignS3DownloadToken } from '../../common/s3/security/token';
import { getLogger, LogCategories } from '../../common/logger'; import { getLogger, LogCategories } from '../../common/logger';
import { S3Buckets } from '../../common/s3/config/constants'; import { S3Buckets } from '../../common/s3/config/constants';
import { getVlmModelList, isImageEmbeddingModel } from '../ai/model'; import { getVlmModelList, isImageEmbeddingModel } from '../ai/model';
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; 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 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<string | undefined>) => {
const objectKeys = new Set<string>();
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<string, string>();
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<string, string>
) => {
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: 需要优化成批量获取权限 // TODO: 需要优化成批量获取权限
export const filterDatasetsByTmbId = async ({ export const filterDatasetsByTmbId = async ({
...@@ -39,7 +144,7 @@ export const filterDatasetsByTmbId = async ({ ...@@ -39,7 +144,7 @@ export const filterDatasetsByTmbId = async ({
}; };
/** /**
* 替换数据集引用 markdown 文本中的图片链接格式的 S3 对象键为 JWT 签名后的 URL * 替换数据集引用 markdown 文本中的图片链接格式的 S3 对象键为短访问 URL。
* *
* @param documentQuoteText 数据集引用文本 * @param documentQuoteText 数据集引用文本
* @param expiredTime 过期时间 * @param expiredTime 过期时间
...@@ -51,39 +156,12 @@ export const filterDatasetsByTmbId = async ({ ...@@ -51,39 +156,12 @@ export const filterDatasetsByTmbId = async ({
* const datasetQuoteText = '![image.png](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/aZos7D-214afce5-4d42-4356-9e05-8164d51c59ae.png)'; * const datasetQuoteText = '![image.png](dataset/68fee42e1d416bb5ddc85b19/6901c3071ba2bea567e8d8db/aZos7D-214afce5-4d42-4356-9e05-8164d51c59ae.png)';
* const replacedText = await replaceS3KeyToPreviewUrl(datasetQuoteText, addDays(new Date(), 90)) * const replacedText = await replaceS3KeyToPreviewUrl(datasetQuoteText, addDays(new Date(), 90))
* console.log(replacedText) * 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) { export async function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime: Date) {
if (!documentQuoteText || typeof documentQuoteText !== 'string') const [content] = await replaceS3KeysToPreviewUrls([documentQuoteText], expiredTime);
return documentQuoteText as string; return content!;
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;
} }
const getAvailableDatasetVlmModel = (vlmModel?: string) => { const getAvailableDatasetVlmModel = (vlmModel?: string) => {
......
...@@ -356,12 +356,14 @@ export const createAgentLoopCoreAssistantEventCollector = ({ ...@@ -356,12 +356,14 @@ export const createAgentLoopCoreAssistantEventCollector = ({
toolAvatar: toolInfo?.avatar || '', toolAvatar: toolInfo?.avatar || '',
functionName, functionName,
params: event.call.function.arguments ?? '', params: event.call.function.arguments ?? '',
response: event.response response: event.response,
...(event.fileRefs?.length ? { fileRefs: event.fileRefs } : {})
}); });
} else { } else {
updateToolResponse(event.call.id, (tool) => ({ updateToolResponse(event.call.id, (tool) => ({
...tool, ...tool,
response: appendUniqueDelta(tool.response, event.response) response: appendUniqueDelta(tool.response, event.response),
...(event.fileRefs?.length ? { fileRefs: event.fileRefs } : {})
})); }));
} }
......
...@@ -10,6 +10,7 @@ import type { ...@@ -10,6 +10,7 @@ import type {
AgentLoopToolExecutionResult AgentLoopToolExecutionResult
} from '../../../../../ai/llm/agentLoop/interface'; } from '../../../../../ai/llm/agentLoop/interface';
import type { AgentLoopCoreSystemToolInfo } from './toolInfo'; import type { AgentLoopCoreSystemToolInfo } from './toolInfo';
import type { SandboxFileRef } from '@fastgpt/global/core/ai/sandbox/type';
export type AgentLoopCoreUserToolInfo<TRaw = unknown> = { export type AgentLoopCoreUserToolInfo<TRaw = unknown> = {
type: 'user'; type: 'user';
...@@ -34,6 +35,7 @@ export type AgentLoopCoreToolRunResult<TChildrenResponse = unknown> = { ...@@ -34,6 +35,7 @@ export type AgentLoopCoreToolRunResult<TChildrenResponse = unknown> = {
interactive?: TChildrenResponse; interactive?: TChildrenResponse;
stop?: boolean; stop?: boolean;
errorMessage?: string; errorMessage?: string;
fileRefs?: SandboxFileRef[];
nodeResponse?: ChatHistoryItemResType; nodeResponse?: ChatHistoryItemResType;
}; };
...@@ -61,6 +63,7 @@ export const normalizeAgentLoopCoreToolRunResult = <TChildrenResponse = unknown> ...@@ -61,6 +63,7 @@ export const normalizeAgentLoopCoreToolRunResult = <TChildrenResponse = unknown>
interactive, interactive,
stop = false, stop = false,
errorMessage, errorMessage,
fileRefs,
nodeResponse nodeResponse
}: AgentLoopCoreToolRunResult<TChildrenResponse>): AgentLoopToolExecutionResult<TChildrenResponse> => { }: AgentLoopCoreToolRunResult<TChildrenResponse>): AgentLoopToolExecutionResult<TChildrenResponse> => {
return { return {
...@@ -70,6 +73,7 @@ export const normalizeAgentLoopCoreToolRunResult = <TChildrenResponse = unknown> ...@@ -70,6 +73,7 @@ export const normalizeAgentLoopCoreToolRunResult = <TChildrenResponse = unknown>
interactive, interactive,
stop, stop,
errorMessage, errorMessage,
fileRefs,
metadata: nodeResponse metadata: nodeResponse
}; };
}; };
...@@ -72,6 +72,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp ...@@ -72,6 +72,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
} }
try { try {
console.dir(modelConstantsData, { depth: null });
aiChatVision = modelConstantsData.vision && aiChatVision; aiChatVision = modelConstantsData.vision && aiChatVision;
aiChatAudio = modelConstantsData.audio && aiChatAudio; aiChatAudio = modelConstantsData.audio && aiChatAudio;
aiChatVideo = modelConstantsData.video && aiChatVideo; aiChatVideo = modelConstantsData.video && aiChatVideo;
......
export { dispatchChatCompletion } from './dispatchChatCompletion'; export { dispatchChatCompletion } from './dispatchChatCompletion';
export { getAIChatFileContextConfig, rewriteChatMessagesWithFiles } from './fileContext'; export {
getAIChatFileContextConfig,
getInputFiles,
rewriteChatMessagesWithFiles
} from './fileContext';
export type { ChatMessageFileParser, ChatProps, ChatResponse } from './type'; export type { ChatMessageFileParser, ChatProps, ChatResponse } from './type';
...@@ -59,7 +59,7 @@ import { classifyEdgesByDFS, findSCCs, isNodeInCycle, getEdgeType } from '../uti ...@@ -59,7 +59,7 @@ import { classifyEdgesByDFS, findSCCs, isNodeInCycle, getEdgeType } from '../uti
import { observeWorkflowRun, observeWorkflowStep } from '../metrics'; import { observeWorkflowRun, observeWorkflowStep } from '../metrics';
import { withActiveSpan } from '../../../common/tracing'; import { withActiveSpan } from '../../../common/tracing';
import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus'; import { delAgentRuntimeStopSign, shouldWorkflowStop } from './workflowStatus';
import { runWithContext } from '../utils/context'; import { buildQueryUrlFileMap, buildQueryUrlTypeMap, runWithContext } from '../utils/context';
import { createClientAbortTracker } from './utils/clientAbort'; import { createClientAbortTracker } from './utils/clientAbort';
import type { IncomingMessage } from 'node:http'; import type { IncomingMessage } from 'node:http';
import type { WorkflowNodeResponseWriter } from '../../chat/nodeResponseStorage'; import type { WorkflowNodeResponseWriter } from '../../chat/nodeResponseStorage';
...@@ -205,6 +205,10 @@ export async function dispatchWorkFlow({ ...@@ -205,6 +205,10 @@ export async function dispatchWorkFlow({
const clientAbortTracker = const clientAbortTracker =
apiVersion === 'v1' ? createClientAbortTracker({ req: data.req, res }) : undefined; apiVersion === 'v1' ? createClientAbortTracker({ req: data.req, res }) : undefined;
// 私有文件已恢复为无后缀短链;进入节点调度前保留类型和原始文件名等媒体元数据。
const queryUrlTypeMap = buildQueryUrlTypeMap(query);
const queryUrlFileMap = buildQueryUrlFileMap(query);
const variableState = await WorkflowVariableState.create({ const variableState = await WorkflowVariableState.create({
timezone, timezone,
runningAppInfo, runningAppInfo,
...@@ -256,7 +260,8 @@ export async function dispatchWorkFlow({ ...@@ -256,7 +260,8 @@ export async function dispatchWorkFlow({
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
runWithContext( runWithContext(
{ {
queryUrlTypeMap: {}, queryUrlTypeMap,
queryUrlFileMap,
mcpClientMemory: {} mcpClientMemory: {}
}, },
(ctx) => { (ctx) => {
......
import type { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; 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 { parseUrlToChatFileType } from '../../chat/fileContext';
import { AsyncLocalStorage } from 'async_hooks'; import { AsyncLocalStorage } from 'async_hooks';
...@@ -6,6 +10,7 @@ import type { MCPClient } from '../../app/mcp'; ...@@ -6,6 +10,7 @@ import type { MCPClient } from '../../app/mcp';
type ContextType = { type ContextType = {
queryUrlTypeMap: Record<string, ChatFileTypeEnum>; queryUrlTypeMap: Record<string, ChatFileTypeEnum>;
queryUrlFileMap?: Record<string, UserChatItemFileItemType>;
mcpClientMemory: Record<string, MCPClient>; mcpClientMemory: Record<string, MCPClient>;
}; };
...@@ -32,9 +37,48 @@ export const updateWorkflowContextVal = (val: Partial<ContextType>) => { ...@@ -32,9 +37,48 @@ export const updateWorkflowContextVal = (val: Partial<ContextType>) => {
} }
}; };
/** 结合 workflow 运行态 URL 类型映射,将 URL 解析成 ChatBox 文件结构。 */ /**
export const parseUrlToFileType = (url: string) => * 从当前轮用户输入建立 URL 到聊天文件类型的映射。
parseUrlToChatFileType({ *
* 私有文件会在工作流启动前从稳定 key 恢复成无后缀短链,无法再依赖 URL 后缀推断媒体类型。
* 该映射保留前端已经确认的 image/audio/video/file 类型,供所有下游节点统一解析 userFiles。
*/
export const buildQueryUrlTypeMap = (query: UserChatItemValueItemType[]) =>
query.reduce<Record<string, ChatFileTypeEnum>>((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<Record<string, UserChatItemFileItemType>>((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, url,
urlTypeMap: getWorkflowContext()?.queryUrlTypeMap urlTypeMap: context?.queryUrlTypeMap
}); });
};
...@@ -172,6 +172,16 @@ export const serviceEnv = createEnv({ ...@@ -172,6 +172,16 @@ export const serviceEnv = createEnv({
STORAGE_REGION: z.string().default('us-east-1'), STORAGE_REGION: z.string().default('us-east-1'),
STORAGE_EXTERNAL_ENDPOINT: UrlSchema.optional(), STORAGE_EXTERNAL_ENDPOINT: UrlSchema.optional(),
STORAGE_S3_CDN_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_S3_ENDPOINT: UrlSchema.default('http://localhost:9000'),
STORAGE_PUBLIC_ACCESS_EXTRA_SUB_PATH: z.string().optional(), STORAGE_PUBLIC_ACCESS_EXTRA_SUB_PATH: z.string().optional(),
STORAGE_ACCESS_KEY_ID: z.string().default('minioadmin'), STORAGE_ACCESS_KEY_ID: z.string().default('minioadmin'),
...@@ -215,6 +225,10 @@ export const serviceEnv = createEnv({ ...@@ -215,6 +225,10 @@ export const serviceEnv = createEnv({
description: description:
'文件域名(也指向 FastGPT 服务);如需更高安全性可独立分配域名,避免高危文件读取到主域名内容' '文件域名(也指向 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(''), NEXT_PUBLIC_BASE_URL: z.string().default(''),
//==================== 安全配置 ==================== //==================== 安全配置 ====================
......
import jwt from 'jsonwebtoken'; 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 { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { import {
PluginPermissionEnum, PluginPermissionEnum,
type PluginPermissionEnumType type PluginPermissionEnumType
} from '@fastgpt/global/sdk/fastgpt-plugin'; } 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 { 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 { getS3ChatSource } from '../../common/s3/sources/chat';
import { removeS3TTL } from '../../common/s3/utils';
import { serviceEnv } from '../../env'; import { serviceEnv } from '../../env';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getGroupsByTmbId } from '../permission/memberGroup/controllers'; import { getGroupsByTmbId } from '../permission/memberGroup/controllers';
import { getOrgsByTmbId } from '../permission/org/controllers'; import { getOrgsByTmbId } from '../permission/org/controllers';
import { MongoOrgModel } from '../permission/org/orgSchema'; import { MongoOrgModel } from '../permission/org/orgSchema';
...@@ -68,7 +74,7 @@ export class InvokeProcessor { ...@@ -68,7 +74,7 @@ export class InvokeProcessor {
return InvokeSessionSchema.parse(this._session); return InvokeSessionSchema.parse(this._session);
} }
async handleFileUpload(params: InvokeFileUploadType): Promise<{ url: string }> { async handleFileUpload(params: InvokeFileUploadType): Promise<InvokeFileUploadResponseType> {
this.assertPermission(PluginPermissionEnum['file-upload:allow']); this.assertPermission(PluginPermissionEnum['file-upload:allow']);
const { appId, chatId, uId } = InvokeSessionSchema.parse(this._session); const { appId, chatId, uId } = InvokeSessionSchema.parse(this._session);
...@@ -85,8 +91,27 @@ export class InvokeProcessor { ...@@ -85,8 +91,27 @@ export class InvokeProcessor {
expiredTime: addHours(new Date(), 365) 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 { return {
url: result.accessUrl.url url: result.accessUrl.url,
key: result.key,
filename,
contentType,
type
}; };
} }
......
...@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; ...@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createVitestStorageMock } from '@fastgpt-sdk/storage'; import { createVitestStorageMock } from '@fastgpt-sdk/storage';
const originalEnv = { const originalEnv = {
STORAGE_VENDOR: process.env.STORAGE_VENDOR,
STORAGE_EXTERNAL_ENDPOINT: process.env.STORAGE_EXTERNAL_ENDPOINT, 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 () => { const loadConstants = async () => {
...@@ -14,31 +17,49 @@ const loadConstants = async () => { ...@@ -14,31 +17,49 @@ const loadConstants = async () => {
describe('s3 storage constants', () => { describe('s3 storage constants', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.stubEnv('STORAGE_VENDOR', undefined);
vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', undefined); vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', undefined);
vi.stubEnv('STORAGE_S3_CDN_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(() => { afterEach(() => {
vi.stubEnv('STORAGE_VENDOR', originalEnv.STORAGE_VENDOR);
vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', originalEnv.STORAGE_EXTERNAL_ENDPOINT); vi.stubEnv('STORAGE_EXTERNAL_ENDPOINT', originalEnv.STORAGE_EXTERNAL_ENDPOINT);
vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', originalEnv.STORAGE_S3_CDN_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(); vi.restoreAllMocks();
}); });
it('keeps proxy download mode when no external or CDN endpoint is configured', async () => { it('defaults to short proxy download mode when no explicit mode is configured', async () => {
const { storageDownloadMode, replaceS3UrlWithCdnEndpoint } = await loadConstants(); const {
storageDownloadUrlMode,
expect(storageDownloadMode).toBe('proxy'); 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( expect(replaceS3UrlWithCdnEndpoint('https://s3.example.com/bucket/file.png')).toBe(
'https://s3.example.com/bucket/file.png' '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'); 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( expect(
replaceS3UrlWithCdnEndpoint( replaceS3UrlWithCdnEndpoint(
'https://fastgpt-private.s3.example.com/chat/app/file.png?X-Amz-Signature=abc#preview' 'https://fastgpt-private.s3.example.com/chat/app/file.png?X-Amz-Signature=abc#preview'
...@@ -46,6 +67,32 @@ describe('s3 storage constants', () => { ...@@ -46,6 +67,32 @@ describe('s3 storage constants', () => {
).toBe('https://cdn.example.com/files/chat/app/file.png?X-Amz-Signature=abc#preview'); ).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 () => { it('rewrites external presigned URLs from S3BaseBucket', async () => {
vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', 'https://cdn.example.com'); vi.stubEnv('STORAGE_S3_CDN_ENDPOINT', 'https://cdn.example.com');
...@@ -73,6 +120,33 @@ describe('s3 storage constants', () => { ...@@ -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 () => { it('passes response content type overrides into external presigned URLs', async () => {
const { S3BaseBucket } = await vi.importActual< const { S3BaseBucket } = await vi.importActual<
typeof import('@fastgpt/service/common/s3/buckets/base') typeof import('@fastgpt/service/common/s3/buckets/base')
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
isAuthorizedChatFileS3Key, isAuthorizedChatFileS3Key,
isChatFileS3KeyForChat,
parseChatFileS3Key parseChatFileS3Key
} from '@fastgpt/service/common/s3/sources/chat/key'; } from '@fastgpt/service/common/s3/sources/chat/key';
import { import {
...@@ -107,6 +108,22 @@ describe('authorized S3 object key helpers', () => { ...@@ -107,6 +108,22 @@ describe('authorized S3 object key helpers', () => {
chatId: 'chat-2' chatId: 'chat-2'
}) })
).toBe(false); ).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', () => { it('parses and authorizes dataset file keys by datasetId', () => {
......
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import jwt from 'jsonwebtoken';
const strongFileTokenKey = '1234567890abcdef1234567890abcdef'; const strongFileTokenKey = '1234567890abcdef1234567890abcdef';
const getExpiredTime = () => new Date(Date.now() + 5 * 60 * 1000);
const originalEnv = { const originalEnv = {
FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY, FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY,
FILE_DOMAIN: process.env.FILE_DOMAIN, FILE_DOMAIN: process.env.FILE_DOMAIN,
...@@ -10,10 +10,6 @@ const originalEnv = { ...@@ -10,10 +10,6 @@ const originalEnv = {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
}; };
const extractTokenFromUrl = (url: string) => {
return url.split('/').pop()?.split('?')[0] || '';
};
const loadTokenModule = async () => { const loadTokenModule = async () => {
vi.resetModules(); vi.resetModules();
vi.stubEnv('FILE_TOKEN_KEY', strongFileTokenKey); vi.stubEnv('FILE_TOKEN_KEY', strongFileTokenKey);
...@@ -35,50 +31,43 @@ describe('s3 token validation', () => { ...@@ -35,50 +31,43 @@ describe('s3 token validation', () => {
}); });
it('rejects upload tokens when verifying download tokens', async () => { it('rejects upload tokens when verifying download tokens', async () => {
const { jwtSignS3UploadToken, jwtVerifyS3DownloadToken } = await loadTokenModule(); const { jwtVerifyS3DownloadToken } = await loadTokenModule();
const token = extractTokenFromUrl( const token = jwt.sign(
jwtSignS3UploadToken({ {
objectKey: 'chat/appId/userId/chatId/file.txt', objectKey: 'chat/appId/userId/chatId/file.txt',
bucketName: 'fastgpt-private', bucketName: 'fastgpt-private',
expiredTime: getExpiredTime(),
maxSize: 1024, maxSize: 1024,
uploadConstraints: { uploadConstraints: {
defaultContentType: 'text/plain' defaultContentType: 'text/plain'
} },
}) type: 'upload'
},
strongFileTokenKey,
{ expiresIn: 300 }
); );
await expect(jwtVerifyS3DownloadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile); await expect(jwtVerifyS3DownloadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile);
}); });
it('rejects download tokens when verifying upload tokens', async () => { it('rejects download tokens when verifying upload tokens', async () => {
const { jwtSignS3DownloadToken, jwtVerifyS3UploadToken } = await loadTokenModule(); const { jwtVerifyS3UploadToken } = await loadTokenModule();
const token = extractTokenFromUrl( const token = jwt.sign(
jwtSignS3DownloadToken({ {
objectKey: 'dataset/datasetId/file.txt', objectKey: 'dataset/datasetId/file.txt',
bucketName: 'fastgpt-private', bucketName: 'fastgpt-private',
expiredTime: getExpiredTime(), type: 'download'
filename: 'file.txt' },
}) strongFileTokenKey,
{ expiresIn: 300 }
); );
await expect(jwtVerifyS3UploadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile); await expect(jwtVerifyS3UploadToken(token)).rejects.toBe(ERROR_ENUM.unAuthFile);
}); });
it('normalizes endpoint slashes when signing proxy download URLs', async () => { it('does not expose legacy JWT signing helpers', async () => {
vi.stubEnv('FILE_DOMAIN', 'https://files.example.com/'); const tokenModule = await loadTokenModule();
vi.stubEnv('FE_DOMAIN', undefined);
vi.stubEnv('NEXT_PUBLIC_BASE_URL', '/fastgpt');
const { jwtSignS3DownloadToken } = await loadTokenModule(); expect('jwtSignS3DownloadToken' in tokenModule).toBe(false);
const url = jwtSignS3DownloadToken({ expect('jwtSignS3UploadToken' in tokenModule).toBe(false);
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$/
);
}); });
}); });
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
createUploadConstraints, createUploadConstraints,
datasetAllowedExtensions, datasetAllowedExtensions,
getAllowedExtensionsFromFileSelectConfig, getAllowedExtensionsFromFileSelectConfig,
getUploadExtensionRulesFromFileSelectConfig,
normalizeAllowedExtensions, normalizeAllowedExtensions,
parseAllowedExtensions parseAllowedExtensions
} from '@fastgpt/service/common/s3/utils/uploadConstraints'; } from '@fastgpt/service/common/s3/utils/uploadConstraints';
...@@ -22,7 +23,7 @@ describe('parseAllowedExtensions', () => { ...@@ -22,7 +23,7 @@ describe('parseAllowedExtensions', () => {
describe('createUploadConstraints', () => { describe('createUploadConstraints', () => {
it('derives the default content type from the filename', () => { it('derives the default content type from the filename', () => {
expect(createUploadConstraints({ filename: 'demo.pdf' })).toEqual({ expect(createUploadConstraints({ filename: 'demo.pdf' })).toMatchObject({
defaultContentType: 'application/pdf' defaultContentType: 'application/pdf'
}); });
}); });
...@@ -35,12 +36,27 @@ describe('createUploadConstraints', () => { ...@@ -35,12 +36,27 @@ describe('createUploadConstraints', () => {
allowedExtensions: ['.png', '.jpg'] allowedExtensions: ['.png', '.jpg']
} }
}) })
).toEqual({ ).toMatchObject({
defaultContentType: 'image/png', defaultContentType: 'image/png',
allowedExtensions: ['.png', '.jpg'] 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', () => { it('rejects filenames outside the allowed extension list', () => {
expect(() => expect(() =>
createUploadConstraints({ createUploadConstraints({
...@@ -72,6 +88,49 @@ describe('getAllowedExtensionsFromFileSelectConfig', () => { ...@@ -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', () => { describe('preset extension lists', () => {
it('exposes avatar and dataset defaults', () => { it('exposes avatar and dataset defaults', () => {
expect(avatarAllowedExtensions).toEqual(['.jpg', '.jpeg', '.png']); expect(avatarAllowedExtensions).toEqual(['.jpg', '.jpeg', '.png']);
......
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);
});
});
...@@ -43,7 +43,7 @@ describe('validateUploadFile', () => { ...@@ -43,7 +43,7 @@ describe('validateUploadFile', () => {
defaultContentType: 'image/png' defaultContentType: 'image/png'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'demo.png', filename: 'demo.png',
contentType: 'image/png' contentType: 'image/png'
}); });
...@@ -61,7 +61,7 @@ describe('validateUploadFile', () => { ...@@ -61,7 +61,7 @@ describe('validateUploadFile', () => {
).rejects.toThrow('UploadFileTypeMismatch'); ).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( await expect(
validateUploadFile({ validateUploadFile({
buffer: pngBuffer, buffer: pngBuffer,
...@@ -71,10 +71,7 @@ describe('validateUploadFile', () => { ...@@ -71,10 +71,7 @@ describe('validateUploadFile', () => {
allowedExtensions: ['.jpg', '.jpeg', '.png'] allowedExtensions: ['.jpg', '.jpeg', '.png']
} }
}) })
).resolves.toEqual({ ).rejects.toThrow('UploadFileTypeMismatch');
filename: 'demo.png',
contentType: 'image/png'
});
}); });
it('accepts text-like files without binary signature', async () => { it('accepts text-like files without binary signature', async () => {
...@@ -86,7 +83,7 @@ describe('validateUploadFile', () => { ...@@ -86,7 +83,7 @@ describe('validateUploadFile', () => {
defaultContentType: 'application/json' defaultContentType: 'application/json'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'demo.json', filename: 'demo.json',
contentType: 'application/json' contentType: 'application/json'
}); });
...@@ -101,7 +98,7 @@ describe('validateUploadFile', () => { ...@@ -101,7 +98,7 @@ describe('validateUploadFile', () => {
defaultContentType: 'application/octet-stream' defaultContentType: 'application/octet-stream'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'hello world.txt', filename: 'hello world.txt',
contentType: 'text/plain' contentType: 'text/plain'
}); });
...@@ -116,7 +113,7 @@ describe('validateUploadFile', () => { ...@@ -116,7 +113,7 @@ describe('validateUploadFile', () => {
defaultContentType: 'application/octet-stream' defaultContentType: 'application/octet-stream'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'archive.custom', filename: 'archive.custom',
contentType: 'application/octet-stream' contentType: 'application/octet-stream'
}); });
...@@ -131,12 +128,184 @@ describe('validateUploadFile', () => { ...@@ -131,12 +128,184 @@ describe('validateUploadFile', () => {
defaultContentType: 'application/octet-stream' defaultContentType: 'application/octet-stream'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'README', filename: 'README',
contentType: 'application/octet-stream' 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 () => { it('rejects disallowed file extensions before content inspection', async () => {
await expect( await expect(
validateUploadFile({ validateUploadFile({
...@@ -160,7 +329,20 @@ describe('validateUploadFile', () => { ...@@ -160,7 +329,20 @@ describe('validateUploadFile', () => {
allowedExtensions: ['.png'] allowedExtensions: ['.png']
} }
}) })
).rejects.toThrow('InvalidUploadFileType'); ).rejects.toThrow('UploadFileTypeMismatch');
});
it('rejects PHP text content renamed as png', async () => {
await expect(
validateUploadFile({
buffer: Buffer.from('<?php echo "not an image"; ?>', '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 () => { it('accepts OOXML files even when detection falls back to zip container', async () => {
...@@ -173,7 +355,7 @@ describe('validateUploadFile', () => { ...@@ -173,7 +355,7 @@ describe('validateUploadFile', () => {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'demo.docx', filename: 'demo.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}); });
...@@ -191,7 +373,7 @@ describe('validateUploadFile', () => { ...@@ -191,7 +373,7 @@ describe('validateUploadFile', () => {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
} }
}) })
).resolves.toEqual({ ).resolves.toMatchObject({
filename: 'demo.docx', filename: 'demo.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}); });
......
...@@ -49,6 +49,14 @@ describe('sandboxGetFileUrlTool', () => { ...@@ -49,6 +49,14 @@ describe('sandboxGetFileUrlTool', () => {
}); });
expect(JSON.parse(result.response)).toEqual([{ fileUrl: 'signed-url', filename: 'file.txt' }]); 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(sandbox.provider.readFileStream).toHaveBeenCalledWith('/workspace/file.txt');
expect(s3Mock.uploadChatFile).toHaveBeenCalledWith( expect(s3Mock.uploadChatFile).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
...@@ -56,14 +64,14 @@ describe('sandboxGetFileUrlTool', () => { ...@@ -56,14 +64,14 @@ describe('sandboxGetFileUrlTool', () => {
sourceId: 'app', sourceId: 'app',
chatId: 'chat', chatId: 'chat',
uId: 'user', uId: 'user',
filename: 'file.txt' filename: 'file.txt',
expiredTime: expect.any(Date)
}) })
); );
expect(s3Mock.createGetChatFileURL).toHaveBeenCalledWith({ expect(s3Mock.createGetChatFileURL).toHaveBeenCalledWith({
key: 'chat/file.txt', key: 'chat/file.txt',
expiredHours: 2, expiredHours: 2,
external: true, external: true
mode: 'presigned'
}); });
}); });
}); });
...@@ -168,6 +168,51 @@ describe('pushChatRecords', () => { ...@@ -168,6 +168,51 @@ describe('pushChatRecords', () => {
expect(chatItems).toHaveLength(0); 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 () => { it('should remove file URLs from user content before processing', async () => {
const props = createMockProps({ const props = createMockProps({
userContent: { userContent: {
...@@ -689,6 +734,101 @@ describe('pushChatRecords', () => { ...@@ -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 () => { it('should mark prepared round as error and keep ai placeholder', async () => {
const responseChatItemId = 'prepared-ai-error'; const responseChatItemId = 'prepared-ai-error';
const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId });
......
...@@ -14,19 +14,23 @@ const { mockCreateGetChatFileURL } = vi.hoisted(() => ({ ...@@ -14,19 +14,23 @@ const { mockCreateGetChatFileURL } = vi.hoisted(() => ({
mockCreateGetChatFileURL: vi.fn() mockCreateGetChatFileURL: vi.fn()
})); }));
type MockCreatePreviewOptions = {
expiredHours?: number;
mode?: 'short-proxy' | 'short-redirect' | 'presigned';
};
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({ getS3ChatSource: () => ({
createGetChatFileURL: mockCreateGetChatFileURL createGetChatFileURL: mockCreateGetChatFileURL
}), }),
createChatFilePreviewUrlGetter: createChatFilePreviewUrlGetter: (options?: MockCreatePreviewOptions) => async (key: string) => {
(options?: { expiredHours?: number; mode?: 'proxy' | 'presigned' }) => async (key: string) => { const { url } = await mockCreateGetChatFileURL({
const { url } = await mockCreateGetChatFileURL({ key,
key, external: true,
external: true, ...options
...options });
}); return url;
return url; }
}
})); }));
describe('presignVariablesFileUrls', () => { describe('presignVariablesFileUrls', () => {
...@@ -298,4 +302,53 @@ describe('addPreviewUrlToChatItems', () => { ...@@ -298,4 +302,53 @@ describe('addPreviewUrlToChatItems', () => {
external: true 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
});
});
}); });
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { formatDatasetDataValue } from '@fastgpt/service/core/dataset/data/controller'; 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', () => { describe('formatDatasetDataValue', () => {
it('should append image descriptions to markdown image alt text in question and answer', () => { beforeEach(() => {
const result = formatDatasetDataValue({ 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)', q: 'Question ![cat]( https://example.com/cat.png ) and ![bird](https://example.com/bird.png)',
a: 'Answer ![](https://example.com/dog.png)', a: 'Answer ![](https://example.com/dog.png)',
imageDescMap: { imageDescMap: {
...@@ -17,4 +34,34 @@ describe('formatDatasetDataValue', () => { ...@@ -17,4 +34,34 @@ describe('formatDatasetDataValue', () => {
a: 'Answer ![dog desc](https://example.com/dog.png)' 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'
}
]);
});
}); });
...@@ -17,6 +17,11 @@ const mockCountPromptTokens = vi.hoisted(() => vi.fn(async (prompt: string) => p ...@@ -17,6 +17,11 @@ const mockCountPromptTokens = vi.hoisted(() => vi.fn(async (prompt: string) => p
const mockCountPromptTokensBatch = vi.hoisted(() => const mockCountPromptTokensBatch = vi.hoisted(() =>
vi.fn(async (prompts: string[]) => prompts.map((prompt) => prompt.length)) 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; const originalMultipleDataToBase64 = serviceEnv.MULTIPLE_DATA_TO_BASE64;
...@@ -40,7 +45,12 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ ...@@ -40,7 +45,12 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({
})); }));
vi.mock('@fastgpt/service/common/file/image/utils', () => ({ 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 // defaultRecall 的结果过滤只关心 token 数的相对大小,测试里用稳定 mock
...@@ -368,4 +378,69 @@ describe('default recall dataset search', () => { ...@@ -368,4 +378,69 @@ describe('default recall dataset search', () => {
expect(mockMongoDatasetDataTextAggregate).not.toHaveBeenCalled(); expect(mockMongoDatasetDataTextAggregate).not.toHaveBeenCalled();
expect(result.searchRes).toEqual([]); 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<string, any>) => {
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'
]);
});
}); });
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; 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 { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { import {
getAIChatFileContextConfig, getAIChatFileContextConfig,
getInputFiles,
rewriteChatMessagesWithFiles rewriteChatMessagesWithFiles
} from '../../../../../core/workflow/dispatch/ai/chat'; } 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 = ({ const createHumanMessage = ({
text, text,
...@@ -75,6 +80,64 @@ describe('getAIChatFileContextConfig', () => { ...@@ -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<typeof getInputFiles>;
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', () => { describe('rewriteChatMessagesWithFiles', () => {
const parseFileFn = vi.fn(async (urls: string[]) => const parseFileFn = vi.fn(async (urls: string[]) =>
urls.map((url) => ({ urls.map((url) => ({
......
...@@ -848,4 +848,58 @@ describe('runToolCall compression node responses', () => { ...@@ -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
})
);
});
}); });
This diff is collapsed. Click to expand it.
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