mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-01 19:46:50 +00:00
* 📎 fix: Alias Shell Script MIME Variants to `application/x-sh` Chrome on Linux reports `.sh` files as `application/x-shellscript` (freedesktop shared-mime-info) and libmagic reports `text/x-shellscript`. Neither string appears anywhere in the source, so uploads were rejected even though `application/x-sh` is in the default allowlist and `codeTypeMapping` maps `sh` to it — `inferMimeType` only consults the extension map when the client sends no type at all, so a non-empty browser value passed straight through to the allowlist check. Alias both variants to the canonical `application/x-sh`, matching the existing treatment of `text/x-markdown` and `application/x-zip-compressed`. Also attach `statusCode`/`body` to multer file-filter rejections. Without them the error misses the `isCustomError` branch in `ErrorController` and falls through to a bare `500 An unknown error occurred.`, so the rejection reason was logged server-side but never reached the client. The upload hook already surfaces `error.response.data.message`, so a rejected file now explains itself instead of showing a generic upload failure. * 🔁 refactor: Move Upload Error Contract Into `packages/api` Addresses codex P1 on #14817. The producer of the `statusCode`/`body` pair now sits beside its consumer: `isCustomError` and `ErrorController` are already in `packages/api/src/middleware/error.ts`, and `CustomError` is already in `packages/api/src/types/error.ts` — only the construction of that pair was stranded in legacy JS. `createCustomError` is exported from the same module as the guard that recognizes it, and `multer.js` is back to a thin caller. Also pins the `.sh` back-compat claim with tests: configs from the documented workarounds (`application/x-sh` per #4660/#5689/#6297, and the broad patterns from #14804) still accept a `.sh` upload after the alias rewrites the type. A negative control confirms the endpoint config is genuinely in play rather than falling back to the default allowlist.
102 lines
2.9 KiB
JavaScript
102 lines
2.9 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const multer = require('multer');
|
|
const { sanitizeFilename, createCustomError } = require('@librechat/api');
|
|
const {
|
|
mergeFileConfig,
|
|
inferMimeType,
|
|
getEndpointFileConfig,
|
|
fileConfig: defaultFileConfig,
|
|
} = require('librechat-data-provider');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
const appConfig = req.config;
|
|
const outputPath = path.join(appConfig.paths.uploads, 'temp', req.user.id);
|
|
if (!fs.existsSync(outputPath)) {
|
|
fs.mkdirSync(outputPath, { recursive: true });
|
|
}
|
|
cb(null, outputPath);
|
|
},
|
|
filename: function (req, file, cb) {
|
|
req.file_id = crypto.randomUUID();
|
|
file.originalname = decodeURIComponent(file.originalname);
|
|
const sanitizedFilename = sanitizeFilename(file.originalname);
|
|
cb(null, sanitizedFilename);
|
|
},
|
|
});
|
|
|
|
const importFileFilter = (req, file, cb) => {
|
|
if (file.mimetype === 'application/json') {
|
|
cb(null, true);
|
|
} else if (path.extname(file.originalname).toLowerCase() === '.json') {
|
|
cb(null, true);
|
|
} else {
|
|
cb(createCustomError(415, 'Only JSON files are allowed'), false);
|
|
}
|
|
};
|
|
|
|
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
|
|
*/
|
|
const createFileFilter = (customFileConfig) => {
|
|
/**
|
|
* @param {ServerRequest} req
|
|
* @param {Express.Multer.File}
|
|
* @param {import('multer').FileFilterCallback} cb
|
|
*/
|
|
const fileFilter = (req, file, cb) => {
|
|
if (!file) {
|
|
return cb(createCustomError(400, 'No file provided'), false);
|
|
}
|
|
|
|
const mimeType = normalizeUploadMimeType(file);
|
|
|
|
if (req.originalUrl.endsWith('/speech/stt') && mimeType.startsWith('audio/')) {
|
|
return cb(null, true);
|
|
}
|
|
|
|
const endpoint = req.body.endpoint;
|
|
const endpointType = req.body.endpointType;
|
|
const endpointFileConfig = getEndpointFileConfig({
|
|
fileConfig: customFileConfig,
|
|
endpoint,
|
|
endpointType,
|
|
});
|
|
|
|
if (!defaultFileConfig.checkType(mimeType, endpointFileConfig.supportedMimeTypes)) {
|
|
return cb(
|
|
createCustomError(415, 'Unsupported file type: ' + (file.mimetype || mimeType)),
|
|
false,
|
|
);
|
|
}
|
|
|
|
cb(null, true);
|
|
};
|
|
|
|
return fileFilter;
|
|
};
|
|
|
|
const createMulterInstance = async () => {
|
|
const appConfig = await getAppConfig();
|
|
const fileConfig = mergeFileConfig(appConfig?.fileConfig);
|
|
const fileFilter = createFileFilter(fileConfig);
|
|
return multer({
|
|
storage,
|
|
fileFilter,
|
|
limits: { fileSize: fileConfig.serverFileSizeLimit },
|
|
});
|
|
};
|
|
|
|
module.exports = { createMulterInstance, storage, importFileFilter, createFileFilter };
|