diff --git a/api/models/Prompt.js b/api/models/Prompt.js index 742ced0f72..58ef78cf7d 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -92,7 +92,7 @@ const createAllGroupsPipeline = ( /** * Get all prompt groups with filters - * @param {Object} req + * @param {ServerRequest} req * @param {TPromptGroupsWithFilterRequest} filter * @returns {Promise} */ @@ -142,7 +142,7 @@ const getAllPromptGroups = async (req, filter) => { /** * Get prompt groups with filters - * @param {Object} req + * @param {ServerRequest} req * @param {TPromptGroupsWithFilterRequest} filter * @returns {Promise} */ @@ -213,8 +213,34 @@ const getPromptGroups = async (req, filter) => { } }; +/** + * @param {Object} fields + * @param {string} fields._id + * @param {string} fields.author + * @param {string} fields.role + * @returns {Promise} + */ +const deletePromptGroup = async ({ _id, author, role }) => { + const query = { _id, author }; + const groupQuery = { groupId: new ObjectId(_id), author }; + if (role === SystemRoles.ADMIN) { + delete query.author; + delete groupQuery.author; + } + const response = await PromptGroup.deleteOne(query); + + if (!response || response.deletedCount === 0) { + throw new Error('Prompt group not found'); + } + + await Prompt.deleteMany(groupQuery); + await removeGroupFromAllProjects(_id); + return { message: 'Prompt group deleted successfully' }; +}; + module.exports = { getPromptGroups, + deletePromptGroup, getAllPromptGroups, /** * Create a prompt and its respective group @@ -510,20 +536,4 @@ module.exports = { return { message: 'Error updating prompt labels' }; } }, - deletePromptGroup: async (_id) => { - try { - const response = await PromptGroup.deleteOne({ _id }); - - if (response.deletedCount === 0) { - return { promptGroup: 'Prompt group not found' }; - } - - await Prompt.deleteMany({ groupId: new ObjectId(_id) }); - await removeGroupFromAllProjects(_id); - return { promptGroup: 'Prompt group deleted successfully' }; - } catch (error) { - logger.error('Error deleting prompt group', error); - return { message: 'Error deleting prompt group' }; - } - }, }; diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index bc92a4b663..863c52431e 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -1,3 +1,4 @@ +const fs = require('fs').promises; const { nanoid } = require('nanoid'); const { FileContext, Constants, Tools, SystemRoles } = require('librechat-data-provider'); const { @@ -7,8 +8,8 @@ const { deleteAgent, getListAgents, } = require('~/models/Agent'); +const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const { uploadImageBuffer } = require('~/server/services/Files/process'); const { getProjectByName } = require('~/models/Project'); const { updateAgentProjects } = require('~/models/Agent'); const { deleteFileByFilter } = require('~/models/File'); @@ -210,7 +211,7 @@ const getListAgentsHandler = async (req, res) => { /** * Uploads and updates an avatar for a specific agent. - * @route POST /avatar/:agent_id + * @route POST /:agent_id/avatar * @param {object} req - Express Request * @param {object} req.params - Request params * @param {string} req.params.agent_id - The ID of the agent. @@ -221,17 +222,17 @@ const getListAgentsHandler = async (req, res) => { */ const uploadAgentAvatarHandler = async (req, res) => { try { + filterFile({ req, file: req.file, image: true, isAvatar: true }); const { agent_id } = req.params; if (!agent_id) { return res.status(400).json({ message: 'Agent ID is required' }); } + const buffer = await fs.readFile(req.file.path); const image = await uploadImageBuffer({ req, context: FileContext.avatar, - metadata: { - buffer: req.file.buffer, - }, + metadata: { buffer }, }); let _avatar; @@ -239,7 +240,7 @@ const uploadAgentAvatarHandler = async (req, res) => { const agent = await getAgent({ id: agent_id }); _avatar = agent.avatar; } catch (error) { - logger.error('[/avatar/:agent_id] Error fetching agent', error); + logger.error('[/:agent_id/avatar] Error fetching agent', error); _avatar = {}; } @@ -249,7 +250,7 @@ const uploadAgentAvatarHandler = async (req, res) => { await deleteFile(req, { filepath: _avatar.filepath }); await deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath }); } catch (error) { - logger.error('[/avatar/:agent_id] Error deleting old avatar', error); + logger.error('[/:agent_id/avatar] Error deleting old avatar', error); } } @@ -270,6 +271,13 @@ const uploadAgentAvatarHandler = async (req, res) => { const message = 'An error occurred while updating the Agent Avatar'; logger.error(message, error); res.status(500).json({ message }); + } finally { + try { + await fs.unlink(req.file.path); + logger.debug('[/:agent_id/avatar] Temp. image upload file deleted'); + } catch (error) { + logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted'); + } } }; diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js index 982e212b7e..5871cce2a8 100644 --- a/api/server/controllers/assistants/v1.js +++ b/api/server/controllers/assistants/v1.js @@ -1,9 +1,10 @@ +const fs = require('fs').promises; const { FileContext } = require('librechat-data-provider'); +const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process'); const validateAuthor = require('~/server/middleware/assistants/validateAuthor'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { deleteAssistantActions } = require('~/server/services/ActionService'); const { updateAssistantDoc, getAssistants } = require('~/models/Assistant'); -const { uploadImageBuffer } = require('~/server/services/Files/process'); const { getOpenAIClient, fetchAssistants } = require('./helpers'); const { deleteFileByFilter } = require('~/models/File'); const { logger } = require('~/config'); @@ -235,7 +236,7 @@ const getAssistantDocuments = async (req, res) => { /** * Uploads and updates an avatar for a specific assistant. - * @route POST /avatar/:assistant_id + * @route POST /:assistant_id/avatar * @param {object} req - Express Request * @param {object} req.params - Request params * @param {string} req.params.assistant_id - The ID of the assistant. @@ -245,6 +246,7 @@ const getAssistantDocuments = async (req, res) => { */ const uploadAssistantAvatar = async (req, res) => { try { + filterFile({ req, file: req.file, image: true, isAvatar: true }); const { assistant_id } = req.params; if (!assistant_id) { return res.status(400).json({ message: 'Assistant ID is required' }); @@ -253,12 +255,11 @@ const uploadAssistantAvatar = async (req, res) => { const { openai } = await getOpenAIClient({ req, res }); await validateAuthor({ req, openai }); + const buffer = await fs.readFile(req.file.path); const image = await uploadImageBuffer({ req, context: FileContext.avatar, - metadata: { - buffer: req.file.buffer, - }, + metadata: { buffer }, }); let _metadata; @@ -269,7 +270,7 @@ const uploadAssistantAvatar = async (req, res) => { _metadata = assistant.metadata; } } catch (error) { - logger.error('[/avatar/:assistant_id] Error fetching assistant', error); + logger.error('[/:assistant_id/avatar] Error fetching assistant', error); _metadata = {}; } @@ -279,7 +280,7 @@ const uploadAssistantAvatar = async (req, res) => { await deleteFile(req, { filepath: _metadata.avatar }); await deleteFileByFilter({ user: req.user.id, filepath: _metadata.avatar }); } catch (error) { - logger.error('[/avatar/:assistant_id] Error deleting old avatar', error); + logger.error('[/:assistant_id/avatar] Error deleting old avatar', error); } } @@ -310,6 +311,13 @@ const uploadAssistantAvatar = async (req, res) => { const message = 'An error occurred while updating the Assistant Avatar'; logger.error(message, error); res.status(500).json({ message }); + } finally { + try { + await fs.unlink(req.file.path); + logger.debug('[/:agent_id/avatar] Temp. image upload file deleted'); + } catch (error) { + logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted'); + } } }; diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index a85c55c06b..25bb5a3c9c 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -27,7 +27,12 @@ const buildFunction = { async function buildEndpointOption(req, res, next) { const { endpoint, endpointType } = req.body; - let parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body }); + let parsedBody; + try { + parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body }); + } catch (error) { + return handleError(res, { text: 'Error parsing conversation' }); + } if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) { /** @type {{ list: TModelSpec[] }}*/ @@ -56,11 +61,15 @@ async function buildEndpointOption(req, res, next) { }); } - parsedBody = parseCompactConvo({ - endpoint, - endpointType, - conversation: currentModelSpec.preset, - }); + try { + parsedBody = parseCompactConvo({ + endpoint, + endpointType, + conversation: currentModelSpec.preset, + }); + } catch (error) { + return handleError(res, { text: 'Error parsing model spec' }); + } } const endpointFn = buildFunction[endpointType ?? endpoint]; diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index e5707761eb..c397ca7d1a 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -6,6 +6,7 @@ const keyvMongo = require('~/cache/keyvMongo'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); const { findUser } = require('~/models'); +const { logger } = require('~/config'); const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 }); const message = 'Your account has been temporarily banned due to violations of our service.'; @@ -45,92 +46,96 @@ const banResponse = async (req, res) => { * @returns {Promise} - Returns a Promise which when resolved calls next middleware if user or source IP is not banned. Otherwise calls `banResponse()` and sets ban details in `banCache`. */ const checkBan = async (req, res, next = () => {}) => { - const { BAN_VIOLATIONS } = process.env ?? {}; + try { + const { BAN_VIOLATIONS } = process.env ?? {}; - if (!isEnabled(BAN_VIOLATIONS)) { - return next(); - } + if (!isEnabled(BAN_VIOLATIONS)) { + return next(); + } - req.ip = removePorts(req); - let userId = req.user?.id ?? req.user?._id ?? null; + req.ip = removePorts(req); + let userId = req.user?.id ?? req.user?._id ?? null; - if (!userId && req?.body?.email) { - const user = await findUser({ email: req.body.email }, '_id'); - userId = user?._id ? user._id.toString() : userId; - } + if (!userId && req?.body?.email) { + const user = await findUser({ email: req.body.email }, '_id'); + userId = user?._id ? user._id.toString() : userId; + } - if (!userId && !req.ip) { - return next(); - } + if (!userId && !req.ip) { + return next(); + } - let cachedIPBan; - let cachedUserBan; + let cachedIPBan; + let cachedUserBan; - let ipKey = ''; - let userKey = ''; + let ipKey = ''; + let userKey = ''; - if (req.ip) { - ipKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:ip:${req.ip}` : req.ip; - cachedIPBan = await banCache.get(ipKey); - } + if (req.ip) { + ipKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:ip:${req.ip}` : req.ip; + cachedIPBan = await banCache.get(ipKey); + } - if (userId) { - userKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:user:${userId}` : userId; - cachedUserBan = await banCache.get(userKey); - } + if (userId) { + userKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:user:${userId}` : userId; + cachedUserBan = await banCache.get(userKey); + } - const cachedBan = cachedIPBan || cachedUserBan; + const cachedBan = cachedIPBan || cachedUserBan; + + if (cachedBan) { + req.banned = true; + return await banResponse(req, res); + } + + const banLogs = getLogStores(ViolationTypes.BAN); + const duration = banLogs.opts.ttl; + + if (duration <= 0) { + return next(); + } + + let ipBan; + let userBan; + + if (req.ip) { + ipBan = await banLogs.get(req.ip); + } + + if (userId) { + userBan = await banLogs.get(userId); + } + + const isBanned = !!(ipBan || userBan); + + if (!isBanned) { + return next(); + } + + const timeLeft = Number(isBanned.expiresAt) - Date.now(); + + if (timeLeft <= 0 && ipKey) { + await banLogs.delete(ipKey); + } + + if (timeLeft <= 0 && userKey) { + await banLogs.delete(userKey); + return next(); + } + + if (ipKey) { + banCache.set(ipKey, isBanned, timeLeft); + } + + if (userKey) { + banCache.set(userKey, isBanned, timeLeft); + } - if (cachedBan) { req.banned = true; return await banResponse(req, res); + } catch (error) { + logger.error('Error in checkBan middleware:', error); } - - const banLogs = getLogStores(ViolationTypes.BAN); - const duration = banLogs.opts.ttl; - - if (duration <= 0) { - return next(); - } - - let ipBan; - let userBan; - - if (req.ip) { - ipBan = await banLogs.get(req.ip); - } - - if (userId) { - userBan = await banLogs.get(userId); - } - - const isBanned = !!(ipBan || userBan); - - if (!isBanned) { - return next(); - } - - const timeLeft = Number(isBanned.expiresAt) - Date.now(); - - if (timeLeft <= 0 && ipKey) { - await banLogs.delete(ipKey); - } - - if (timeLeft <= 0 && userKey) { - await banLogs.delete(userKey); - return next(); - } - - if (ipKey) { - banCache.set(ipKey, isBanned, timeLeft); - } - - if (userKey) { - banCache.set(userKey, isBanned, timeLeft); - } - - req.banned = true; - return await banResponse(req, res); }; module.exports = checkBan; diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index aa15400fe6..d7ef93af73 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -9,7 +9,7 @@ const { // messageUserLimiter, } = require('~/server/middleware'); -const v1 = require('./v1'); +const { v1 } = require('./v1'); const chat = require('./chat'); router.use(requireJwtAuth); diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index a4fcde1241..2a275c1204 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -1,4 +1,3 @@ -const multer = require('multer'); const express = require('express'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); @@ -6,8 +5,8 @@ const v1 = require('~/server/controllers/agents/v1'); const actions = require('./actions'); const tools = require('./tools'); -const upload = multer(); const router = express.Router(); +const avatar = express.Router(); const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [ @@ -81,12 +80,12 @@ router.get('/', checkAgentAccess, v1.getListAgents); /** * Uploads and updates an avatar for a specific agent. - * @route POST /avatar/:agent_id + * @route POST /agents/:agent_id/avatar * @param {string} req.params.agent_id - The ID of the agent. * @param {Express.Multer.File} req.file - The avatar image file. * @param {string} [req.body.metadata] - Optional metadata for the agent's avatar. * @returns {Object} 200 - success response - application/json */ -router.post('/avatar/:agent_id', checkAgentAccess, upload.single('file'), v1.uploadAgentAvatar); +avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar); -module.exports = router; +module.exports = { v1: router, avatar }; diff --git a/api/server/routes/assistants/index.js b/api/server/routes/assistants/index.js index 9640b37b39..e4408b2fe6 100644 --- a/api/server/routes/assistants/index.js +++ b/api/server/routes/assistants/index.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { uaParser, checkBan, requireJwtAuth } = require('~/server/middleware'); -const v1 = require('./v1'); +const { v1 } = require('./v1'); const chatV1 = require('./chatV1'); const v2 = require('./v2'); const chatV2 = require('./chatV2'); diff --git a/api/server/routes/assistants/v1.js b/api/server/routes/assistants/v1.js index 8314c91d1a..544a48fb6d 100644 --- a/api/server/routes/assistants/v1.js +++ b/api/server/routes/assistants/v1.js @@ -1,12 +1,11 @@ -const multer = require('multer'); const express = require('express'); const controllers = require('~/server/controllers/assistants/v1'); const documents = require('./documents'); const actions = require('./actions'); const tools = require('./tools'); -const upload = multer(); const router = express.Router(); +const avatar = express.Router(); /** * Assistant actions route. @@ -71,12 +70,12 @@ router.get('/', controllers.listAssistants); /** * Uploads and updates an avatar for a specific assistant. - * @route POST /avatar/:assistant_id + * @route POST /assistants/:assistant_id/avatar/ * @param {string} req.params.assistant_id - The ID of the assistant. * @param {Express.Multer.File} req.file - The avatar image file. * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar. * @returns {Object} 200 - success response - application/json */ -router.post('/avatar/:assistant_id', upload.single('file'), controllers.uploadAssistantAvatar); +avatar.post('/:assistant_id/avatar/', controllers.uploadAssistantAvatar); -module.exports = router; +module.exports = { v1: router, avatar }; diff --git a/api/server/routes/assistants/v2.js b/api/server/routes/assistants/v2.js index 230bcc2873..e7c0d84763 100644 --- a/api/server/routes/assistants/v2.js +++ b/api/server/routes/assistants/v2.js @@ -1,4 +1,3 @@ -const multer = require('multer'); const express = require('express'); const v1 = require('~/server/controllers/assistants/v1'); const v2 = require('~/server/controllers/assistants/v2'); @@ -6,7 +5,6 @@ const documents = require('./documents'); const actions = require('./actions'); const tools = require('./tools'); -const upload = multer(); const router = express.Router(); /** @@ -78,6 +76,6 @@ router.get('/', v1.listAssistants); * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar. * @returns {Object} 200 - success response - application/json */ -router.post('/avatar/:assistant_id', upload.single('file'), v1.uploadAssistantAvatar); +router.post('/avatar/:assistant_id', v1.uploadAssistantAvatar); module.exports = router; diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js index beb64d449a..eab1a6435f 100644 --- a/api/server/routes/files/avatar.js +++ b/api/server/routes/files/avatar.js @@ -1,17 +1,18 @@ -const multer = require('multer'); +const fs = require('fs').promises; const express = require('express'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); +const { filterFile } = require('~/server/services/Files/process'); const { logger } = require('~/config'); -const upload = multer(); const router = express.Router(); -router.post('/', upload.single('input'), async (req, res) => { +router.post('/', async (req, res) => { try { + filterFile({ req, file: req.file, image: true, isAvatar: true }); const userId = req.user.id; const { manual } = req.body; - const input = req.file.buffer; + const input = await fs.readFile(req.file.path); if (!userId) { throw new Error('User ID is undefined'); @@ -33,6 +34,13 @@ router.post('/', upload.single('input'), async (req, res) => { const message = 'An error occurred while uploading the profile picture'; logger.error(message, error); res.status(500).json({ message }); + } finally { + try { + await fs.unlink(req.file.path); + logger.debug('[/files/images/avatar] Temp. image upload file deleted'); + } catch (error) { + logger.debug('[/files/images/avatar] Temp. image upload file already deleted'); + } } }); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index df2c05efe7..e177142908 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -231,7 +231,6 @@ router.post('/', async (req, res) => { } catch (error) { let message = 'Error processing file'; logger.error('[/files] Error processing file:', error); - cleanup = false; if (error.message?.includes('file_ids')) { message += ': ' + error.message; @@ -240,6 +239,7 @@ router.post('/', async (req, res) => { // TODO: delete remote file if it exists try { await fs.unlink(file.path); + cleanup = false; } catch (error) { logger.error('[/files] Error deleting file:', error); } diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index 374711c4ac..318ac91e22 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -30,6 +30,13 @@ router.post('/', async (req, res) => { logger.error('[/files/images] Error deleting file:', error); } res.status(500).json({ message: 'Error processing file' }); + } finally { + try { + await fs.unlink(req.file.path); + logger.debug('[/files/images] Temp. image upload file deleted'); + } catch (error) { + logger.debug('[/files/images] Temp. image upload file already deleted'); + } } }); diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js index 6317f4495f..2004b97e46 100644 --- a/api/server/routes/files/index.js +++ b/api/server/routes/files/index.js @@ -1,5 +1,7 @@ const express = require('express'); const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware'); +const { avatar: asstAvatarRouter } = require('~/server/routes/assistants/v1'); +const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1'); const { createMulterInstance } = require('./multer'); const files = require('./files'); @@ -13,18 +15,25 @@ const initialize = async () => { router.use(checkBan); router.use(uaParser); + const upload = await createMulterInstance(); + router.post('/speech/stt', upload.single('audio')); + /* Important: speech route must be added before the upload limiters */ router.use('/speech', speech); - const upload = await createMulterInstance(); const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters(); router.post('*', fileUploadIpLimiter, fileUploadUserLimiter); router.post('/', upload.single('file')); router.post('/images', upload.single('file')); + router.post('/images/avatar', upload.single('file')); + router.post('/images/agents/:agent_id/avatar', upload.single('file')); + router.post('/images/assistants/:assistant_id/avatar', upload.single('file')); router.use('/', files); router.use('/images', images); router.use('/images/avatar', avatar); + router.use('/images/agents', agentAvatarRouter); + router.use('/images/assistants', asstAvatarRouter); return router; }; diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index e37ae49fc1..f23ecd2823 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -3,6 +3,7 @@ const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); +const { sanitizeFilename } = require('~/server/utils/handleText'); const { getCustomConfig } = require('~/server/services/Config'); const storage = multer.diskStorage({ @@ -16,7 +17,8 @@ const storage = multer.diskStorage({ filename: function (req, file, cb) { req.file_id = crypto.randomUUID(); file.originalname = decodeURIComponent(file.originalname); - cb(null, `${file.originalname}`); + const sanitizedFilename = sanitizeFilename(file.originalname); + cb(null, sanitizedFilename); }, }); @@ -45,6 +47,10 @@ const createFileFilter = (customFileConfig) => { return cb(new Error('No file provided'), false); } + if (req.originalUrl.endsWith('/speech/stt') && file.mimetype.startsWith('audio/')) { + return cb(null, true); + } + const endpoint = req.body.endpoint; const supportedTypes = customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ?? diff --git a/api/server/routes/files/speech/stt.js b/api/server/routes/files/speech/stt.js index 81c7338cd2..663d2e4638 100644 --- a/api/server/routes/files/speech/stt.js +++ b/api/server/routes/files/speech/stt.js @@ -1,13 +1,8 @@ const express = require('express'); -const router = express.Router(); -const multer = require('multer'); -const { requireJwtAuth } = require('~/server/middleware/'); const { speechToText } = require('~/server/services/Files/Audio'); -const upload = multer(); +const router = express.Router(); -router.post('/', requireJwtAuth, upload.single('audio'), async (req, res) => { - await speechToText(req, res); -}); +router.post('/', speechToText); module.exports = router; diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index c78591265d..8338f63a3c 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -214,7 +214,7 @@ const deletePromptController = async (req, res) => { const { promptId } = req.params; const { groupId } = req.query; const author = req.user.id; - const query = { promptId, groupId, author, role: req.user.role }; + const query = { promptId, groupId, author }; if (req.user.role === SystemRoles.ADMIN) { delete query.author; } @@ -226,11 +226,24 @@ const deletePromptController = async (req, res) => { } }; -router.delete('/:promptId', checkPromptCreate, deletePromptController); +/** + * Delete a prompt group + * @param {ServerRequest} req + * @param {ServerResponse} res + * @returns {Promise} + */ +const deletePromptGroupController = async (req, res) => { + try { + const { groupId: _id } = req.params; + const message = await deletePromptGroup({ _id, author: req.user.id, role: req.user.role }); + res.send(message); + } catch (error) { + logger.error('Error deleting prompt group', error); + res.status(500).send({ message: 'Error deleting prompt group' }); + } +}; -router.delete('/groups/:groupId', checkPromptCreate, async (req, res) => { - const { groupId } = req.params; - res.status(200).send(await deletePromptGroup(groupId)); -}); +router.delete('/:promptId', checkPromptCreate, deletePromptController); +router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController); module.exports = router; diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 03f6b28610..84590cac11 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); @@ -200,11 +201,11 @@ class STTService { * @returns {Promise} */ async processTextToSpeech(req, res) { - if (!req.file || !req.file.buffer) { + if (!req.file) { return res.status(400).json({ message: 'No audio file provided in the FormData' }); } - const audioBuffer = req.file.buffer; + const audioBuffer = await fs.readFile(req.file.path); const audioFile = { originalname: req.file.originalname, mimetype: req.file.mimetype, @@ -218,6 +219,13 @@ class STTService { } catch (error) { logger.error('An error occurred while processing the audio:', error); res.sendStatus(500); + } finally { + try { + await fs.unlink(req.file.path); + logger.debug('[/speech/stt] Temp. audio upload file deleted'); + } catch (error) { + logger.debug('[/speech/stt] Temp. audio upload file already deleted'); + } } } } diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index d1cbc13ed1..5436b7037a 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -716,14 +716,15 @@ async function retrieveAndProcessFile({ * @param {number} [params.req.version] * @param {Express.Multer.File} params.file - The file uploaded to the server via multer. * @param {boolean} [params.image] - Whether the file expected is an image. + * @param {boolean} [params.isAvatar] - Whether the file expected is a user or entity avatar. * @returns {void} * * @throws {Error} If a file exception is caught (invalid file size or type, lack of metadata). */ -function filterFile({ req, file, image }) { +function filterFile({ req, file, image, isAvatar }) { const { endpoint, file_id, width, height } = req.body; - if (!file_id) { + if (!file_id && !isAvatar) { throw new Error('No file_id provided'); } @@ -732,20 +733,25 @@ function filterFile({ req, file, image }) { } /* parse to validate api call, throws error on fail */ - isUUID.parse(file_id); + if (!isAvatar) { + isUUID.parse(file_id); + } - if (!endpoint) { + if (!endpoint && !isAvatar) { throw new Error('No endpoint provided'); } const fileConfig = mergeFileConfig(req.app.locals.fileConfig); - const { fileSizeLimit, supportedMimeTypes } = + const { fileSizeLimit: sizeLimit, supportedMimeTypes } = fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default; + const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit; if (file.size > fileSizeLimit) { throw new Error( - `File size limit of ${fileSizeLimit / megabyte} MB exceeded for ${endpoint} endpoint`, + `File size limit of ${fileSizeLimit / megabyte} MB exceeded for ${ + isAvatar ? 'avatar upload' : `${endpoint} endpoint` + }`, ); } @@ -755,7 +761,7 @@ function filterFile({ req, file, image }) { throw new Error('Unsupported file type'); } - if (!image) { + if (!image || isAvatar === true) { return; } diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 08f40672f3..5a32394fcd 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -1,3 +1,5 @@ +const path = require('path'); +const crypto = require('crypto'); const { Capabilities, EModelEndpoint, @@ -222,6 +224,38 @@ function normalizeEndpointName(name = '') { return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; } +/** + * Sanitize a filename by removing any directory components, replacing non-alphanumeric characters + * @param {string} inputName + * @returns {string} + */ +function sanitizeFilename(inputName) { + // Remove any directory components + let name = path.basename(inputName); + + // Replace any non-alphanumeric characters except for '.' and '-' + name = name.replace(/[^a-zA-Z0-9.-]/g, '_'); + + // Ensure the name doesn't start with a dot (hidden file in Unix-like systems) + if (name.startsWith('.') || name === '') { + name = '_' + name; + } + + // Limit the length of the filename + const MAX_LENGTH = 255; + if (name.length > MAX_LENGTH) { + const ext = path.extname(name); + const nameWithoutExt = path.basename(name, ext); + name = + nameWithoutExt.slice(0, MAX_LENGTH - ext.length - 7) + + '-' + + crypto.randomBytes(3).toString('hex') + + ext; + } + + return name; +} + module.exports = { isEnabled, handleText, @@ -231,5 +265,6 @@ module.exports = { generateConfig, addSpaceIfNeeded, createOnProgress, + sanitizeFilename, normalizeEndpointName, }; diff --git a/api/server/utils/handleText.spec.js b/api/server/utils/handleText.spec.js index ea440a89a5..8b1b6eef8d 100644 --- a/api/server/utils/handleText.spec.js +++ b/api/server/utils/handleText.spec.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('./handleText'); +const { isEnabled, sanitizeFilename } = require('./handleText'); describe('isEnabled', () => { test('should return true when input is "true"', () => { @@ -49,3 +49,51 @@ describe('isEnabled', () => { expect(isEnabled([])).toBe(false); }); }); + +jest.mock('crypto', () => ({ + randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')), +})); + +describe('sanitizeFilename', () => { + test('removes directory components (1/2)', () => { + expect(sanitizeFilename('/path/to/file.txt')).toBe('file.txt'); + }); + + test('removes directory components (2/2)', () => { + expect(sanitizeFilename('../../../../file.txt')).toBe('file.txt'); + }); + + test('replaces non-alphanumeric characters', () => { + expect(sanitizeFilename('file name@#$.txt')).toBe('file_name___.txt'); + }); + + test('preserves dots and hyphens', () => { + expect(sanitizeFilename('file-name.with.dots.txt')).toBe('file-name.with.dots.txt'); + }); + + test('prepends underscore to filenames starting with a dot', () => { + expect(sanitizeFilename('.hiddenfile')).toBe('_.hiddenfile'); + }); + + test('truncates long filenames', () => { + const longName = 'a'.repeat(300) + '.txt'; + const result = sanitizeFilename(longName); + expect(result.length).toBe(255); + expect(result).toMatch(/^a+-abc123\.txt$/); + }); + + test('handles filenames with no extension', () => { + const longName = 'a'.repeat(300); + const result = sanitizeFilename(longName); + expect(result.length).toBe(255); + expect(result).toMatch(/^a+-abc123$/); + }); + + test('handles empty input', () => { + expect(sanitizeFilename('')).toBe('_'); + }); + + test('handles input with only special characters', () => { + expect(sanitizeFilename('@#$%^&*')).toBe('_______'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Container.tsx b/client/src/components/Chat/Messages/Content/Container.tsx index ecc40d6cd1..7e208f3bd1 100644 --- a/client/src/components/Chat/Messages/Content/Container.tsx +++ b/client/src/components/Chat/Messages/Content/Container.tsx @@ -3,7 +3,7 @@ import Files from './Files'; const Container = ({ children, message }: { children: React.ReactNode; message?: TMessage }) => (
{message?.isCreatedByUser === true && } diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index d391e4d08d..e31f827e95 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -27,7 +27,7 @@ export const ErrorMessage = ({ return ( +

diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index f18b7a88bc..fb75b451d9 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useCallback } from 'react'; -import { FileImage, RotateCw, Upload } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; import AvatarEditor from 'react-avatar-editor'; +import { FileImage, RotateCw, Upload } from 'lucide-react'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import type { TUser } from 'librechat-data-provider'; import { @@ -20,16 +20,23 @@ import { cn, formatBytes } from '~/utils'; import { useLocalize } from '~/hooks'; import store from '~/store'; +interface AvatarEditorRef { + getImageScaledToCanvas: () => HTMLCanvasElement; + getImage: () => HTMLImageElement; +} + function Avatar() { const setUser = useSetRecoilState(store.user); - const [image, setImage] = useState(null); - const [isDialogOpen, setDialogOpen] = useState(false); + const [scale, setScale] = useState(1); const [rotation, setRotation] = useState(0); - const editorRef = useRef(null); + const editorRef = useRef(null); const fileInputRef = useRef(null); const openButtonRef = useRef(null); + const [image, setImage] = useState(null); + const [isDialogOpen, setDialogOpen] = useState(false); + const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); @@ -55,12 +62,13 @@ function Avatar() { }; const handleFile = (file: File | undefined) => { - if (fileConfig.avatarSizeLimit && file && file.size <= fileConfig.avatarSizeLimit) { + if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { setImage(file); setScale(1); setRotation(0); } else { - const megabytes = fileConfig.avatarSizeLimit ? formatBytes(fileConfig.avatarSizeLimit) : 2; + const megabytes = + fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; showToast({ message: localize('com_ui_upload_invalid_var', megabytes + ''), status: 'error', @@ -82,7 +90,7 @@ function Avatar() { canvas.toBlob((blob) => { if (blob) { const formData = new FormData(); - formData.append('input', blob, 'avatar.png'); + formData.append('file', blob, 'avatar.png'); formData.append('manual', 'true'); uploadAvatar(formData); } @@ -134,11 +142,11 @@ function Avatar() { - {image ? localize('com_ui_preview') : localize('com_ui_upload_image')} + {image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}

- {image ? ( + {image != null ? ( <>
- Zoom: + {localize('com_ui_zoom')} user?.id === group.author, [user, group]); const groupIsGlobal = useMemo( - () => instanceProjectId && group.projectIds?.includes(instanceProjectId), + () => instanceProjectId != null && group.projectIds?.includes(instanceProjectId), [group, instanceProjectId], ); diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 10c8fd895e..6dd32362b4 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -222,6 +222,7 @@ export default { com_ui_latest_footer: 'Every AI for Everyone.', com_ui_enter: 'Enter', com_ui_submit: 'Submit', + com_ui_zoom: 'Zoom', com_ui_none_selected: 'None selected', com_ui_upload_success: 'Successfully uploaded file', com_ui_upload_error: 'There was an error uploading your file', diff --git a/client/src/mobile.css b/client/src/mobile.css index 97e7cca823..7257cab2bf 100644 --- a/client/src/mobile.css +++ b/client/src/mobile.css @@ -337,4 +337,4 @@ .shake { animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; -} +} \ No newline at end of file diff --git a/client/src/style.css b/client/src/style.css index f676f8e58e..8a7987f23c 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2399,3 +2399,10 @@ button.scroll-convo { scale: 1; translate: 0; } + +/** Note: ensure KaTeX can spread across visible space */ +.message-content pre:has(> span.katex) { + overflow: visible !important; + height: auto !important; + max-height: none !important; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ffe6d4cb38..21126d4f9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36283,7 +36283,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.54", + "version": "0.7.55", "license": "ISC", "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 483640a766..ec47712975 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.54", + "version": "0.7.55", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index a33b368a1b..0868d218e8 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -34,9 +34,9 @@ export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`; export const conversationsRoot = '/api/convos'; export const conversations = (pageNumber: string, isArchived?: boolean, tags?: string[]) => - `${conversationsRoot}?pageNumber=${pageNumber}${isArchived ? '&isArchived=true' : ''}${tags - ?.map((tag) => `&tags=${tag}`) - .join('')}`; + `${conversationsRoot}?pageNumber=${pageNumber}${ + isArchived === true ? '&isArchived=true' : '' + }${tags?.map((tag) => `&tags=${tag}`).join('')}`; export const conversationById = (id: string) => `${conversationsRoot}/${id}`; @@ -77,7 +77,8 @@ export const loginFacebook = () => '/api/auth/facebook'; export const loginGoogle = () => '/api/auth/google'; -export const refreshToken = (retry?: boolean) => `/api/auth/refresh${retry ? '?retry=true' : ''}`; +export const refreshToken = (retry?: boolean) => + `/api/auth/refresh${retry === true ? '?retry=true' : ''}`; export const requestPasswordReset = () => '/api/auth/requestPasswordReset'; @@ -94,19 +95,21 @@ export const config = () => '/api/config'; export const prompts = () => '/api/prompts'; export const assistants = ({ - path, + path = '', options, version, endpoint, + isAvatar, }: { path?: string; options?: object; endpoint?: AssistantsEndpoint; version: number | string; + isAvatar?: boolean; }) => { - let url = `/api/assistants/v${version}`; + let url = isAvatar === true ? `${images()}/assistants` : `/api/assistants/v${version}`; - if (path) { + if (path && path !== '') { url += `/${path}`; } diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index ccce126a81..1a6cdae199 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -471,7 +471,8 @@ export const uploadAvatar = (data: FormData): Promise => export const uploadAssistantAvatar = (data: m.AssistantAvatarVariables): Promise => { return request.postMultiPart( endpoints.assistants({ - path: `avatar/${data.assistant_id}`, + isAvatar: true, + path: `${data.assistant_id}/avatar`, options: { model: data.model, endpoint: data.endpoint }, version: data.version, }), @@ -481,9 +482,7 @@ export const uploadAssistantAvatar = (data: m.AssistantAvatarVariables): Promise export const uploadAgentAvatar = (data: m.AgentAvatarVariables): Promise => { return request.postMultiPart( - endpoints.agents({ - path: `avatar/${data.agent_id}`, - }), + `${endpoints.images()}/agents/${data.agent_id}/avatar`, data.formData, ); }; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 031a85f8c8..f62f001418 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -491,9 +491,7 @@ export type TUpdatePromptLabelsResponse = { message: string; }; -export type TDeletePromptGroupResponse = { - promptGroup: string; -}; +export type TDeletePromptGroupResponse = TUpdatePromptLabelsResponse; export type TDeletePromptGroupRequest = { id: string;