LibreChat/api/server/services/Files/saveBase64Image.spec.js
Danny Avila a21c7944ee
🏷️ fix: Record the Media Type of Saved Images, Not the Declared One (#15606)
`saveBase64Image` recorded the media type declared in the incoming data URL
while persisting bytes that sharp had re-encoded. Those are not always the same
format: sharp rasterizes SVG to PNG, so an image arriving as `image/svg+xml` was
stored as `<id>-<name>.svg`, typed `image/svg+xml`, holding PNG bytes.

That type is not cosmetic. `encode.js` hands `file.type` to providers verbatim —
`media_type` for Anthropic, `inlineData.mimeType` for Google, and the `data:`
prefix for OpenAI — so re-attaching such an image sends PNG bytes under a media
type the provider does not accept for images, and the request fails.

A declared type is also only ever a claim. The same mismatch arises whenever a
producer mislabels what it sends, which for MCP tool output means any connected
server can decide what the record says about bytes it did not have to match.

`resizeImageBuffer` already read the encoded output back to measure it, so it now
resolves that metadata to a media type and returns it, and `saveBase64Image`
records it in place of the declared one, falling back to the declared type only
when sharp reports a format that has no media type of its own. The upload paths
were already correct — they record `image/${imageOutputType}`, the format they
convert to — so this brings the tool-output path in line with them.

`resolveImageMimeType` lives in `packages/api` because AVIF and HEIC share the
heif container and are told apart by its compression, which is worth stating once
with tests rather than open-coding at each call site.
2026-09-04 11:51:28 -04:00

103 lines
3.6 KiB
JavaScript

const sharp = require('sharp');
const { resolveImageMimeType } = require('@librechat/api');
jest.mock('~/models', () => ({ createFile: jest.fn(async (doc) => doc) }));
jest.mock('~/server/utils/getFileStrategy', () => ({
getFileStrategy: jest.fn(() => 'local'),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn(async ({ fileName }) => `/images/user-1/${fileName}`),
})),
}));
jest.mock('./retention', () => ({
getRetentionExpiry: jest.fn(async () => ({})),
getAgentFileRetentionExpiry: jest.fn(async () => ({})),
}));
jest.mock('./Audio/STTService', () => ({ STTService: class {} }));
jest.mock('~/server/controllers/assistants/v2', () => ({
addResourceFileId: jest.fn(),
deleteResourceFileId: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({ getOpenAIClient: jest.fn() }));
jest.mock('~/server/services/Tools/credentials', () => ({ loadAuthValues: jest.fn() }));
jest.mock('~/server/services/Config', () => ({ checkCapability: jest.fn() }));
jest.mock('~/server/utils/queue', () => ({ LB_QueueAsyncCall: jest.fn() }));
const db = require('~/models');
const { saveBase64Image } = require('./process');
const req = {
user: { id: 'user-1', tenantId: null },
config: { fileConfig: { imageGeneration: 'high' }, paths: {} },
};
const dataUrl = (mimeType, buffer) => `data:${mimeType};base64,${buffer.toString('base64')}`;
const save = (url) =>
saveBase64Image(url, {
req,
file_id: 'file-1',
filename: 'tool_img_abc',
endpoint: 'openAI',
context: 'image_generation',
});
describe('saveBase64Image records the media type of the bytes it saves', () => {
it('records PNG for an SVG that sharp rasterizes', async () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40">' +
'<rect width="80" height="40" fill="red"/></svg>',
);
const file = await save(dataUrl('image/svg+xml', svg));
expect(file.type).toBe('image/png');
expect(file.filename).toBe('file-1-tool_img_abc.png');
expect(db.createFile).toHaveBeenCalledWith(
expect.objectContaining({ type: 'image/png' }),
true,
);
});
it('leaves a genuine PNG labeled as PNG', async () => {
const png = await sharp({
create: { width: 40, height: 20, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
})
.png()
.toBuffer();
const file = await save(dataUrl('image/png', png));
expect(file.type).toBe('image/png');
expect(file.filename).toBe('file-1-tool_img_abc.png');
});
it('corrects a WebP mislabeled by the producer as PNG', async () => {
const webp = await sharp({
create: { width: 40, height: 20, channels: 3, background: { r: 1, g: 2, b: 3 } },
})
.webp()
.toBuffer();
const file = await save(dataUrl('image/png', webp));
expect(file.type).toBe('image/webp');
expect(file.filename).toBe('file-1-tool_img_abc.webp');
});
it('records a type that matches the bytes actually written to storage', async () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40">' +
'<rect width="80" height="40" fill="blue"/></svg>',
);
const file = await save(dataUrl('image/svg+xml', svg));
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { saveBuffer } = getStrategyFunctions.mock.results.at(-1).value;
const savedBuffer = saveBuffer.mock.calls.at(-1)[0].buffer;
expect(file.type).toBe(resolveImageMimeType(await sharp(savedBuffer).metadata()));
});
});