From 5231f4281fec96c3b1278ac26df9c277621c8736 Mon Sep 17 00:00:00 2001 From: heheer Date: Thu, 18 Dec 2025 23:25:48 +0800 Subject: [PATCH] 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> --- document/content/docs/upgrading/4-14/4145.mdx | 1 + document/data/doc-last-modified.json | 2 +- .../global/openapi/core/chat/history/api.ts | 2 +- .../service/common/file/image/controller.ts | 3 +- packages/service/common/file/image/utils.ts | 116 ++++++++++- packages/service/common/file/utils.ts | 33 +-- packages/service/core/ai/embedding/index.ts | 115 ++++++----- packages/service/core/ai/llm/request.ts | 28 ++- packages/service/core/ai/rerank/index.ts | 6 +- .../core/dataset/SearchParamsTip.tsx | 10 +- .../app/detail/SimpleApp/EditForm.tsx | 2 +- .../templates/SelectDatasetParams.tsx | 2 +- .../pageComponents/dataset/detail/Test.tsx | 2 +- .../api/core/chat/history/getHistories.ts | 8 + .../support/user/account/loginByPassword.ts | 25 +-- .../core/chat/feedback/closeCustom.test.ts | 4 +- .../feedback/getFeedbackRecordIds.test.ts | 4 +- .../feedback/updateFeedbackReadStatus.test.ts | 5 +- .../chat/feedback/updateUserFeedback.test.ts | 4 +- .../core/chat/history/getHistories.test.ts | 44 +++- .../user/account/loginByPassword.test.ts | 86 ++++++-- .../service/common/file/image/utils.test.ts | 189 ++++++++++++++++++ test/cases/service/common/file/utils.test.ts | 63 ++++++ 23 files changed, 605 insertions(+), 149 deletions(-) create mode 100644 test/cases/service/common/file/image/utils.test.ts create mode 100644 test/cases/service/common/file/utils.test.ts diff --git a/document/content/docs/upgrading/4-14/4145.mdx b/document/content/docs/upgrading/4-14/4145.mdx index 186a5efa3..6fa0cfbeb 100644 --- a/document/content/docs/upgrading/4-14/4145.mdx +++ b/document/content/docs/upgrading/4-14/4145.mdx @@ -16,6 +16,7 @@ description: 'FastGPT V4.14.5 更新说明' 1. MCP 工具创建时,使用自定义鉴权头会报错。 2. 获取对话日志列表时,如果用户头像为空,会抛错。 +3. chatAgent 未开启问题优化时,前端 UI 显示开启。 ## 插件 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index c7ab6a0f5..301fb3e29 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -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", diff --git a/packages/global/openapi/core/chat/history/api.ts b/packages/global/openapi/core/chat/history/api.ts index 70fa76d49..04a03bf12 100644 --- a/packages/global/openapi/core/chat/history/api.ts +++ b/packages/global/openapi/core/chat/history/api.ts @@ -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('创建时间结束'), diff --git a/packages/service/common/file/image/controller.ts b/packages/service/common/file/image/controller.ts index 6c6308548..5c5e4fb11 100644 --- a/packages/service/common/file/image/controller.ts +++ b/packages/service/common/file/image/controller.ts @@ -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'; diff --git a/packages/service/common/file/image/utils.ts b/packages/service/common/file/image/utils.ts index a61953ad3..a3f636adf 100644 --- a/packages/service/common/file/image/utils.ts +++ b/packages/service/common/file/image/utils.ts @@ -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 = { + '/': '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}`, diff --git a/packages/service/common/file/utils.ts b/packages/service/common/file/utils.ts index b1cda4ad2..2b1a641ff 100644 --- a/packages/service/common/file/utils.ts +++ b/packages/service/common/file/utils.ts @@ -17,37 +17,8 @@ export const removeFilesByPaths = (paths: string[]) => { }); }; -export const guessBase64ImageType = (str: string) => { - const imageTypeMap: Record = { - '/': '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) => { diff --git a/packages/service/core/ai/embedding/index.ts b/packages/service/core/ai/embedding/index.ts index be26cbf24..5f81a2c62 100644 --- a/packages/service/core/ai/embedding/index.ts +++ b/packages/service/core/ai/embedding/index.ts @@ -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); } diff --git a/packages/service/core/ai/llm/request.ts b/packages/service/core/ai/llm/request.ts index e7ce08a66..1d132c129 100644 --- a/packages/service/core/ai/llm/request.ts +++ b/packages/service/core/ai/llm/request.ts @@ -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); } diff --git a/packages/service/core/ai/rerank/index.ts b/packages/service/core/ai/rerank/index.ts index b8b8f56ca..e5db1eae1 100644 --- a/packages/service/core/ai/rerank/index.ts +++ b/packages/service/core/ai/rerank/index.ts @@ -35,7 +35,7 @@ export function reRankRecall({ headers?: Record; }): Promise { 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); }); diff --git a/projects/app/src/components/core/dataset/SearchParamsTip.tsx b/projects/app/src/components/core/dataset/SearchParamsTip.tsx index b85ab81d7..89e236268 100644 --- a/projects/app/src/components/core/dataset/SearchParamsTip.tsx +++ b/projects/app/src/components/core/dataset/SearchParamsTip.tsx @@ -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 = ({ )} - {extensionModelName ? extensionModelName : '❌'} + {extensionModelName || '❌'} {hasEmptyResponseMode && {responseEmptyText !== '' ? '✅' : '❌'}} diff --git a/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx b/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx index 217a35ee6..927fdd558 100644 --- a/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx +++ b/projects/app/src/pageComponents/app/detail/SimpleApp/EditForm.tsx @@ -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} /> diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDatasetParams.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDatasetParams.tsx index cebbc2350..41870a137 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDatasetParams.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/SelectDatasetParams.tsx @@ -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} /> diff --git a/projects/app/src/pageComponents/dataset/detail/Test.tsx b/projects/app/src/pageComponents/dataset/detail/Test.tsx index 5cae30269..61e59c82f 100644 --- a/projects/app/src/pageComponents/dataset/detail/Test.tsx +++ b/projects/app/src/pageComponents/dataset/detail/Test.tsx @@ -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} /> diff --git a/projects/app/src/pages/api/core/chat/history/getHistories.ts b/projects/app/src/pages/api/core/chat/history/getHistories.ts index 435afc3ac..d4dfc7af9 100644 --- a/projects/app/src/pages/api/core/chat/history/getHistories.ts +++ b/projects/app/src/pages/api/core/chat/history/getHistories.ts @@ -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 = {}; if (startCreateTime || endCreateTime) { timeMatch.createTime = { diff --git a/projects/app/src/pages/api/support/user/account/loginByPassword.ts b/projects/app/src/pages/api/support/user/account/loginByPassword.ts index b191ff344..6e38138c4 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -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, diff --git a/projects/app/test/api/core/chat/feedback/closeCustom.test.ts b/projects/app/test/api/core/chat/feedback/closeCustom.test.ts index b866e814c..b730c5730 100644 --- a/projects/app/test/api/core/chat/feedback/closeCustom.test.ts +++ b/projects/app/test/api/core/chat/feedback/closeCustom.test.ts @@ -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( handler, diff --git a/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts b/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts index 1887dd153..7151f9b0f 100644 --- a/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts +++ b/projects/app/test/api/core/chat/feedback/getFeedbackRecordIds.test.ts @@ -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( handler, diff --git a/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts b/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts index 3b629e873..ceac2d206 100644 --- a/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts +++ b/projects/app/test/api/core/chat/feedback/updateFeedbackReadStatus.test.ts @@ -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, diff --git a/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts b/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts index 69f791ce8..0b8cf98ad 100644 --- a/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts +++ b/projects/app/test/api/core/chat/feedback/updateUserFeedback.test.ts @@ -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( handler, diff --git a/projects/app/test/api/core/chat/history/getHistories.test.ts b/projects/app/test/api/core/chat/history/getHistories.test.ts index 004476311..e6afb3ef1 100644 --- a/projects/app/test/api/core/chat/history/getHistories.test.ts +++ b/projects/app/test/api/core/chat/history/getHistories.test.ts @@ -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(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(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(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); + }); }); diff --git a/projects/app/test/api/support/user/account/loginByPassword.test.ts b/projects/app/test/api/support/user/account/loginByPassword.test.ts index 7d1782ab8..5be89805b 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -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(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(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(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(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(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)); + }); }); diff --git a/test/cases/service/common/file/image/utils.test.ts b/test/cases/service/common/file/image/utils.test.ts new file mode 100644 index 000000000..5c0ed405f --- /dev/null +++ b/test/cases/service/common/file/image/utils.test.ts @@ -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'); + }); +}); diff --git a/test/cases/service/common/file/utils.test.ts b/test/cases/service/common/file/utils.test.ts new file mode 100644 index 000000000..e1fe12e89 --- /dev/null +++ b/test/cases/service/common/file/utils.test.ts @@ -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'); + }); +});