From ab804a6d2e0219db40c5fd021ce7f70d56c68b9c Mon Sep 17 00:00:00 2001 From: JeongCheolLee Date: Tue, 6 Oct 2026 15:27:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Convert=20Up?= =?UTF-8?q?loaded=20Images=20Whose=20Bytes=20Are=20Not=20in=20the=20Output?= =?UTF-8?q?=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resizeImageBuffer` already reports the media type of the bytes it returns, and `saveBase64Image` reads it (#15606). The upload path drops it and asks the file name instead, so a file whose name already carries the output extension skips conversion whatever is inside it. A JPEG named `photo.png` is then written as JPEG bytes and recorded `image/png`. Conversion is now skipped only when the name and the bytes both match `imageOutputType`, across every strategy that made the decision from the extension alone: local, Firebase, Azure, the shared `ImageService` behind S3 and CloudFront, and `convertImage`. An unnamed format converts too, since bytes sharp could not name are not evidence of a match. --- api/server/services/Files/Azure/images.js | 8 +- api/server/services/Files/Firebase/images.js | 8 +- .../Files/Local/__tests__/images.spec.js | 87 ++++++++++++++++++- api/server/services/Files/Local/images.js | 10 ++- api/server/services/Files/images/convert.js | 6 +- .../api/src/storage/__tests__/images.test.ts | 50 +++++++++-- packages/api/src/storage/images.ts | 13 ++- 7 files changed, 168 insertions(+), 14 deletions(-) diff --git a/api/server/services/Files/Azure/images.js b/api/server/services/Files/Azure/images.js index 4d857d0d1db..9905465d3ef 100644 --- a/api/server/services/Files/Azure/images.js +++ b/api/server/services/Files/Azure/images.js @@ -37,6 +37,7 @@ async function uploadImageToAzure({ buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); const userId = req.user.id; @@ -44,7 +45,12 @@ async function uploadImageToAzure({ let fileName = `${file_id}__${path.basename(inputFilePath)}`; const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { webPBuffer = resizedBuffer; } else { webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js index cffafa890cb..a0f94d6a7c6 100644 --- a/api/server/services/Files/Firebase/images.js +++ b/api/server/services/Files/Firebase/images.js @@ -32,6 +32,7 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); const userId = req.user.id; @@ -39,7 +40,12 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution let webPBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { webPBuffer = resizedBuffer; } else { webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/api/server/services/Files/Local/__tests__/images.spec.js b/api/server/services/Files/Local/__tests__/images.spec.js index f64d5477655..28a3cf6dfab 100644 --- a/api/server/services/Files/Local/__tests__/images.spec.js +++ b/api/server/services/Files/Local/__tests__/images.spec.js @@ -1,4 +1,8 @@ -jest.mock('sharp', () => ({})); +jest.mock('sharp', () => { + const toBuffer = jest.fn(async () => Buffer.from('converted-bytes')); + const sharpMock = jest.fn(() => ({ toFormat: jest.fn().mockReturnThis(), toBuffer })); + return sharpMock; +}); jest.mock('@librechat/api', () => ({ stripCacheBust: jest.fn((filepath) => filepath.split('?')[0]), })); @@ -11,8 +15,10 @@ jest.mock('~/models', () => ({ const fs = require('fs'); const os = require('os'); const path = require('path'); +const sharp = require('sharp'); const { updateFile } = require('~/models'); -const { prepareImagesLocal } = require('../images'); +const { resizeImageBuffer } = require('../../images/resize'); +const { prepareImagesLocal, uploadLocalImage } = require('../images'); describe('prepareImagesLocal', () => { let tmpDir; @@ -62,3 +68,80 @@ describe('prepareImagesLocal', () => { expect(encoded).toBe(Buffer.from('plain-png-bytes').toString('base64')); }); }); + +describe('uploadLocalImage', () => { + let tmpDir; + let imageOutput; + + beforeEach(() => { + jest.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-local-image-')); + imageOutput = path.join(tmpDir, 'images'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const makeReq = (imageOutputType) => ({ + user: { id: 'user-1' }, + config: { paths: { imageOutput }, imageOutputType }, + }); + + /** The caller records the upload as `image/${imageOutputType}`, so whatever this writes has to + * be in that format — the type is handed to providers verbatim as `media_type`. */ + const upload = async (name, resized) => { + const inputFilePath = path.join(tmpDir, name); + fs.writeFileSync(inputFilePath, Buffer.from('uploaded-bytes')); + resizeImageBuffer.mockResolvedValue({ width: 8, height: 8, ...resized }); + + const { filepath } = await uploadLocalImage({ + req: makeReq('png'), + file: { path: inputFilePath }, + file_id: 'file-1', + endpoint: 'anthropic', + }); + + return fs.readFileSync(path.join(imageOutput, 'user-1', path.basename(filepath))); + }; + + it('converts a JPEG that was given a .png name', async () => { + const written = await upload('fake.png', { + buffer: Buffer.from('resized-jpeg-bytes'), + type: 'image/jpeg', + }); + + expect(sharp).toHaveBeenCalledWith(Buffer.from('resized-jpeg-bytes')); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); + + it('writes the resized bytes untouched when the name and the bytes both match the target', async () => { + const written = await upload('real.png', { + buffer: Buffer.from('resized-png-bytes'), + type: 'image/png', + }); + + expect(sharp).not.toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('resized-png-bytes')); + }); + + it('converts when sharp named no format for the resized bytes', async () => { + const written = await upload('unknown.png', { + buffer: Buffer.from('resized-unknown-bytes'), + type: undefined, + }); + + expect(sharp).toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); + + it('still converts a name that does not carry the target extension', async () => { + const written = await upload('photo.jpg', { + buffer: Buffer.from('resized-jpeg-bytes'), + type: 'image/jpeg', + }); + + expect(sharp).toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); +}); diff --git a/api/server/services/Files/Local/images.js b/api/server/services/Files/Local/images.js index ebfaf1e4c43..073f3700a28 100644 --- a/api/server/services/Files/Local/images.js +++ b/api/server/services/Files/Local/images.js @@ -36,6 +36,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); @@ -50,7 +51,14 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi const newPath = path.join(userPath, fileName); const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents, so conversion is skipped only when the bytes + * agree with it. A JPEG named `.png` otherwise reaches `createFile` typed `image/png`, and that + * type is handed to providers verbatim; Anthropic then refuses the request, and keeps refusing + * it, because the image stays in the conversation's history. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { const bytes = Buffer.byteLength(resizedBuffer); await fs.promises.writeFile(newPath, resizedBuffer); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(newPath)); diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 09d8c0ecc3e..610fca5fbf1 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -38,11 +38,13 @@ async function convertImage(req, file, resolution = 'high', basename = '') { buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution); - // Check if the file is already in target format; if it isn't, convert it: + // Check if the file is already in target format; if it isn't, convert it. The name is only a + // claim about the contents, so the bytes have to agree with it before conversion is skipped. const targetExtension = `.${appConfig.imageOutputType}`; - if (extension === targetExtension) { + if (extension === targetExtension && type === `image/${appConfig.imageOutputType}`) { outputBuffer = resizedBuffer; } else { outputBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/packages/api/src/storage/__tests__/images.test.ts b/packages/api/src/storage/__tests__/images.test.ts index ec8f71910ec..a99d29a047e 100644 --- a/packages/api/src/storage/__tests__/images.test.ts +++ b/packages/api/src/storage/__tests__/images.test.ts @@ -43,6 +43,7 @@ describe('ImageService', () => { buffer: Buffer.from('resized'), width: 800, height: 600, + type: 'image/jpeg', }), updateUser: jest.fn().mockResolvedValue(undefined), updateFile: jest.fn().mockResolvedValue(null), @@ -108,11 +109,18 @@ describe('ImageService', () => { ).rejects.toThrow('User not authenticated'); }); - it('skips format conversion when extension matches target', async () => { - const webpFile = { - path: '/tmp/upload-123.webp', - originalname: 'photo.webp', - } as Express.Multer.File; + const webpFile = { + path: '/tmp/upload-123.webp', + originalname: 'photo.webp', + } as Express.Multer.File; + + it('skips format conversion when the name and the bytes both match the target', async () => { + (mockDeps.resizeImageBuffer as jest.Mock).mockResolvedValue({ + buffer: Buffer.from('resized'), + width: 800, + height: 600, + type: 'image/webp', + }); await service.uploadImage({ req: mockReq as ServerRequest, @@ -124,6 +132,38 @@ describe('ImageService', () => { expect(sharp).not.toHaveBeenCalled(); }); + /* The record is typed from the output format the upload was meant to be in, so bytes that + * skipped conversion on the strength of their name alone leave it describing a format they are + * not in — and that type is handed to providers verbatim as `media_type`. */ + it('converts bytes that are not in the target format, whatever the name says', async () => { + await service.uploadImage({ + req: mockReq as ServerRequest, + file: webpFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(sharp).toHaveBeenCalled(); + }); + + it('converts when sharp named no format for the resized bytes', async () => { + (mockDeps.resizeImageBuffer as jest.Mock).mockResolvedValue({ + buffer: Buffer.from('resized'), + width: 800, + height: 600, + type: undefined, + }); + + await service.uploadImage({ + req: mockReq as ServerRequest, + file: webpFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(sharp).toHaveBeenCalled(); + }); + it('uses custom resolution when provided', async () => { await service.uploadImage({ req: mockReq as ServerRequest, diff --git a/packages/api/src/storage/images.ts b/packages/api/src/storage/images.ts index e1c770923c9..2626867233d 100644 --- a/packages/api/src/storage/images.ts +++ b/packages/api/src/storage/images.ts @@ -18,7 +18,13 @@ export interface ImageServiceDeps { buffer: Buffer, resolution: string, endpoint: string, - ) => Promise<{ buffer: Buffer; width: number; height: number }>; + ) => Promise<{ + buffer: Buffer; + width: number; + height: number; + /** Media type of the re-encoded bytes; `undefined` when sharp named no type for them. */ + type?: string; + }>; updateUser: (userId: string, update: { avatar: string }) => Promise; updateFile: (params: { file_id: string }) => Promise; } @@ -71,6 +77,7 @@ export class ImageService { buffer: resizedBuffer, width, height, + type, } = await this.deps.resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); @@ -81,7 +88,9 @@ export class ImageService { let processedBuffer: Buffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if (extension.toLowerCase() === targetExtension && type === `image/${outputType}`) { processedBuffer = resizedBuffer; } else { const outputFormat = outputType as keyof FormatEnum; From 0b6dbd692685ec9f588c0b687d06be08369d5d09 Mon Sep 17 00:00:00 2001 From: JeongCheolLee Date: Tue, 6 Oct 2026 15:27:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Declare=20th?= =?UTF-8?q?e=20Media=20Type=20an=20Image=20Is=20In,=20Not=20the=20One=20It?= =?UTF-8?q?=20Was=20Recorded=20As?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored type is a claim about bytes it need not describe, and `encodeAndFormatImages` hands it to providers verbatim as Anthropic's `media_type`, Google's `inlineData.mimeType` and the OpenAI data URL prefix. Anthropic refuses a request whose `media_type` disagrees with the bytes, and keeps refusing it, because the image stays in the conversation's history — one mislabeled upload ends the conversation. `sniffImageMimeType` names an image from its signature, which the encode path can afford because it already holds the decoded buffer: no sharp call, no second decode. Records written before the upload path was corrected recover on their next turn. Bytes with no recognized signature keep the stored type rather than being guessed at. --- packages/api/src/files/encode/image.spec.ts | 91 +++++++++++++++++++++ packages/api/src/files/encode/image.ts | 35 +++++--- packages/api/src/files/mime.spec.ts | 73 +++++++++++++++-- packages/api/src/files/mime.ts | 52 ++++++++++++ 4 files changed, 231 insertions(+), 20 deletions(-) diff --git a/packages/api/src/files/encode/image.spec.ts b/packages/api/src/files/encode/image.spec.ts index f447faedc17..248c90ae383 100644 --- a/packages/api/src/files/encode/image.spec.ts +++ b/packages/api/src/files/encode/image.spec.ts @@ -1,3 +1,4 @@ +import sharp from 'sharp'; import { Readable } from 'node:stream'; import { FileSources, VisionModes } from 'librechat-data-provider'; import type { ServerRequest } from '~/types'; @@ -202,6 +203,96 @@ describe('encodeAndFormatImages', () => { expect(result.image_urls).toEqual([expected]); }); + describe('a record whose type does not describe its bytes', () => { + /** A JPEG stored under a `.png` name: the upload was typed from the name, so the record reads + * `image/png`. Sending that as `media_type` is what Anthropic rejects, on this turn and on + * every later one, because the image stays in the conversation's history. */ + let jpegBytes: Buffer; + let jpegContent: string; + const mislabeled = () => ({ ...file, bytes: jpegBytes.length, type: 'image/png' }); + + beforeAll(async () => { + jpegBytes = await sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }) + .jpeg() + .toBuffer(); + jpegContent = jpegBytes.toString('base64'); + }); + + beforeEach(() => { + getDownloadStream.mockImplementation(async () => Readable.from(jpegBytes)); + }); + + it('declares the bytes to Anthropic, not the stored type', async () => { + const result = await encodeAndFormatImages( + makeReq(), + [mislabeled()], + { endpoint: 'anthropic' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: jpegContent } }, + ]); + }); + + it('declares the bytes to Google', async () => { + const result = await encodeAndFormatImages( + makeReq(), + [mislabeled()], + { provider: 'google' }, + deps, + VisionModes.generative, + ); + + expect(result.image_urls).toEqual([ + { type: 'image_url', inlineData: { mimeType: 'image/jpeg', data: jpegContent } }, + ]); + }); + + it('declares the bytes in the data URL', async () => { + const result = await encodeAndFormatImages(makeReq(), [mislabeled()], {}, deps); + + expect(result.image_urls).toEqual([ + { + type: 'image_url', + image_url: { url: `data:image/jpeg;base64,${jpegContent}`, detail: 'auto' }, + }, + ]); + }); + + it('keeps the stored type when the bytes name no format of their own', async () => { + getDownloadStream.mockImplementation(async () => Readable.from(imageBytes)); + + const result = await encodeAndFormatImages( + makeReq(), + [{ ...file, type: 'image/png' }], + { endpoint: 'anthropic' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: content } }, + ]); + }); + + it('leaves an external URL alone, having no bytes to read', async () => { + prepareImagePayload.mockImplementation(async (_req, image) => [image, file.filepath]); + + const result = await encodeAndFormatImages( + makeReq(), + [{ ...mislabeled(), source: FileSources.openai }], + { endpoint: 'openai' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image_url', image_url: { url: file.filepath, detail: 'auto' } }, + ]); + }); + }); + it.each([ [undefined, undefined, 'auto'], ['low', undefined, 'low'], diff --git a/packages/api/src/files/encode/image.ts b/packages/api/src/files/encode/image.ts index bcd486a54d8..4788b753f6e 100644 --- a/packages/api/src/files/encode/image.ts +++ b/packages/api/src/files/encode/image.ts @@ -13,6 +13,7 @@ import type { AxiosInstance } from 'axios'; import type { ServerRequest, StrategyFunctions } from '~/types'; import { getFileStream, isAttachmentObjectNotFoundError } from './utils'; import { validateImage } from '~/files/validation'; +import { sniffImageMimeType } from '~/files/mime'; import { runGuardedEncode } from './memoryGuard'; import { logAxiosError } from '~/utils/axios'; @@ -185,33 +186,43 @@ export async function encodeAndFormatImages( } const isURL = imageContent.startsWith('http'); - if (file.height && file.width && !isURL) { + /** The stored type is a claim about bytes it need not describe: an upload is typed from the + * output format it was meant to be converted into, and a file whose name already carries that + * extension skips conversion whatever is inside it. The claim is handed to providers verbatim + * below, and Anthropic refuses a request whose `media_type` disagrees with the bytes — every + * later turn of the conversation included, since the image stays in the history. The bytes are + * decoded here anyway, so they are what the request should be built from. */ + let mediaType = file.type; + if (!isURL) { const imageBuffer = Buffer.from(imageContent, 'base64'); - const validation = await validateImage( - imageBuffer, - imageBuffer.length, - effectiveEndpoint ?? '', - configuredFileSizeLimit, - ); - if (!validation.isValid) { - throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`); + mediaType = sniffImageMimeType(imageBuffer) ?? file.type; + if (file.height && file.width) { + const validation = await validateImage( + imageBuffer, + imageBuffer.length, + effectiveEndpoint ?? '', + configuredFileSizeLimit, + ); + if (!validation.isValid) { + throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`); + } } } - const url = isURL ? imageContent : `data:${file.type};base64,${imageContent}`; + const url = isURL ? imageContent : `data:${mediaType};base64,${imageContent}`; if (mode === VisionModes.agents) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: { url, detail } }); } else if (effectiveEndpoint === EModelEndpoint.google && mode === VisionModes.generative) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, - inlineData: { mimeType: file.type, data: imageContent }, + inlineData: { mimeType: mediaType, data: imageContent }, }); } else if (effectiveEndpoint === EModelEndpoint.google) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: url }); } else if (effectiveEndpoint === EModelEndpoint.anthropic) { result.image_urls.push({ type: 'image', - source: { type: 'base64', media_type: file.type, data: imageContent }, + source: { type: 'base64', media_type: mediaType, data: imageContent }, }); } else { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: { url, detail } }); diff --git a/packages/api/src/files/mime.spec.ts b/packages/api/src/files/mime.spec.ts index 9f28fb170b0..dfa3838c97a 100644 --- a/packages/api/src/files/mime.spec.ts +++ b/packages/api/src/files/mime.spec.ts @@ -1,14 +1,14 @@ import sharp from 'sharp'; -import { resolveImageMimeType } from './mime'; +import { resolveImageMimeType, sniffImageMimeType } from './mime'; -describe('resolveImageMimeType', () => { - const encode = async (format: 'png' | 'jpeg' | 'webp' | 'gif' | 'tiff'): Promise => - await sharp({ - create: { width: 8, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, - }) - [format]() - .toBuffer(); +const encode = async (format: 'png' | 'jpeg' | 'webp' | 'gif' | 'tiff'): Promise => + await sharp({ + create: { width: 8, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, + }) + [format]() + .toBuffer(); +describe('resolveImageMimeType', () => { it.each([ ['png', 'image/png'], ['jpeg', 'image/jpeg'], @@ -46,3 +46,60 @@ describe('resolveImageMimeType', () => { expect(resolveImageMimeType({ format: 'heif', compression: undefined })).toBeUndefined(); }); }); + +describe('sniffImageMimeType', () => { + it.each([ + ['png', 'image/png'], + ['jpeg', 'image/jpeg'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ['tiff', 'image/tiff'], + ] as const)('names %s bytes %s without decoding them', async (format, expected) => { + expect(sniffImageMimeType(await encode(format))).toBe(expected); + }); + + it('agrees with what sharp reads back out of the same bytes', async () => { + for (const format of ['png', 'jpeg', 'webp', 'gif', 'tiff'] as const) { + const buffer = await encode(format); + expect(sniffImageMimeType(buffer)).toBe(resolveImageMimeType(await sharp(buffer).metadata())); + } + }); + + it('reads the brand of an ISO base media container rather than the container itself', () => { + const ftyp = (brand: string) => + Buffer.concat([ + Buffer.from([0, 0, 0, 0x20]), + Buffer.from('ftyp', 'latin1'), + Buffer.from(brand, 'latin1'), + ]); + + expect(sniffImageMimeType(ftyp('avif'))).toBe('image/avif'); + expect(sniffImageMimeType(ftyp('heic'))).toBe('image/heic'); + expect(sniffImageMimeType(ftyp('mif1'))).toBe('image/heic'); + /** `isom` is a plain MP4; naming it an image is the guess this declines to make. */ + expect(sniffImageMimeType(ftyp('isom'))).toBeUndefined(); + }); + + it('names a file by its bytes, not by the extension it was given', async () => { + const jpegNamedPng = await encode('jpeg'); + + expect(sniffImageMimeType(jpegNamedPng)).toBe('image/jpeg'); + }); + + it('returns undefined rather than naming bytes it does not recognize', () => { + expect(sniffImageMimeType(Buffer.from('not an image at all'))).toBeUndefined(); + expect(sniffImageMimeType(Buffer.alloc(0))).toBeUndefined(); + /** Shorter than the longest signature, so there is nothing to match against. */ + expect(sniffImageMimeType(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeUndefined(); + }); + + it('does not mistake a RIFF container that is not a WebP', () => { + const wav = Buffer.concat([ + Buffer.from('RIFF', 'latin1'), + Buffer.from([0, 0, 0, 0]), + Buffer.from('WAVE', 'latin1'), + ]); + + expect(sniffImageMimeType(wav)).toBeUndefined(); + }); +}); diff --git a/packages/api/src/files/mime.ts b/packages/api/src/files/mime.ts index 1fa85a46d0d..b330ea30313 100644 --- a/packages/api/src/files/mime.ts +++ b/packages/api/src/files/mime.ts @@ -44,3 +44,55 @@ export function resolveImageMimeType(metadata: EncodedImageMetadata): string | u } return SHARP_FORMAT_MIME_TYPES[format]; } + +/** ISO base media file format brands, read from bytes 8..12 of an `ftyp` box. */ +const ISO_BMFF_BRAND_MIME_TYPES: Readonly> = { + avif: 'image/avif', + avis: 'image/avif', + heic: 'image/heic', + heim: 'image/heic', + heis: 'image/heic', + heix: 'image/heic', + hevc: 'image/heic', + hevx: 'image/heic', + mif1: 'image/heic', + msf1: 'image/heic', +}; + +/** + * Reads the media type of an image out of its leading bytes. + * + * {@link resolveImageMimeType} answers the same question, but only where sharp has already decoded + * the image and reported what it found. The encode path has neither: it holds raw bytes, and it + * holds them for every image of every turn, so paying for a decode to name a format that the first + * twelve bytes already state would be a cost per message rather than per upload. + * + * Returns `undefined` when nothing matches. An unrecognized container is not evidence that the + * stored type is wrong, so the caller keeps what it already has rather than guessing. + */ +export function sniffImageMimeType(buffer: Buffer): string | undefined { + if (buffer.length < 12) { + return undefined; + } + if (buffer.readUInt32BE(0) === 0x89504e47 && buffer.readUInt32BE(4) === 0x0d0a1a0a) { + return 'image/png'; + } + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg'; + } + const leading = buffer.toString('latin1', 0, 12); + if (leading.startsWith('GIF87a') || leading.startsWith('GIF89a')) { + return 'image/gif'; + } + /** A WebP is a RIFF container whose form type, at bytes 8..12, names the payload. */ + if (leading.startsWith('RIFF') && leading.slice(8, 12) === 'WEBP') { + return 'image/webp'; + } + if (leading.slice(4, 8) === 'ftyp') { + return ISO_BMFF_BRAND_MIME_TYPES[leading.slice(8, 12).toLowerCase()]; + } + if (leading.startsWith('II\x2a\x00') || leading.startsWith('MM\x00\x2a')) { + return 'image/tiff'; + } + return undefined; +}