🐛 fix: normalize image MIME from bytes (#15172)

This commit is contained in:
YuTengjing
2026-05-25 00:32:55 +08:00
committed by GitHub
parent f16c280e93
commit d71686ba88
14 changed files with 421 additions and 56 deletions
@@ -13,10 +13,13 @@ import {
// Mock the parseDataUri function since it's an implementation detail
vi.mock('../../utils/uriParser');
vi.mock('@lobechat/utils', () => ({
vi.mock('@lobechat/utils', async (importOriginal) => ({
...((await importOriginal()) as object),
imageUrlToBase64: vi.fn(),
}));
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
describe('anthropicHelpers', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -52,6 +55,30 @@ describe('anthropicHelpers', () => {
});
});
it('should correct data URL MIME type when declared type does not match image bytes', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
mimeType: 'image/jpeg',
base64: PNG_BASE64,
type: 'base64',
});
const content: UserMessageContentPart = {
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${PNG_BASE64}` },
};
const result = await buildAnthropicBlock(content);
expect(result).toEqual({
source: {
data: PNG_BASE64,
media_type: 'image/png',
type: 'base64',
},
type: 'image',
});
});
it('should convert URL to base64 for image URLs', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
mimeType: 'image/png',
@@ -1,5 +1,5 @@
import type Anthropic from '@anthropic-ai/sdk';
import { imageUrlToBase64 } from '@lobechat/utils';
import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/utils';
import type OpenAI from 'openai';
import type { OpenAIChatMessage, UserMessageContentPart } from '../../types';
@@ -13,10 +13,8 @@ const ANTHROPIC_SUPPORTED_IMAGE_TYPES = new Set([
'image/webp',
]);
const isImageTypeSupported = (mimeType: string | null): boolean => {
if (!mimeType) return true;
return ANTHROPIC_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
};
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
!!mimeType && ANTHROPIC_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
/**
* Check if a text value contains visible (non-whitespace) characters.
@@ -43,12 +41,14 @@ export const buildAnthropicBlock = async (
const { mimeType, base64, type } = parseDataUri(content.image_url.url);
if (type === 'base64') {
if (!isImageTypeSupported(mimeType)) return undefined;
const resolvedMimeType = await resolveImageMimeTypeFromBase64(mimeType, base64);
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
return {
source: {
data: base64 as string,
media_type: mimeType as Anthropic.Base64ImageSource['media_type'],
media_type: resolvedMimeType as Anthropic.Base64ImageSource['media_type'],
type: 'base64',
},
type: 'image',
@@ -22,6 +22,8 @@ vi.mock('../../utils/imageToBase64', () => ({
imageUrlToBase64: vi.fn(),
}));
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
describe('google contextBuilders', () => {
describe('GEMINI_MAGIC_THOUGHT_SIGNATURE', () => {
it('should use skip_thought_signature_validator for Vertex AI compatibility', () => {
@@ -82,6 +84,29 @@ describe('google contextBuilders', () => {
});
});
it('should correct base64 image MIME type when declared type does not match bytes', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: PNG_BASE64,
mimeType: 'image/jpeg',
type: 'base64',
});
const content: UserMessageContentPart = {
image_url: { url: `data:image/jpeg;base64,${PNG_BASE64}` },
type: 'image_url',
};
const result = await buildGooglePart(content);
expect(result).toEqual({
inlineData: {
data: PNG_BASE64,
mimeType: 'image/png',
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
});
});
it('should handle URL type images', async () => {
const imageUrl = 'http://example.com/image.png';
const mockBase64 = 'mockBase64Data';
@@ -4,7 +4,7 @@ import type {
Part,
Tool as GoogleFunctionCallTool,
} from '@google/genai';
import { imageUrlToBase64 } from '@lobechat/utils';
import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/utils';
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
import { safeParseJSON } from '../../utils/safeParseJSON';
@@ -18,10 +18,8 @@ const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([
'image/webp',
]);
const isImageTypeSupported = (mimeType: string | null): boolean => {
if (!mimeType) return true;
return GOOGLE_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
};
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
!!mimeType && GOOGLE_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
/**
* Magic thoughtSignature to bypass Gemini thought signature validation.
@@ -58,10 +56,12 @@ export const buildGooglePart = async (
throw new TypeError("Image URL doesn't contain base64 data");
}
if (!isImageTypeSupported(mimeType)) return undefined;
const resolvedMimeType = await resolveImageMimeTypeFromBase64(mimeType, base64);
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
return {
inlineData: { data: base64, mimeType: mimeType || 'image/png' },
inlineData: { data: base64, mimeType: resolvedMimeType },
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
};
}
+1
View File
@@ -25,6 +25,7 @@
"debug": "^4.4.3",
"dompurify": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"file-type": "^21.3.0",
"mime": "^4.1.0",
"model-bank": "workspace:*",
"nanoid": "^5.1.6",
+45 -7
View File
@@ -130,24 +130,50 @@ describe('compressImageFile', () => {
return new File([content], name, { type });
};
it('should skip compression for small images', async () => {
const file = createMockFile('small.png', 'image/png', 1000);
// Mock Image load with small dimensions
const mockImageLoad = (width: number, height: number) => {
const originalImage = global.Image;
global.Image = class MockImage extends originalImage {
constructor() {
super();
Object.defineProperty(this, 'width', { value: 800, writable: false });
Object.defineProperty(this, 'height', { value: 600, writable: false });
Object.defineProperty(this, 'width', { value: width, writable: false });
Object.defineProperty(this, 'height', { value: height, writable: false });
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
}
} as any;
return () => {
global.Image = originalImage;
};
};
it('should skip compression for small images', async () => {
const file = createMockFile('small.png', 'image/png', 1000);
const restoreImage = mockImageLoad(800, 600);
const result = await compressImageFile(file);
expect(result).toBe(file); // same reference, no compression
global.Image = originalImage;
restoreImage();
});
it('should correct MIME type for small images when declared type does not match bytes', async () => {
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
0x15, 0xc4, 0x89,
]);
const file = new File([pngBytes], 'mislabelled.jpg', { type: 'image/jpeg' });
const restoreImage = mockImageLoad(800, 600);
const result = await compressImageFile(file);
expect(result).not.toBe(file);
expect(result.type).toBe('image/png');
expect(result.name).toBe('mislabelled.jpg');
expect([...new Uint8Array(await result.arrayBuffer())]).toEqual([...pngBytes]);
restoreImage();
});
it('should compress images exceeding max dimensions', async () => {
@@ -253,4 +279,16 @@ describe('compressImageFile', () => {
expect(result).toBe(file);
global.Image = originalImage;
});
it('should resolve original file when MIME correction fails after image load', async () => {
const file = createMockFile('broken-buffer.png', 'image/png', 1000);
vi.spyOn(file, 'arrayBuffer').mockRejectedValue(new Error('Failed to read file'));
const restoreImage = mockImageLoad(800, 600);
const result = await compressImageFile(file);
expect(result).toBe(file);
restoreImage();
});
});
+39 -19
View File
@@ -1,3 +1,5 @@
import { inferImageMimeTypeFromBytes } from './imageMimeType';
export const MAX_IMAGE_SIZE = 1920;
// Anthropic enforces a 5MB cap on the base64-encoded image payload. Base64
// inflates binary by ~4/3, so a 3MB binary file maps to ~4MB base64 — gives
@@ -58,34 +60,52 @@ const dataUrlToFile = (dataUrl: string, name: string): File => {
return new File([bytes], name, { type: mimeType });
};
const correctImageFileType = async (file: File): Promise<File> => {
const detectedMimeType = await inferImageMimeTypeFromBytes(await file.arrayBuffer());
if (!detectedMimeType || detectedMimeType === file.type) return file;
return new File([file], file.name, {
lastModified: file.lastModified,
type: detectedMimeType,
});
};
export const compressImageFile = (file: File): Promise<File> =>
new Promise((resolve) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.addEventListener('load', () => {
img.addEventListener('load', async () => {
URL.revokeObjectURL(objectUrl);
// skip if image is small enough in both dimensions and file size
if (
img.width <= MAX_IMAGE_SIZE &&
img.height <= MAX_IMAGE_SIZE &&
file.size <= MAX_IMAGE_BYTES
) {
try {
const normalizedFile = await correctImageFileType(file);
const outputType = normalizedFile.type;
// skip if image is small enough in both dimensions and file size
if (
img.width <= MAX_IMAGE_SIZE &&
img.height <= MAX_IMAGE_SIZE &&
normalizedFile.size <= MAX_IMAGE_BYTES
) {
resolve(normalizedFile);
return;
}
// progressively shrink until under 5MB
let maxSize = MAX_IMAGE_SIZE;
let result: File;
do {
const dataUrl = compressImage({ img, maxSize, type: outputType });
result = dataUrlToFile(dataUrl, normalizedFile.name);
maxSize = Math.round(maxSize * 0.8);
} while (result.size > MAX_IMAGE_BYTES && maxSize > 100);
resolve(result);
} catch {
resolve(file);
return;
}
// progressively shrink until under 5MB
let maxSize = MAX_IMAGE_SIZE;
let result: File;
do {
const dataUrl = compressImage({ img, maxSize, type: file.type });
result = dataUrlToFile(dataUrl, file.name);
maxSize = Math.round(maxSize * 0.8);
} while (result.size > MAX_IMAGE_BYTES && maxSize > 100);
resolve(result);
});
img.addEventListener('error', () => {
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
inferImageMimeTypeFromBase64,
inferImageMimeTypeFromBytes,
inferMimeTypeFromBytes,
resolveImageMimeTypeFromBase64,
resolveImageMimeTypeFromBytes,
resolveMimeTypeFromBytes,
} from './imageMimeType';
const PNG_BYTES = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89,
]);
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const GIF_BYTES = new TextEncoder().encode('GIF89a');
const WEBP_BYTES = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
]);
const PDF_BYTES = new TextEncoder().encode('%PDF-1.7\n');
describe('imageMimeType', () => {
it('should infer common image MIME types from bytes', async () => {
expect(await inferImageMimeTypeFromBytes(PNG_BYTES)).toBe('image/png');
expect(await inferImageMimeTypeFromBytes(JPEG_BYTES)).toBe('image/jpeg');
expect(await inferImageMimeTypeFromBytes(GIF_BYTES)).toBe('image/gif');
expect(await inferImageMimeTypeFromBytes(WEBP_BYTES)).toBe('image/webp');
});
it('should return undefined for unrecognized bytes', async () => {
expect(await inferImageMimeTypeFromBytes(new Uint8Array([0x00, 0x01, 0x02]))).toBeUndefined();
});
it('should infer image MIME type from base64 data', async () => {
expect(await inferImageMimeTypeFromBase64(PNG_BASE64)).toBe('image/png');
});
it('should infer non-image MIME types from bytes', async () => {
expect(await inferMimeTypeFromBytes(PDF_BYTES)).toBe('application/pdf');
});
it('should prefer detected bytes over a wrong declared MIME type', async () => {
expect(await resolveImageMimeTypeFromBytes('image/jpeg', PNG_BYTES)).toBe('image/png');
expect(await resolveImageMimeTypeFromBase64('image/jpeg', PNG_BASE64)).toBe('image/png');
});
it('should fall back to declared image MIME type when bytes are not recognized', async () => {
expect(
await resolveImageMimeTypeFromBytes('image/jpeg; charset=utf-8', new Uint8Array([1])),
).toBe('image/jpeg');
});
it('should not fabricate an image MIME type when no image signal is available', async () => {
expect(await resolveImageMimeTypeFromBase64('', 'not-valid-base64')).toBeUndefined();
expect(await resolveImageMimeTypeFromBytes('', PDF_BYTES)).toBeUndefined();
});
it('should resolve generic MIME types without defaulting unknown bytes to image/png', async () => {
expect(await resolveMimeTypeFromBytes('', PDF_BYTES)).toBe('application/pdf');
expect(await resolveMimeTypeFromBytes('', new Uint8Array([1]))).toBe(
'application/octet-stream',
);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { fileTypeFromBuffer } from 'file-type';
const normalizeMimeType = (mimeType: string | null | undefined): string => {
return mimeType?.split(';')[0]?.trim().toLowerCase() ?? '';
};
const getBytes = (input: ArrayBuffer | Uint8Array): Uint8Array =>
input instanceof Uint8Array ? input : new Uint8Array(input);
const normalizeDetectedMimeType = (mimeType: string | undefined): string | undefined => {
const normalizedMimeType = normalizeMimeType(mimeType);
return normalizedMimeType || undefined;
};
const normalizeDetectedImageMimeType = (mimeType: string | undefined): string | undefined => {
const normalizedMimeType = normalizeDetectedMimeType(mimeType);
if (!normalizedMimeType?.startsWith('image/')) return undefined;
return normalizedMimeType === 'image/jpg' ? 'image/jpeg' : normalizedMimeType;
};
const decodeBase64Header = (base64: string): Uint8Array | undefined => {
const header = base64.replaceAll(/\s/g, '').slice(0, 64);
if (!header) return undefined;
try {
const binary = atob(header);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
} catch {
return undefined;
}
};
export const inferMimeTypeFromBytes = async (
input: ArrayBuffer | Uint8Array,
): Promise<string | undefined> => {
const fileType = await fileTypeFromBuffer(getBytes(input));
return normalizeDetectedMimeType(fileType?.mime);
};
export const inferImageMimeTypeFromBytes = async (
input: ArrayBuffer | Uint8Array,
): Promise<string | undefined> => {
return normalizeDetectedImageMimeType(await inferMimeTypeFromBytes(input));
};
export const inferImageMimeTypeFromBase64 = async (base64: string | null | undefined) => {
if (!base64) return undefined;
const bytes = decodeBase64Header(base64);
if (!bytes) return undefined;
return await inferImageMimeTypeFromBytes(bytes);
};
export const resolveImageMimeTypeFromBytes = async (
declaredMimeType: string | null | undefined,
input: ArrayBuffer | Uint8Array,
): Promise<string | undefined> => {
return (
(await inferImageMimeTypeFromBytes(input)) ??
normalizeDetectedImageMimeType(declaredMimeType ?? undefined)
);
};
export const resolveImageMimeTypeFromBase64 = async (
declaredMimeType: string | null | undefined,
base64: string | null | undefined,
): Promise<string | undefined> => {
return (
(await inferImageMimeTypeFromBase64(base64)) ??
normalizeDetectedImageMimeType(declaredMimeType ?? undefined)
);
};
export const resolveMimeTypeFromBytes = async (
declaredMimeType: string | null | undefined,
input: ArrayBuffer | Uint8Array,
): Promise<string> => {
const declared = normalizeMimeType(declaredMimeType);
return (await inferMimeTypeFromBytes(input)) ?? (declared || 'application/octet-stream');
};
+28
View File
@@ -82,6 +82,34 @@ describe('imageUrlToBase64', () => {
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'image/jpg' });
});
it('should correct MIME type when response metadata does not match image bytes', async () => {
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
0x15, 0xc4, 0x89,
]);
mockFetch.mockResolvedValue({
blob: () => Promise.resolve(new Blob([pngBytes], { type: 'image/jpeg' })),
});
const result = await imageUrlToBase64('https://example.com/image.jpg');
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'image/png' });
});
it('should preserve detected non-image MIME types when response metadata is empty', async () => {
const pdfBytes = new TextEncoder().encode('%PDF-1.7\n');
mockFetch.mockResolvedValue({
blob: () => Promise.resolve(new Blob([pdfBytes], { type: '' })),
});
const result = await imageUrlToBase64('https://example.com/file');
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'application/pdf' });
});
it('should throw an error when fetch fails', async () => {
const mockError = new Error('Fetch failed');
mockFetch.mockRejectedValue(mockError);
+4 -1
View File
@@ -1,5 +1,7 @@
import { Buffer } from 'buffer.js';
import { resolveMimeTypeFromBytes } from './imageMimeType';
export const imageToBase64 = ({
size,
img,
@@ -55,6 +57,7 @@ export const imageUrlToBase64 = async (
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const mimeType = await resolveMimeTypeFromBytes(blob.type, arrayBuffer);
// Client-side uses btoa, server-side uses Buffer
const base64 = isServer
@@ -63,7 +66,7 @@ export const imageUrlToBase64 = async (
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), ''),
);
return { base64, mimeType: blob.type };
return { base64, mimeType };
} catch (error) {
console.error('Error converting image to base64:', error);
throw error;
+1
View File
@@ -8,6 +8,7 @@ export * from './env';
export * from './error';
export * from './folderStructure';
export * from './format';
export * from './imageMimeType';
export * from './imageToBase64';
export * from './jina';
export * from './keyboard';
+59 -6
View File
@@ -427,6 +427,59 @@ describe('FileUploadAction', () => {
);
});
it('should correct image file type from bytes when file.type is wrong', async () => {
const { result } = renderHook(() => useStore());
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
0x15, 0xc4, 0x89,
]);
const mockFile = new File([pngBytes], 'mislabelled.jpg', { type: 'image/jpeg' });
const mockMetadata = {
date: '12345',
dirname: '/uploads',
filename: 'mislabelled.jpg',
path: '/uploads/mislabelled.jpg',
};
const mockCheckResult = { isExist: false };
const mockUploadResult = { data: mockMetadata, success: true };
const mockFileResponse = {
id: 'file-id-mislabelled',
url: 'https://example.com/mislabelled.jpg',
};
const { fileTypeFromBuffer } = await import('file-type');
vi.spyOn(mockFile, 'arrayBuffer').mockResolvedValue(pngBytes.buffer);
vi.mocked(fileTypeFromBuffer).mockResolvedValue({ ext: 'png', mime: 'image/png' } as any);
vi.mocked(getImageDimensions).mockResolvedValue(undefined);
vi.spyOn(fileService, 'checkFileHash').mockResolvedValue(mockCheckResult);
const uploadSpy = vi
.spyOn(uploadService, 'uploadFileToS3')
.mockResolvedValue(mockUploadResult);
vi.spyOn(fileService, 'createFile').mockResolvedValue(mockFileResponse);
await act(async () => {
await result.current.uploadWithProgress({
file: mockFile,
});
});
const uploadedFile = uploadSpy.mock.calls[0]?.[0];
expect(uploadedFile).toBeInstanceOf(File);
expect(uploadedFile).not.toBe(mockFile);
expect(uploadedFile?.name).toBe('mislabelled.jpg');
expect(uploadedFile?.type).toBe('image/png');
expect(fileService.createFile).toHaveBeenCalledWith(
expect.objectContaining({
fileType: 'image/png',
name: 'mislabelled.jpg',
}),
undefined,
);
});
it('should detect file type from buffer when file.type is empty', async () => {
const { result } = renderHook(() => useStore());
@@ -640,7 +693,7 @@ describe('FileUploadAction', () => {
describe('error handling', () => {
it('should handle checkFileHash errors', async () => {
const { result } = renderHook(() => useStore());
const { uploadWithProgress } = useStore.getState();
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
@@ -649,7 +702,7 @@ describe('FileUploadAction', () => {
await expect(
act(async () => {
await result.current.uploadWithProgress({
await uploadWithProgress({
file: mockFile,
});
}),
@@ -657,7 +710,7 @@ describe('FileUploadAction', () => {
});
it('should handle uploadFileToS3 errors', async () => {
const { result } = renderHook(() => useStore());
const { uploadWithProgress } = useStore.getState();
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
const mockCheckResult = { isExist: false };
@@ -668,7 +721,7 @@ describe('FileUploadAction', () => {
await expect(
act(async () => {
await result.current.uploadWithProgress({
await uploadWithProgress({
file: mockFile,
});
}),
@@ -676,7 +729,7 @@ describe('FileUploadAction', () => {
});
it('should handle createFile errors', async () => {
const { result } = renderHook(() => useStore());
const { uploadWithProgress } = useStore.getState();
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
const mockMetadata = {
@@ -695,7 +748,7 @@ describe('FileUploadAction', () => {
await expect(
act(async () => {
await result.current.uploadWithProgress({
await uploadWithProgress({
file: mockFile,
});
}),
+24 -8
View File
@@ -1,4 +1,5 @@
import { LOBE_CHAT_CLOUD } from '@lobechat/business-const';
import { inferImageMimeTypeFromBytes } from '@lobechat/utils';
import { t } from 'i18next';
import { sha256 } from 'js-sha256';
@@ -53,6 +54,20 @@ interface UploadWithProgressResult {
url: string;
}
const normalizeUploadedImageFileType = async (
file: File,
fileArrayBuffer: ArrayBuffer,
): Promise<File> => {
const detectedMimeType = await inferImageMimeTypeFromBytes(fileArrayBuffer);
if (!detectedMimeType || detectedMimeType === file.type) return file;
return new File([file], file.name, {
lastModified: file.lastModified,
type: detectedMimeType,
});
};
type Setter = StoreSetter<FileStore>;
export const createFileUploadSlice = (set: Setter, get: () => FileStore, _api?: unknown) =>
new FileUploadActionImpl(set, get, _api);
@@ -97,9 +112,10 @@ export class FileUploadActionImpl {
try {
const fileArrayBuffer = await file.arrayBuffer();
const normalizedFile = await normalizeUploadedImageFileType(file, fileArrayBuffer);
// 1. extract image dimensions if applicable
const dimensions = await getImageDimensions(file);
const dimensions = await getImageDimensions(normalizedFile);
// 2. check file hash
const hash = sha256(fileArrayBuffer);
@@ -118,14 +134,14 @@ export class FileUploadActionImpl {
}
// 3. if file don't exist, need upload files
else {
const { data, success } = await uploadService.uploadFileToS3(file, {
const { data, success } = await uploadService.uploadFileToS3(normalizedFile, {
abortController,
onNotSupported: () => {
onStatusUpdate?.({ id: statusId, type: 'removeFile' });
message.info({
content: t('upload.fileOnlySupportInServerMode', {
cloud: LOBE_CHAT_CLOUD,
ext: file.name.split('.').pop(),
ext: normalizedFile.name.split('.').pop(),
ns: 'error',
}),
duration: 5,
@@ -146,9 +162,9 @@ export class FileUploadActionImpl {
}
// 4. use more powerful file type detector to get file type
let fileType = file.type;
let fileType = normalizedFile.type;
if (!file.type) {
if (!normalizedFile.type) {
const { fileTypeFromBuffer } = await import('file-type');
const type = await fileTypeFromBuffer(fileArrayBuffer);
@@ -161,9 +177,9 @@ export class FileUploadActionImpl {
fileType,
hash,
metadata,
name: file.name,
name: normalizedFile.name,
parentId,
size: file.size,
size: normalizedFile.size,
source,
url: metadata.path || checkStatus.url,
},
@@ -181,7 +197,7 @@ export class FileUploadActionImpl {
},
});
return { ...data, dimensions, filename: file.name };
return { ...data, dimensions, filename: normalizedFile.name };
} catch (error) {
// Handle file storage plan limit error
if ((error as any)?.message?.includes('beyond the plan limit')) {