🐛 fix: Name provisioned images by their stored MIME type

Image uploads are converted to appConfig.imageOutputType while the file
record keeps the original filename, so code-env provisioning shipped webp
bytes as photo.jpg and extension-sniffing tools mis-handled them. Uploads now
rename known converted image types to match the persisted MIME type.
This commit is contained in:
Danny Avila 2026-08-30 22:33:47 -04:00
parent 82040ea5c6
commit 7e458649ef
2 changed files with 76 additions and 2 deletions

View file

@ -29,6 +29,24 @@ async function buildCodeApiHeaders({ apiKey, req }) {
};
}
/** Image uploads are converted to appConfig.imageOutputType while the record keeps
* the original filename; rename so sandbox decoders match the stored bytes. */
function provisionFilename(file) {
if (!file.type?.startsWith('image/')) {
return file.filename;
}
const subtype = file.type.slice('image/'.length);
if (!['webp', 'png', 'jpeg', 'gif'].includes(subtype)) {
return file.filename;
}
const accepted = subtype === 'jpeg' ? ['.jpg', '.jpeg'] : [`.${subtype}`];
const currentExt = path.extname(file.filename).toLowerCase();
if (accepted.includes(currentExt)) {
return file.filename;
}
return `${path.basename(file.filename, path.extname(file.filename))}${accepted[0]}`;
}
/** Env var holding the code-execution API key (symmetric with LIBRECHAT_CODE_BASEURL). */
const CODE_API_KEY_FIELD = 'LIBRECHAT_CODE_API_KEY';
@ -79,7 +97,7 @@ async function provisionToCodeEnv({ req, file, entity_id }) {
const uploaded = await uploadCodeEnvFile({
req,
stream,
filename: file.filename,
filename: provisionFilename(file),
kind,
id,
});

View file

@ -37,7 +37,8 @@ jest.mock('./strategies', () => ({
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCodeApiAuthHeaders, __codeAxios } = require('@librechat/api');
const { loadCodeApiKey, checkSessionsAlive } = require('./provision');
const { getStrategyFunctions } = require('./strategies');
const { loadCodeApiKey, checkSessionsAlive, provisionToCodeEnv } = require('./provision');
describe('loadCodeApiKey', () => {
afterEach(() => jest.clearAllMocks());
@ -102,3 +103,58 @@ describe('checkSessionsAlive', () => {
expect(__codeAxios.mock.calls[0][0].headers['X-API-Key']).toBe('legacy-key');
});
});
describe('provisionToCodeEnv', () => {
afterEach(() => jest.clearAllMocks());
const setupStrategies = (uploadCodeEnvFile) => {
const getDownloadStream = jest.fn().mockResolvedValue({ pipe: jest.fn() });
getStrategyFunctions.mockImplementation((source) =>
source === 'execute_code' ? { handleFileUpload: uploadCodeEnvFile } : { getDownloadStream },
);
};
it('renames converted images to match the stored MIME type', async () => {
const uploadCodeEnvFile = jest
.fn()
.mockResolvedValue({ storage_session_id: 's1', file_id: 'r1' });
setupStrategies(uploadCodeEnvFile);
const result = await provisionToCodeEnv({
req: { user: { id: 'u1' } },
file: {
file_id: 'f1',
filename: 'photo.jpg',
type: 'image/webp',
source: 'local',
filepath: '/x/photo.jpg',
metadata: {},
},
});
expect(uploadCodeEnvFile).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'photo.webp' }),
);
expect(result.codeEnvRef.file_id).toBe('r1');
});
it('keeps filenames untouched when the extension already matches or the file is not an image', async () => {
const uploadCodeEnvFile = jest
.fn()
.mockResolvedValue({ storage_session_id: 's1', file_id: 'r1' });
setupStrategies(uploadCodeEnvFile);
const baseFile = { file_id: 'f1', source: 'local', filepath: '/x/f', metadata: {} };
await provisionToCodeEnv({
req: { user: { id: 'u1' } },
file: { ...baseFile, filename: 'data.csv', type: 'text/csv' },
});
await provisionToCodeEnv({
req: { user: { id: 'u1' } },
file: { ...baseFile, filename: 'pic.webp', type: 'image/webp' },
});
expect(uploadCodeEnvFile.mock.calls[0][0].filename).toBe('data.csv');
expect(uploadCodeEnvFile.mock.calls[1][0].filename).toBe('pic.webp');
});
});