image compatibility for various content-types (#6119)

* image compatibility for various content-types

* perf: image type detect

* perf: gethistory

* update test

* update rerank log

* perf: login

* fix: query extension use

---------

Co-authored-by: archer <545436317@qq.com>
This commit is contained in:
heheer 2025-12-18 23:25:48 +08:00 committed by GitHub
parent ea7c37745a
commit 5231f4281f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 605 additions and 149 deletions

View File

@ -16,6 +16,7 @@ description: 'FastGPT V4.14.5 更新说明'
1. MCP 工具创建时,使用自定义鉴权头会报错。
2. 获取对话日志列表时,如果用户头像为空,会抛错。
3. chatAgent 未开启问题优化时,前端 UI 显示开启。
## 插件

View File

@ -31,7 +31,7 @@
"document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-09-29T11:52:39+08:00",
"document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00",
"document/content/docs/introduction/development/openapi/app.mdx": "2025-09-26T13:18:51+08:00",
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-11-14T13:21:17+08:00",
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-12-18T13:49:45+08:00",
"document/content/docs/introduction/development/openapi/dataset.mdx": "2025-09-29T11:34:11+08:00",
"document/content/docs/introduction/development/openapi/intro.mdx": "2025-09-29T11:34:11+08:00",
"document/content/docs/introduction/development/openapi/share.mdx": "2025-12-09T23:33:32+08:00",

View File

@ -8,7 +8,7 @@ import { PaginationSchema, PaginationResponseSchema } from '../../../api';
export const GetHistoriesBodySchema = PaginationSchema.and(
OutLinkChatAuthSchema.and(
z.object({
appId: ObjectIdSchema.optional().describe('应用ID'),
appId: z.string().optional().describe('应用ID'),
source: z.enum(ChatSourceEnum).optional().describe('对话来源'),
startCreateTime: z.string().optional().describe('创建时间开始'),
endCreateTime: z.string().optional().describe('创建时间结束'),

View File

@ -2,13 +2,12 @@ import { type preUploadImgProps } from '@fastgpt/global/common/file/api';
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
import { MongoImage } from './schema';
import { type ClientSession, Types } from '../../../common/mongo';
import { guessBase64ImageType } from '../utils';
import { guessBase64ImageType } from './utils';
import { readFromSecondary } from '../../mongo/utils';
import { addHours } from 'date-fns';
import { imageFileType } from '@fastgpt/global/common/file/constants';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { UserError } from '@fastgpt/global/common/error/utils';
import { S3Sources } from '../../s3/type';
import { getS3AvatarSource } from '../../s3/sources/avatar';
import { isS3ObjectKey } from '../../s3/utils';
import path from 'path';

View File

@ -1,8 +1,98 @@
import axios from 'axios';
import { addLog } from '../../system/log';
import { serverRequestBaseUrl } from '../../api/serverRequest';
import { getFileContentTypeFromHeader, guessBase64ImageType } from '../utils';
import { retryFn } from '@fastgpt/global/common/system/utils';
import { getContentTypeFromHeader } from '../utils';
// 图片格式魔数映射表
const IMAGE_SIGNATURES: { type: string; magic: number[]; check?: (buffer: Buffer) => boolean }[] = [
{ type: 'image/jpeg', magic: [0xff, 0xd8, 0xff] },
{ type: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] },
{
type: 'image/webp',
magic: [0x52, 0x49, 0x46, 0x46],
check: (buffer) => buffer.length >= 12 && buffer.slice(8, 12).toString('ascii') === 'WEBP'
},
{ type: 'image/bmp', magic: [0x42, 0x4d] },
{ type: 'image/tiff', magic: [0x49, 0x49, 0x2a, 0x00] },
{ type: 'image/tiff', magic: [0x4d, 0x4d, 0x00, 0x2a] },
{ type: 'image/svg+xml', magic: [0x3c, 0x73, 0x76, 0x67] },
{ type: 'image/x-icon', magic: [0x00, 0x00, 0x01, 0x00] }
];
// 有效的图片 MIME 类型
const VALID_IMAGE_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
'image/tiff',
'image/x-icon',
'image/vnd.microsoft.icon',
'image/ico',
'image/heic',
'image/heif',
'image/avif'
]);
// Base64 首字符到图片类型的映射
const BASE64_PREFIX_MAP: Record<string, string> = {
'/': 'image/jpeg',
i: 'image/png',
R: 'image/gif',
U: 'image/webp',
Q: 'image/bmp',
P: 'image/svg+xml',
T: 'image/tiff',
J: 'image/jp2',
S: 'image/x-tga',
I: 'image/ief',
V: 'image/vnd.microsoft.icon',
W: 'image/vnd.wap.wbmp',
X: 'image/x-xbitmap',
Z: 'image/x-xpixmap',
Y: 'image/x-xwindowdump'
};
const DEFAULT_IMAGE_TYPE = 'image/jpeg';
export const isValidImageContentType = (contentType: string): boolean => {
if (!contentType) return false;
return VALID_IMAGE_TYPES.has(contentType);
};
export const detectImageTypeFromBuffer = (buffer: Buffer): string | undefined => {
if (!buffer || buffer.length === 0) return;
for (const { type, magic, check } of IMAGE_SIGNATURES) {
if (buffer.length < magic.length) continue;
const matches = magic.every((byte, index) => buffer[index] === byte);
if (matches && (!check || check(buffer))) {
return type;
}
}
return;
};
export const guessBase64ImageType = (str: string): string => {
if (!str || typeof str !== 'string') return DEFAULT_IMAGE_TYPE;
// 尝试从 base64 解码并检测文件头
try {
const buffer = Buffer.from(str, 'base64');
const detectedType = detectImageTypeFromBuffer(buffer);
if (detectedType) return detectedType;
} catch {}
// 回退到首字符映射
return BASE64_PREFIX_MAP[str.charAt(0)] || DEFAULT_IMAGE_TYPE;
};
export const getImageBase64 = async (url: string) => {
addLog.debug(`Load image to base64: ${url}`);
@ -16,10 +106,26 @@ export const getImageBase64 = async (url: string) => {
})
);
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const imageType =
getFileContentTypeFromHeader(response.headers['content-type']) ||
guessBase64ImageType(base64);
const buffer = Buffer.from(response.data);
const base64 = buffer.toString('base64');
const headerContentType = getContentTypeFromHeader(response.headers['content-type']);
// 检测图片类型的优先级策略
const imageType = (() => {
// 1. 如果 Header 是有效的图片类型,直接使用
if (headerContentType && isValidImageContentType(headerContentType)) {
return headerContentType;
}
// 2. 使用文件头检测(适用于通用二进制类型或无效类型)
const detectedType = detectImageTypeFromBuffer(buffer);
if (detectedType) {
return detectedType;
}
// 3. 回退到 base64 推断
return guessBase64ImageType(base64);
})();
return {
completeBase64: `data:${imageType};base64,${base64}`,

View File

@ -17,37 +17,8 @@ export const removeFilesByPaths = (paths: string[]) => {
});
};
export const guessBase64ImageType = (str: string) => {
const imageTypeMap: Record<string, string> = {
'/': 'image/jpeg',
i: 'image/png',
R: 'image/gif',
U: 'image/webp',
Q: 'image/bmp',
P: 'image/svg+xml',
T: 'image/tiff',
J: 'image/jp2',
S: 'image/x-tga',
I: 'image/ief',
V: 'image/vnd.microsoft.icon',
W: 'image/vnd.wap.wbmp',
X: 'image/x-xbitmap',
Z: 'image/x-xpixmap',
Y: 'image/x-xwindowdump'
};
const defaultType = 'image/jpeg';
if (typeof str !== 'string' || str.length === 0) {
return defaultType;
}
const firstChar = str.charAt(0);
return imageTypeMap[firstChar] || defaultType;
};
export const getFileContentTypeFromHeader = (header: string): string | undefined => {
const contentType = header.split(';')[0];
return contentType;
export const getContentTypeFromHeader = (header: string): string | undefined => {
return header?.toLowerCase()?.split(';')?.[0]?.trim();
};
export const clearDirFiles = (dirPath: string) => {

View File

@ -3,6 +3,8 @@ import { getAIApi } from '../config';
import { countPromptTokens } from '../../../common/string/tiktoken/index';
import { EmbeddingTypeEnm } from '@fastgpt/global/core/ai/constants';
import { addLog } from '../../../common/system/log';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { retryFn } from '@fastgpt/global/common/system/utils';
type GetVectorProps = {
model: EmbeddingModelItemType;
@ -38,55 +40,70 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
for (const chunk of chunks) {
// input text to vector
const result = await ai.embeddings
.create(
{
...model.defaultConfig,
...(type === EmbeddingTypeEnm.db && model.dbConfig),
...(type === EmbeddingTypeEnm.query && model.queryConfig),
model: model.model,
input: chunk
},
model.requestUrl
? {
path: model.requestUrl,
headers: {
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
...headers
const result = await retryFn(() =>
ai.embeddings
.create(
{
...model.defaultConfig,
...(type === EmbeddingTypeEnm.db && model.dbConfig),
...(type === EmbeddingTypeEnm.query && model.queryConfig),
model: model.model,
input: chunk
},
model.requestUrl
? {
path: model.requestUrl,
headers: {
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
...headers
}
}
}
: { headers }
)
.then(async (res) => {
if (!res.data) {
addLog.error('[Embedding] API is not responding', res);
return Promise.reject('Embedding API is not responding');
}
if (!res?.data?.[0]?.embedding) {
// @ts-ignore
const msg = res.data?.err?.message || 'Embedding API Error';
addLog.error('[Embedding] API Error', {
message: msg,
data: res
});
return Promise.reject(msg);
}
: { headers }
)
.then(async (res) => {
if (!res.data) {
addLog.error('[Embedding Error] not responding', {
message: '',
data: {
response: res,
model: model.model,
inputLength: chunk.length
}
});
return Promise.reject('Embedding API is not responding');
}
if (!res?.data?.[0]?.embedding) {
// @ts-ignore
const msg = res.data?.err?.message || '';
addLog.error('[Embedding Error]', {
message: msg,
data: {
response: res,
model: model.model,
inputLength: chunk.length
}
});
return Promise.reject('Embedding API is not responding');
}
const [tokens, vectors] = await Promise.all([
(async () => {
if (res.usage) return res.usage.total_tokens;
const [tokens, vectors] = await Promise.all([
(async () => {
if (res.usage) return res.usage.total_tokens;
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
return tokens.reduce((sum, item) => sum + item, 0);
})(),
Promise.all(res.data.map((item) => formatVectors(item.embedding, model.normalization)))
]);
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
return tokens.reduce((sum, item) => sum + item, 0);
})(),
Promise.all(
res.data.map((item) => formatVectors(item.embedding, model.normalization))
)
]);
return {
tokens,
vectors
};
});
return {
tokens,
vectors
};
})
);
totalTokens += result.tokens;
allVectors.push(...result.vectors);
@ -97,7 +114,13 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
vectors: allVectors
};
} catch (error) {
addLog.error(`[Embedding] request error`, error);
addLog.error(`[Embedding Error]`, {
message: getErrText(error),
data: {
model: model.model,
inputLengths: formatInput.map((item) => item.length)
}
});
return Promise.reject(error);
}

View File

@ -628,12 +628,17 @@ const createChatCompletion = async ({
('iterator' in response || 'controller' in response);
const getEmptyResponseTip = () => {
addLog.warn(`LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
if (userKey?.baseUrl) {
addLog.warn(`User LLM response empty`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
} else {
addLog.error(`LLM response empty`, {
message: '',
data: body
});
}
return i18nT('chat:LLM_model_response_empty');
};
@ -652,13 +657,18 @@ const createChatCompletion = async ({
getEmptyResponseTip
};
} catch (error) {
addLog.error(`LLM response error`, error);
addLog.warn(`LLM response error`, {
baseUrl: userKey?.baseUrl,
requestBody: body
});
if (userKey?.baseUrl) {
addLog.warn(`User ai api error`, {
message: getErrText(error),
baseUrl: userKey?.baseUrl,
data: body
});
return Promise.reject(`您的 OpenAI key 出错了: ${getErrText(error)}`);
} else {
addLog.error(`LLM response error`, {
message: getErrText(error),
data: body
});
}
return Promise.reject(error);
}

View File

@ -35,7 +35,7 @@ export function reRankRecall({
headers?: Record<string, string>;
}): Promise<ReRankCallResult> {
if (!model) {
return Promise.reject('[Rerank] No rerank model');
return Promise.reject('No rerank model');
}
if (documents.length === 0) {
return Promise.resolve({
@ -67,7 +67,7 @@ export function reRankRecall({
addLog.info('ReRank finish:', { time: Date.now() - start });
if (!data?.results || data?.results?.length === 0) {
addLog.error('[Rerank] Empty result', { data });
addLog.error('[Rerank Error]', { message: 'Empty result', data });
}
return {
@ -81,7 +81,7 @@ export function reRankRecall({
};
})
.catch((err) => {
addLog.error('[Rerank] request error', err);
addLog.error('[Rerank Error]', err);
return Promise.reject(err);
});

View File

@ -15,7 +15,7 @@ const SearchParamsTip = ({
limit = 5000,
responseEmptyText,
usingReRank = false,
datasetSearchUsingExtensionQuery,
usingExtensionQuery,
queryExtensionModel
}: {
searchMode: `${DatasetSearchModeEnum}`;
@ -23,7 +23,7 @@ const SearchParamsTip = ({
limit?: number;
responseEmptyText?: string;
usingReRank?: boolean;
datasetSearchUsingExtensionQuery?: boolean;
usingExtensionQuery?: boolean;
queryExtensionModel?: string;
}) => {
const { t } = useTranslation();
@ -34,8 +34,8 @@ const SearchParamsTip = ({
const hasSimilarityMode = usingReRank || searchMode === DatasetSearchModeEnum.embedding;
const extensionModelName = useMemo(
() => getWebLLMModel(queryExtensionModel)?.name,
[queryExtensionModel]
() => (usingExtensionQuery ? getWebLLMModel(queryExtensionModel)?.name : ''),
[usingExtensionQuery, queryExtensionModel]
);
return (
@ -94,7 +94,7 @@ const SearchParamsTip = ({
</Td>
)}
<Td pt={0} pb={2} fontSize={'mini'}>
{extensionModelName ? extensionModelName : '❌'}
{extensionModelName || '❌'}
</Td>
{hasEmptyResponseMode && <Th>{responseEmptyText !== '' ? '✅' : '❌'}</Th>}
</Tr>

View File

@ -282,7 +282,7 @@ const EditForm = ({
similarity={appForm.dataset.similarity}
limit={appForm.dataset.limit}
usingReRank={appForm.dataset.usingReRank}
datasetSearchUsingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
usingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
queryExtensionModel={appForm.dataset.datasetSearchExtensionModel}
/>
</Box>

View File

@ -70,7 +70,7 @@ const SelectDatasetParam = ({ inputs = [], nodeId }: RenderInputProps) => {
similarity={data.similarity}
limit={data.limit}
usingReRank={data.usingReRank}
datasetSearchUsingExtensionQuery={data.datasetSearchUsingExtensionQuery}
usingExtensionQuery={data.datasetSearchUsingExtensionQuery}
queryExtensionModel={data.datasetSearchExtensionModel}
/>

View File

@ -433,7 +433,7 @@ const TestResults = React.memo(function TestResults({
similarity={datasetTestItem.similarity}
limit={datasetTestItem.limit}
usingReRank={datasetTestItem.usingReRank}
datasetSearchUsingExtensionQuery={!!datasetTestItem.queryExtensionModel}
usingExtensionQuery={!!datasetTestItem.queryExtensionModel}
queryExtensionModel={datasetTestItem.queryExtensionModel}
/>
</Box>

View File

@ -12,6 +12,7 @@ import {
} from '@fastgpt/global/openapi/core/chat/history/api';
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
import { addMonths } from 'date-fns';
import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
/* get chat histories list */
export async function handler(
@ -70,6 +71,13 @@ export async function handler(
};
}
if (match.appId && !ObjectIdSchema.safeParse(match.appId).success) {
return {
list: [],
total: 0
};
}
const timeMatch: Record<string, any> = {};
if (startCreateTime || endCreateTime) {
timeMatch.createTime = {

View File

@ -30,21 +30,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
type: UserAuthTypeEnum.login
});
// 检测用户是否存在
const authCert = await MongoUser.findOne(
{
username
},
'status'
);
if (!authCert) {
return Promise.reject(UserErrEnum.account_psw_error);
}
if (authCert.status === UserStatusEnum.forbidden) {
return Promise.reject('Invalid account!');
}
const user = await MongoUser.findOne({
username,
password
@ -53,16 +38,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!user) {
return Promise.reject(UserErrEnum.account_psw_error);
}
if (user.status === UserStatusEnum.forbidden) {
return Promise.reject('Invalid account!');
}
const userDetail = await getUserDetail({
tmbId: user?.lastLoginTmbId,
userId: user._id
});
MongoUser.findByIdAndUpdate(user._id, {
lastLoginTmbId: userDetail.team.tmbId,
language
});
user.lastLoginTmbId = userDetail.team.tmbId;
user.language = language;
await user.save();
const token = await createUserSession({
userId: user._id,

View File

@ -20,7 +20,7 @@ describe('closeCustom api test', () => {
let dataId: string;
beforeEach(async () => {
testUser = await getUser('test-user-close-custom');
testUser = await getUser(`test-user-close-custom-${Math.random()}`);
// Create test app
const app = await MongoApp.create({
@ -94,7 +94,7 @@ describe('closeCustom api test', () => {
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-close');
const unauthorizedUser = await getUser(`unauthorized-user-close-${Math.random()}`);
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
handler,

View File

@ -19,7 +19,7 @@ describe('getFeedbackRecordIds api test', () => {
let chatId: string;
beforeEach(async () => {
testUser = await getUser('test-user-get-feedback-ids');
testUser = await getUser(`test-user-get-feedback-ids-${Math.random()}`);
// Create test app
const app = await MongoApp.create({
@ -270,7 +270,7 @@ describe('getFeedbackRecordIds api test', () => {
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-get-ids');
const unauthorizedUser = await getUser(`unauthorized-user-get-ids-${Math.random()}`);
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
handler,

View File

@ -20,7 +20,8 @@ describe('updateFeedbackReadStatus api test', () => {
let dataId: string;
beforeEach(async () => {
testUser = await getUser('test-user-update-read-status');
// Use unique username for each test to avoid concurrency issues
testUser = await getUser(`test-user-update-read-status-${Math.random()}`);
// Create test app
const app = await MongoApp.create({
@ -127,7 +128,7 @@ describe('updateFeedbackReadStatus api test', () => {
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-read-status');
const unauthorizedUser = await getUser(`unauthorized-user-read-status-${Math.random()}`);
const res = await Call<
UpdateFeedbackReadStatusBodyType,

View File

@ -21,7 +21,7 @@ describe('updateUserFeedback api test', () => {
let dataId: string;
beforeEach(async () => {
testUser = await getUser('test-user-update-feedback');
testUser = await getUser(`test-user-update-feedback-${Math.random()}`);
// Create test app
const app = await MongoApp.create({
@ -383,7 +383,7 @@ describe('updateUserFeedback api test', () => {
});
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-feedback');
const unauthorizedUser = await getUser(`unauthorized-user-feedback-${Math.random()}`);
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
handler,

View File

@ -263,7 +263,7 @@ describe('getHistories api test', () => {
});
it('should return empty list when appId does not exist', async () => {
const nonExistentAppId = getNanoid(24);
const nonExistentAppId = '507f1f77bcf86cd799439011'; // Valid ObjectId format but non-existent
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
@ -276,9 +276,9 @@ describe('getHistories api test', () => {
}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
expect(res.error?.name).toBe('ZodError');
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(0);
expect(res.data.total).toBe(0);
});
it('should fail when appId is missing', async () => {
@ -341,4 +341,40 @@ describe('getHistories api test', () => {
// Second should be the most recently updated non-top chat
expect(res.data.list[1].chatId).toBe(chatIds[2]);
});
it('should return empty list when appId format is invalid', async () => {
const invalidAppId = 'invalid-app-id'; // Not a valid ObjectId format
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId: invalidAppId
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(0);
expect(res.data.total).toBe(0);
});
it('should accept empty string for appId and return empty list', async () => {
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
body: {
appId: ''
},
query: {
offset: 0,
pageSize: 10
}
});
expect(res.code).toBe(200);
expect(res.data.list).toHaveLength(0);
expect(res.data.total).toBe(0);
});
});

View File

@ -193,9 +193,6 @@ describe('loginByPassword API', () => {
});
it('should accept language parameter on successful login', async () => {
// Spy on findByIdAndUpdate to verify it's called with the language
const findByIdAndUpdateSpy = vi.spyOn(MongoUser, 'findByIdAndUpdate');
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
body: {
username: 'testuser',
@ -208,15 +205,10 @@ describe('loginByPassword API', () => {
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify findByIdAndUpdate was called with the language
expect(findByIdAndUpdateSpy).toHaveBeenCalledWith(
testUser._id,
expect.objectContaining({
language: 'en'
})
);
findByIdAndUpdateSpy.mockRestore();
// Verify user was updated with the language
const updatedUser = await MongoUser.findById(testUser._id);
expect(updatedUser?.language).toBe('en');
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
});
it('should handle root user login correctly', async () => {
@ -261,4 +253,74 @@ describe('loginByPassword API', () => {
expect(res.data.token).toBeDefined();
expect(typeof res.data.token).toBe('string');
});
it('should use default language when language is not provided', async () => {
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456'
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify user was updated with the default language 'zh-CN'
const updatedUser = await MongoUser.findById(testUser._id);
expect(updatedUser?.language).toBe('zh-CN');
});
it('should update lastLoginTmbId on successful login', async () => {
const updateOneSpy = vi.spyOn(MongoUser, 'updateOne');
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456'
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
// Verify user was updated with lastLoginTmbId
const updatedUser = await MongoUser.findById(testUser._id);
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
});
it('should verify user authentication flow', async () => {
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456'
}
});
expect(res.code).toBe(200);
// Verify the full authentication flow
expect(authCode).toHaveBeenCalled();
expect(setCookie).toHaveBeenCalled();
expect(pushTrack.login).toHaveBeenCalled();
expect(addAuditLog).toHaveBeenCalled();
});
it('should return user details with correct structure', async () => {
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
body: {
username: 'testuser',
password: 'testpassword',
code: '123456'
}
});
expect(res.code).toBe(200);
expect(res.data.user).toBeDefined();
expect(res.data.user.team).toBeDefined();
expect(res.data.user.team.teamId).toBe(String(testTeam._id));
expect(res.data.user.team.tmbId).toBe(String(testTmb._id));
});
});

View File

@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest';
import {
isValidImageContentType,
detectImageTypeFromBuffer,
guessBase64ImageType
} from '@fastgpt/service/common/file/image/utils';
describe('isValidImageContentType', () => {
it('should return true for valid image MIME types', () => {
expect(isValidImageContentType('image/jpeg')).toBe(true);
expect(isValidImageContentType('image/jpg')).toBe(true);
expect(isValidImageContentType('image/png')).toBe(true);
expect(isValidImageContentType('image/gif')).toBe(true);
expect(isValidImageContentType('image/webp')).toBe(true);
expect(isValidImageContentType('image/bmp')).toBe(true);
expect(isValidImageContentType('image/svg+xml')).toBe(true);
expect(isValidImageContentType('image/tiff')).toBe(true);
expect(isValidImageContentType('image/x-icon')).toBe(true);
expect(isValidImageContentType('image/vnd.microsoft.icon')).toBe(true);
expect(isValidImageContentType('image/ico')).toBe(true);
expect(isValidImageContentType('image/heic')).toBe(true);
expect(isValidImageContentType('image/heif')).toBe(true);
expect(isValidImageContentType('image/avif')).toBe(true);
});
it('should return false for invalid image MIME types', () => {
expect(isValidImageContentType('text/plain')).toBe(false);
expect(isValidImageContentType('application/json')).toBe(false);
expect(isValidImageContentType('video/mp4')).toBe(false);
expect(isValidImageContentType('audio/mpeg')).toBe(false);
expect(isValidImageContentType('application/pdf')).toBe(false);
});
it('should return false for empty or undefined input', () => {
expect(isValidImageContentType('')).toBe(false);
expect(isValidImageContentType(undefined as any)).toBe(false);
expect(isValidImageContentType(null as any)).toBe(false);
});
it('should be case-sensitive (requires lowercase input)', () => {
// Note: This function expects lowercase input
// The getContentTypeFromHeader function should normalize it first
expect(isValidImageContentType('IMAGE/JPEG')).toBe(false);
expect(isValidImageContentType('Image/Png')).toBe(false);
});
});
describe('detectImageTypeFromBuffer', () => {
it('should detect JPEG images', () => {
const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
expect(detectImageTypeFromBuffer(jpegBuffer)).toBe('image/jpeg');
});
it('should detect PNG images', () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(pngBuffer)).toBe('image/png');
});
it('should detect GIF images', () => {
const gifBuffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(gifBuffer)).toBe('image/gif');
});
it('should detect WebP images', () => {
// RIFF....WEBP
const webpBuffer = Buffer.from([
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50
]);
expect(detectImageTypeFromBuffer(webpBuffer)).toBe('image/webp');
});
it('should detect BMP images', () => {
const bmpBuffer = Buffer.from([0x42, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(bmpBuffer)).toBe('image/bmp');
});
it('should detect TIFF images (little-endian)', () => {
const tiffBuffer = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00, 0x00, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(tiffBuffer)).toBe('image/tiff');
});
it('should detect TIFF images (big-endian)', () => {
const tiffBuffer = Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(tiffBuffer)).toBe('image/tiff');
});
it('should detect SVG images', () => {
const svgBuffer = Buffer.from([0x3c, 0x73, 0x76, 0x67, 0x20, 0x78, 0x6d, 0x6c]);
expect(detectImageTypeFromBuffer(svgBuffer)).toBe('image/svg+xml');
});
it('should detect ICO images', () => {
const icoBuffer = Buffer.from([0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
expect(detectImageTypeFromBuffer(icoBuffer)).toBe('image/x-icon');
});
it('should return undefined for invalid or too short buffers', () => {
expect(detectImageTypeFromBuffer(Buffer.from([]))).toBe(undefined);
expect(detectImageTypeFromBuffer(Buffer.from([0x00]))).toBe(undefined);
expect(detectImageTypeFromBuffer(Buffer.from([0x00, 0x00]))).toBe(undefined);
expect(detectImageTypeFromBuffer(null as any)).toBe(undefined);
expect(detectImageTypeFromBuffer(undefined as any)).toBe(undefined);
});
it('should return undefined for unknown image formats', () => {
const unknownBuffer = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11]);
expect(detectImageTypeFromBuffer(unknownBuffer)).toBe(undefined);
});
it('should not detect WebP without proper WEBP marker', () => {
// RIFF but no WEBP marker
const fakeWebpBuffer = Buffer.from([
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43, 0x44
]);
expect(detectImageTypeFromBuffer(fakeWebpBuffer)).toBe(undefined);
});
});
describe('guessBase64ImageType', () => {
it('should detect JPEG from base64 string', () => {
// /9j/ is the base64 encoded start of a JPEG file (0xFF 0xD8 0xFF)
const jpegBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/';
expect(guessBase64ImageType(jpegBase64)).toBe('image/jpeg');
});
it('should detect PNG from base64 string', () => {
// iVBORw== is the base64 encoded start of a PNG file
const pngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
expect(guessBase64ImageType(pngBase64)).toBe('image/png');
});
it('should detect GIF from base64 string', () => {
// R0lGOD is the base64 encoded start of a GIF file
const gifBase64 = 'R0lGODlhAQABAAAAACw=';
expect(guessBase64ImageType(gifBase64)).toBe('image/gif');
});
it('should detect WebP from base64 string', () => {
// UklGR is RIFF in base64
const webpBase64 = 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=';
expect(guessBase64ImageType(webpBase64)).toBe('image/webp');
});
it('should detect BMP from base64 string', () => {
// Qk0= is the base64 encoded start of a BMP file (0x42 0x4D)
const bmpBase64 = 'Qk02AgAAAAAAADYAAAAoAAAA';
expect(guessBase64ImageType(bmpBase64)).toBe('image/bmp');
});
it('should fallback to first character mapping for unknown formats', () => {
// Test various first characters from BASE64_PREFIX_MAP
expect(guessBase64ImageType('/')).toBe('image/jpeg');
expect(guessBase64ImageType('i')).toBe('image/png');
expect(guessBase64ImageType('R')).toBe('image/gif');
expect(guessBase64ImageType('U')).toBe('image/webp');
expect(guessBase64ImageType('Q')).toBe('image/bmp');
expect(guessBase64ImageType('P')).toBe('image/svg+xml');
expect(guessBase64ImageType('T')).toBe('image/tiff');
expect(guessBase64ImageType('V')).toBe('image/vnd.microsoft.icon');
});
it('should return default type for unknown first characters', () => {
expect(guessBase64ImageType('xyz')).toBe('image/jpeg');
expect(guessBase64ImageType('123')).toBe('image/jpeg');
expect(guessBase64ImageType('ABC')).toBe('image/jpeg');
});
it('should handle empty or invalid input', () => {
expect(guessBase64ImageType('')).toBe('image/jpeg');
expect(guessBase64ImageType(null as any)).toBe('image/jpeg');
expect(guessBase64ImageType(undefined as any)).toBe('image/jpeg');
expect(guessBase64ImageType(123 as any)).toBe('image/jpeg');
});
it('should handle invalid base64 strings', () => {
// Should fallback to first character mapping
expect(guessBase64ImageType('!!!invalid!!!')).toBe('image/jpeg');
expect(guessBase64ImageType('not-base64')).toBe('image/jpeg');
});
it('should prefer buffer detection over first character mapping', () => {
// Create a valid JPEG base64 that starts with 'i' (which would map to PNG)
// But the actual content is JPEG
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
const base64 = jpegBytes.toString('base64'); // This is "/9j/4A=="
expect(guessBase64ImageType(base64)).toBe('image/jpeg');
});
});

View File

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { getContentTypeFromHeader } from '@fastgpt/service/common/file/utils';
describe('getContentTypeFromHeader', () => {
it('should extract and normalize content type from header', () => {
expect(getContentTypeFromHeader('image/jpeg')).toBe('image/jpeg');
expect(getContentTypeFromHeader('image/png; charset=utf-8')).toBe('image/png');
expect(getContentTypeFromHeader('text/html; charset=UTF-8')).toBe('text/html');
expect(getContentTypeFromHeader('application/json;charset=utf-8')).toBe('application/json');
});
it('should handle uppercase content types and convert to lowercase', () => {
expect(getContentTypeFromHeader('Image/JPEG')).toBe('image/jpeg');
expect(getContentTypeFromHeader('IMAGE/PNG')).toBe('image/png');
expect(getContentTypeFromHeader('Application/JSON')).toBe('application/json');
expect(getContentTypeFromHeader('TEXT/HTML')).toBe('text/html');
});
it('should handle mixed case content types', () => {
expect(getContentTypeFromHeader('Image/Jpeg')).toBe('image/jpeg');
expect(getContentTypeFromHeader('IMAGE/png; charset=UTF-8')).toBe('image/png');
expect(getContentTypeFromHeader('Application/Json')).toBe('application/json');
});
it('should trim whitespace', () => {
expect(getContentTypeFromHeader(' image/jpeg ')).toBe('image/jpeg');
expect(getContentTypeFromHeader(' image/png ; charset=utf-8 ')).toBe('image/png');
expect(getContentTypeFromHeader('text/html ;charset=UTF-8')).toBe('text/html');
});
it('should handle empty or undefined input', () => {
// Empty string after processing results in empty string, not undefined
expect(getContentTypeFromHeader('')).toBe('');
expect(getContentTypeFromHeader(undefined as any)).toBe(undefined);
});
it('should handle content types with multiple parameters', () => {
expect(getContentTypeFromHeader('image/jpeg; charset=utf-8; boundary=something')).toBe(
'image/jpeg'
);
expect(getContentTypeFromHeader('multipart/form-data; boundary=----WebKit')).toBe(
'multipart/form-data'
);
});
it('should handle content types without parameters', () => {
expect(getContentTypeFromHeader('image/webp')).toBe('image/webp');
expect(getContentTypeFromHeader('image/gif')).toBe('image/gif');
expect(getContentTypeFromHeader('image/svg+xml')).toBe('image/svg+xml');
});
it('should handle special image formats', () => {
expect(getContentTypeFromHeader('image/x-icon')).toBe('image/x-icon');
expect(getContentTypeFromHeader('image/vnd.microsoft.icon')).toBe('image/vnd.microsoft.icon');
expect(getContentTypeFromHeader('image/heic')).toBe('image/heic');
expect(getContentTypeFromHeader('image/avif')).toBe('image/avif');
});
it('should handle edge cases with semicolons', () => {
expect(getContentTypeFromHeader('image/jpeg;')).toBe('image/jpeg');
expect(getContentTypeFromHeader('image/png;;')).toBe('image/png');
});
});