fix: use upload control file config

Made-with: Cursor
This commit is contained in:
xqvvu
2026-04-29 16:45:35 +08:00
parent ca80bf0e13
commit e1905a1cc6
13 changed files with 183 additions and 76 deletions
@@ -28,8 +28,7 @@ export const PresignChatFilePostUrlSchema = z
filename: z.string().min(1).describe('文件名'),
appId: ObjectIdSchema.describe('应用ID'),
chatId: z.string().min(1).describe('对话ID'),
fileSelectConfig:
AppFileSelectConfigTypeSchema.optional().describe('调试态前端当前文件选择配置'),
fileSelectConfig: AppFileSelectConfigTypeSchema.describe('本次上传控件的文件选择配置'),
outLinkAuthData: OutLinkChatAuthSchema.optional().describe('外链鉴权数据')
})
.meta({
+4 -3
View File
@@ -1,8 +1,6 @@
import { authDatasetByTmbId } from '../../support/permission/dataset/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { S3Sources } from '../../common/s3/contracts/type';
import { getS3DatasetSource, S3DatasetSource } from '../../common/s3/sources/dataset';
import { getS3ChatSource } from '../../common/s3/sources/chat';
import { jwtSignS3DownloadToken, isS3ObjectKey } from '../../common/s3/utils';
import { getLogger, LogCategories } from '../../common/logger';
import { S3Buckets } from '../../common/s3/config/constants';
@@ -67,7 +65,10 @@ export function replaceS3KeyToPreviewUrl(documentQuoteText: string, expiredTime:
for (const match of matches.slice().reverse()) {
const [full, bang, alt, objectKey] = match;
if (isS3ObjectKey(objectKey, 'dataset') || isS3ObjectKey(objectKey, 'chat')) {
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,
@@ -134,10 +134,11 @@ describe('replaceS3KeyToPreviewUrl', () => {
expect(result).toBe(text);
});
it('temp 前缀应被替换', () => {
it('temp 前缀应被替换', () => {
const text = '![临时文件](temp/team1/temp-file.png)';
const result = replaceS3KeyToPreviewUrl(text, expiredTime);
expect(result).toBe(text);
expect(result).toContain('https://example.com/api/system/file/download/mock-jwt-token-');
expect(result).toContain('temp/team1/temp-file.png');
});
});
@@ -62,10 +62,6 @@ const FileSelector = ({
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const runtimeFileSelectConfig = useContextSelector(
WorkflowRuntimeContext,
(v) => v.runtimeFileSelectConfig
);
const setFileUploadingCount = useContextSelector(
WorkflowRuntimeContext,
(v) => v.setFileUploadingCount
@@ -95,6 +91,26 @@ const FileSelector = ({
canSelectCustomFileExtension,
customFileExtensionList
]);
const fileSelectConfig = useMemo<AppFileSelectConfigType>(
() => ({
maxFiles,
canSelectFile,
canSelectImg,
canSelectVideo,
canSelectAudio,
canSelectCustomFileExtension,
customFileExtensionList
}),
[
maxFiles,
canSelectFile,
canSelectImg,
canSelectVideo,
canSelectAudio,
canSelectCustomFileExtension,
customFileExtensionList
]
);
// 文件数量限制:组件参数 || 团队套餐 || 系统配置 || 默认值
const maxSelectFiles =
maxFiles ||
@@ -131,7 +147,7 @@ const FileSelector = ({
filename: file.rawFile.name,
appId,
chatId,
fileSelectConfig: runtimeFileSelectConfig,
fileSelectConfig,
outLinkAuthData
});
@@ -181,14 +197,7 @@ const FileSelector = ({
})
);
},
[
handleChangeFiles,
setFileUploadingCount,
appId,
chatId,
runtimeFileSelectConfig,
outLinkAuthData
]
[handleChangeFiles, setFileUploadingCount, appId, chatId, fileSelectConfig, outLinkAuthData]
);
// Selector props
@@ -23,7 +23,6 @@ import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { useCreation } from 'ahooks';
import type { ChatTypeEnum } from './constants';
import { ChatTypeEnum as ChatTypeEnumValue } from './constants';
import type { ChatQuickAppType } from '@fastgpt/global/core/chat/setting/type';
import { WorkflowRuntimeContextProvider } from '@/components/core/chat/ChatContainer/context/workflowRuntimeContext';
@@ -254,22 +253,11 @@ const Provider = ({
getHistoryResponseData,
chatType
};
const runtimeFileSelectConfig = useMemo(() => {
if (chatType === ChatTypeEnumValue.test) {
return fileSelectConfig;
}
if (chatType === ChatTypeEnumValue.home && !props.currentQuickAppId) {
return fileSelectConfig;
}
return undefined;
}, [chatType, fileSelectConfig, props.currentQuickAppId]);
return (
<WorkflowRuntimeContextProvider
appId={appId}
chatId={chatId}
outLinkAuthData={formatOutLinkAuth}
runtimeFileSelectConfig={runtimeFileSelectConfig}
>
<ChatBoxContext.Provider value={value}>{children}</ChatBoxContext.Provider>
</WorkflowRuntimeContextProvider>
@@ -17,8 +17,6 @@ import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch
import { getPresignedChatFileGetUrl, getUploadChatFilePresignedUrl } from '@/web/common/file/api';
import { getUploadFileType } from '@fastgpt/global/core/app/constants';
import { putFileToS3 } from '@fastgpt/web/common/file/utils';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { useContextSelector } from 'use-context-selector';
type UseFileUploadOptions = {
fileSelectConfig: AppFileSelectConfigType;
@@ -35,10 +33,6 @@ export const useFileUpload = (props: UseFileUploadOptions) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore();
const runtimeFileSelectConfig = useContextSelector(
WorkflowRuntimeContext,
(v) => v.runtimeFileSelectConfig
);
const {
update: updateFiles,
@@ -196,7 +190,7 @@ export const useFileUpload = (props: UseFileUploadOptions) => {
filename: copyFile.rawFile.name,
appId,
chatId,
fileSelectConfig: runtimeFileSelectConfig,
fileSelectConfig,
outLinkAuthData
});
@@ -242,10 +236,10 @@ export const useFileUpload = (props: UseFileUploadOptions) => {
appId,
chatId,
fileList,
fileSelectConfig,
outLinkAuthData,
removeFiles,
replaceFiles,
runtimeFileSelectConfig,
t,
toast,
updateFiles
@@ -294,7 +294,6 @@ const PluginRunContextProvider = ({
appId={props.appId}
chatId={props.chatId}
outLinkAuthData={props.outLinkAuthData || {}}
runtimeFileSelectConfig={props.runtimeFileSelectConfig}
>
<PluginRunContext.Provider value={contextValue}>{children}</PluginRunContext.Provider>
</WorkflowRuntimeContextProvider>
@@ -5,7 +5,6 @@ import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch
import React from 'react';
import type { onStartChatType } from '../type';
import { ChatBoxInputFormType } from '../ChatBox/type';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema';
export type PluginRunBoxProps = {
appId: string;
@@ -15,5 +14,4 @@ export type PluginRunBoxProps = {
onStartChat?: onStartChatType;
onNewChat?: () => void;
showTab?: PluginRunBoxTabEnum; // 如何设置了该字段,全局都 tab 不生效
runtimeFileSelectConfig?: AppFileSelectConfigType;
};
@@ -1,5 +1,4 @@
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { useState } from 'react';
import { createContext } from 'use-context-selector';
@@ -8,7 +7,6 @@ type WorkflowRuntimeContextType = {
outLinkAuthData: OutLinkChatAuthProps;
appId: string;
chatId: string;
runtimeFileSelectConfig?: AppFileSelectConfigType;
fileUploading: boolean;
setFileUploadingCount: React.Dispatch<React.SetStateAction<number>>;
@@ -18,7 +16,6 @@ export const WorkflowRuntimeContext = createContext<WorkflowRuntimeContextType>(
outLinkAuthData: {},
appId: '',
chatId: '',
runtimeFileSelectConfig: undefined,
fileUploading: false,
setFileUploadingCount: () => {}
});
@@ -27,13 +24,11 @@ export const WorkflowRuntimeContextProvider = ({
appId,
chatId,
outLinkAuthData,
runtimeFileSelectConfig,
children
}: {
appId: string;
chatId: string;
outLinkAuthData: OutLinkChatAuthProps;
runtimeFileSelectConfig?: AppFileSelectConfigType;
children: React.ReactNode;
}) => {
const [fileUploadingCount, setFileUploadingCount] = useState<number>(0);
@@ -44,11 +39,10 @@ export const WorkflowRuntimeContextProvider = ({
outLinkAuthData,
appId,
chatId,
runtimeFileSelectConfig,
fileUploading,
setFileUploadingCount
}),
[outLinkAuthData, appId, chatId, runtimeFileSelectConfig, fileUploading, setFileUploadingCount]
[outLinkAuthData, appId, chatId, fileUploading, setFileUploadingCount]
);
return (
@@ -158,7 +158,6 @@ export const useChatTest = ({
chatId={chatId}
onNewChat={restartChat}
onStartChat={startChat}
runtimeFileSelectConfig={chatConfig.fileSelectConfig}
/>
</Box>
) : (
@@ -133,6 +133,11 @@ export const usePreviewFileUpload = ({ fileCtrl, appId, chatId }: UsePreviewFile
filename: copyFile.rawFile.name,
appId,
chatId,
fileSelectConfig: {
maxFiles: maxSelectFiles,
canSelectFile: true,
canSelectImg: true
},
outLinkAuthData: undefined
});
@@ -171,7 +176,7 @@ export const usePreviewFileUpload = ({ fileCtrl, appId, chatId }: UsePreviewFile
);
removeFiles(errorIndexes);
}, [appId, chatId, fileList, removeFiles, replaceFiles, t, toast, updateFiles]);
}, [appId, chatId, fileList, maxSelectFiles, removeFiles, replaceFiles, t, toast, updateFiles]);
const sortFileList = useMemo(
() =>
@@ -3,16 +3,12 @@ import { NextAPI } from '@/service/middleware/entry';
import type { CreatePostPresignedUrlResponseType } from '@fastgpt/global/common/file/s3/type';
import { getS3ChatSource } from '@fastgpt/service/common/s3/sources/chat';
import { getAllowedExtensionsFromFileSelectConfig } from '@fastgpt/service/common/s3/utils/uploadConstraints';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { authChatCrud } from '@/service/support/permission/auth/chat';
import { authFrequencyLimit } from '@fastgpt/service/common/system/frequencyLimit/utils';
import { addSeconds } from 'date-fns';
import { PresignChatFilePostUrlSchema } from '@fastgpt/global/openapi/core/chat/file/api';
import { getTeamPlanStatus } from '@fastgpt/service/support/wallet/sub/utils';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { S3ErrEnum } from '@fastgpt/global/common/error/code/s3';
import { MongoChatSetting } from '@fastgpt/service/core/chat/setting/schema';
import { env } from '@fastgpt/service/env';
async function handler(req: ApiRequestProps): Promise<CreatePostPresignedUrlResponseType> {
@@ -27,26 +23,8 @@ async function handler(req: ApiRequestProps): Promise<CreatePostPresignedUrlResp
...outLinkAuthData
});
const [planStatus, app] = await Promise.all([
getTeamPlanStatus({ teamId }),
MongoApp.findById(appId, 'chatConfig.fileSelectConfig').lean()
]);
const effectiveFileSelectConfig = fileSelectConfig
? await (async () => {
const isHomeApp = await MongoChatSetting.exists({ teamId, appId });
if (!isHomeApp) {
await authApp({
req,
authToken: true,
appId,
per: WritePermissionVal
});
}
return fileSelectConfig;
})()
: app?.chatConfig?.fileSelectConfig;
const allowedExtensions = getAllowedExtensionsFromFileSelectConfig(effectiveFileSelectConfig);
const planStatus = await getTeamPlanStatus({ teamId });
const allowedExtensions = getAllowedExtensionsFromFileSelectConfig(fileSelectConfig);
if (!env.SKIP_FILE_TYPE_CHECK && allowedExtensions.length === 0) {
return Promise.reject(S3ErrEnum.fileUploadDisabled);
@@ -0,0 +1,142 @@
import { S3ErrEnum } from '@fastgpt/global/common/error/code/s3';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authChatCrud: vi.fn(),
getTeamPlanStatus: vi.fn(),
authFrequencyLimit: vi.fn(),
createUploadChatFileURL: vi.fn(),
findAppById: vi.fn(),
chatSettingExists: vi.fn(),
authApp: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: unknown) => handler
}));
vi.mock('@/service/support/permission/auth/chat', () => ({
authChatCrud: mocks.authChatCrud
}));
vi.mock('@fastgpt/service/support/wallet/sub/utils', () => ({
getTeamPlanStatus: mocks.getTeamPlanStatus
}));
vi.mock('@fastgpt/service/common/system/frequencyLimit/utils', () => ({
authFrequencyLimit: mocks.authFrequencyLimit
}));
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
createUploadChatFileURL: mocks.createUploadChatFileURL
})
}));
vi.mock('@fastgpt/service/env', () => ({
env: {
SKIP_FILE_TYPE_CHECK: false
}
}));
vi.mock('@fastgpt/service/core/app/schema', () => ({
MongoApp: {
findById: mocks.findAppById
}
}));
vi.mock('@fastgpt/service/core/chat/setting/schema', () => ({
MongoChatSetting: {
exists: mocks.chatSettingExists
}
}));
vi.mock('@fastgpt/service/support/permission/app/auth', () => ({
authApp: mocks.authApp
}));
import handler from '@/pages/api/core/chat/file/presignChatFilePostUrl';
const appId = '507f1f77bcf86cd799439011';
const chatId = 'chat-id';
const filename = 'demo.png';
const presignHandler = handler as unknown as (req: ApiRequestProps) => Promise<unknown>;
const callHandler = (body: Record<string, unknown>) =>
presignHandler({
body
} as ApiRequestProps);
describe('presignChatFilePostUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authChatCrud.mockResolvedValue({
teamId: 'team-id',
uid: 'user-id'
});
mocks.getTeamPlanStatus.mockResolvedValue({
standard: {
maxUploadFileCount: 20,
maxUploadFileSize: 15
}
});
mocks.createUploadChatFileURL.mockResolvedValue({
url: 'https://example.com/upload-token',
key: 'chat/app/user/chat/demo.png',
headers: {
'content-type': 'image/png'
},
maxSize: 15 * 1024 * 1024
});
});
it('uses request fileSelectConfig as upload constraints without reading app chatConfig', async () => {
await expect(
callHandler({
filename,
appId,
chatId,
fileSelectConfig: {
canSelectImg: true
}
})
).resolves.toMatchObject({
url: 'https://example.com/upload-token'
});
expect(mocks.findAppById).not.toHaveBeenCalled();
expect(mocks.chatSettingExists).not.toHaveBeenCalled();
expect(mocks.authApp).not.toHaveBeenCalled();
expect(mocks.createUploadChatFileURL).toHaveBeenCalledWith(
expect.objectContaining({
appId,
chatId,
filename,
uId: 'user-id',
allowedExtensions: expect.arrayContaining([
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.webp'
])
})
);
});
it('rejects upload when fileSelectConfig does not enable any file type', async () => {
await expect(
callHandler({
filename,
appId,
chatId,
fileSelectConfig: {}
})
).rejects.toBe(S3ErrEnum.fileUploadDisabled);
expect(mocks.createUploadChatFileURL).not.toHaveBeenCalled();
});
});