Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions api/server/routes/files/multer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api');
const {
mergeFileConfig,
inferMimeType,
getEndpointFileConfig,
fileConfig: defaultFileConfig,
} = require('librechat-data-provider');
Expand Down Expand Up @@ -37,6 +38,14 @@ const importFileFilter = (req, file, cb) => {
}
};

const normalizeUploadMimeType = (file) => {
const mimeType = inferMimeType(file.originalname || '', file.mimetype || '');
if (mimeType && file.mimetype !== mimeType) {
file.mimetype = mimeType;
}
return mimeType;
};

/**
*
* @param {import('librechat-data-provider').FileConfig | undefined} customFileConfig
Expand All @@ -52,7 +61,9 @@ const createFileFilter = (customFileConfig) => {
return cb(new Error('No file provided'), false);
}

if (req.originalUrl.endsWith('/speech/stt') && file.mimetype.startsWith('audio/')) {
const mimeType = normalizeUploadMimeType(file);

if (req.originalUrl.endsWith('/speech/stt') && mimeType.startsWith('audio/')) {
return cb(null, true);
}

Expand All @@ -64,8 +75,8 @@ const createFileFilter = (customFileConfig) => {
endpointType,
});

if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
if (!defaultFileConfig.checkType(mimeType, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + (file.mimetype || mimeType)), false);
}

cb(null, true);
Expand All @@ -85,4 +96,4 @@ const createMulterInstance = async () => {
});
};

module.exports = { createMulterInstance, storage, importFileFilter };
module.exports = { createMulterInstance, storage, importFileFilter, createFileFilter };
21 changes: 20 additions & 1 deletion api/server/routes/files/multer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { createMulterInstance, storage, importFileFilter } = require('./multer');
const { createMulterInstance, storage, importFileFilter, createFileFilter } = require('./multer');

// Mock only the config service that requires external dependencies
jest.mock('~/server/services/Config', () => ({
Expand Down Expand Up @@ -281,6 +281,25 @@ describe('Multer Configuration', () => {
}
});

it('should infer ZIP MIME type when multipart upload omits it', (done) => {
const { mergeFileConfig } = require('librechat-data-provider');
const fileFilter = createFileFilter(mergeFileConfig());
const zipFile = {
...mockFile,
originalname: 'archive.zip',
mimetype: '',
};

const cb = jest.fn((err, result) => {
expect(err).toBeNull();
expect(result).toBe(true);
expect(zipFile.mimetype).toBe('application/zip');
done();
});

fileFilter(mockReq, zipFile, cb);
});

it('should use real mergeFileConfig function', async () => {
const { mergeFileConfig, mbToBytes } = require('librechat-data-provider');

Expand Down
16 changes: 16 additions & 0 deletions client/src/utils/__tests__/validateFiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ describe('validateFiles', () => {
expect(setError).toHaveBeenCalledWith('Unsupported file type: application/x-unknown');
});

it('normalizes Windows ZIP MIME type before validation', () => {
const fileList = [makeFile('archive.zip', 'application/x-zip-compressed', 1024)];
const result = validateFiles({ files, fileList, setError, endpointFileConfig, fileConfig });
expect(result).toBe(true);
expect(fileList[0].type).toBe('application/zip');
expect(setError).not.toHaveBeenCalled();
});

it('infers ZIP MIME type when the browser does not provide one', () => {
const fileList = [makeFile('archive.zip', '', 1024)];
const result = validateFiles({ files, fileList, setError, endpointFileConfig, fileConfig });
expect(result).toBe(true);
expect(fileList[0].type).toBe('application/zip');
expect(setError).not.toHaveBeenCalled();
});

it('rejects when file size equals fileSizeLimit (>= comparison)', () => {
const limit = 5 * megabyte;
endpointFileConfig = makeEndpointConfig({ fileSizeLimit: limit });
Expand Down
6 changes: 6 additions & 0 deletions packages/data-provider/src/file-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ describe('inferMimeType', () => {
expect(inferMimeType('test.md', 'text/x-markdown')).toBe('text/markdown');
});

it('should normalize application/x-zip-compressed to application/zip', () => {
expect(inferMimeType('archive.zip', 'application/x-zip-compressed')).toBe('application/zip');
});

it('should return a type that matches textMimeTypes after normalization', () => {
const normalized = inferMimeType('test.py', 'text/x-python-script');
expect(textMimeTypes.test(normalized)).toBe(true);
Expand All @@ -36,6 +40,7 @@ describe('inferMimeType', () => {
it('should infer from extension when browser type is empty', () => {
expect(inferMimeType('test.py', '')).toBe('text/x-python');
expect(inferMimeType('code.js', '')).toBe('text/javascript');
expect(inferMimeType('archive.zip', '')).toBe('application/zip');
expect(inferMimeType('photo.heic', '')).toBe('image/heic');
expect(inferMimeType('Main.java', '')).toBe('text/x-java');
});
Expand Down Expand Up @@ -82,6 +87,7 @@ describe('applicationMimeTypes', () => {
'application/msword',
'application/xml',
'application/zip',
'application/x-zip-compressed',
'application/epub+zip',
'application/x-tar',
'application/x-sh',
Expand Down
5 changes: 4 additions & 1 deletion packages/data-provider/src/file-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const fullMimeTypesList = [
'application/vnd.coffeescript',
'application/xml',
'application/zip',
'application/x-zip-compressed',
'application/x-parquet',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
Expand Down Expand Up @@ -121,6 +122,7 @@ export const codeInterpreterMimeTypesList = [
'application/typescript',
'application/xml',
'application/zip',
'application/x-zip-compressed',
'application/x-parquet',
...excelFileTypes,
];
Expand Down Expand Up @@ -185,7 +187,7 @@ export const textMimeTypes =
/^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml|calendar))$/;

export const applicationMimeTypes =
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/;
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|x-zip-compressed|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/;

export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/;

Expand Down Expand Up @@ -367,6 +369,7 @@ export const imageTypeMapping: { [key: string]: string } = {

/** Normalizes non-standard MIME types that browsers may report to their canonical forms */
export const mimeTypeAliases: Readonly<Record<string, string>> = {
'application/x-zip-compressed': 'application/zip',
'text/x-python-script': 'text/x-python',
'text/x-markdown': 'text/markdown',
};
Expand Down
Loading