diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 3089a637a1..ce5b36589d 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -28,6 +28,7 @@ const { } = require('~/server/services/PermissionService'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { refreshS3Url } = require('~/server/services/Files/S3/crud'); const { filterFile } = require('~/server/services/Files/process'); const { updateAction, getActions } = require('~/models/Action'); @@ -505,7 +506,7 @@ const uploadAgentAvatarHandler = async (req, res) => { const buffer = await fs.readFile(req.file.path); - const fileStrategy = req.app.locals.fileStrategy; + const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true }); const resizedBuffer = await resizeAvatar({ userId: req.user.id, diff --git a/api/server/middleware/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js new file mode 100644 index 0000000000..71b0a78387 --- /dev/null +++ b/api/server/middleware/accessResources/fileAccess.js @@ -0,0 +1,124 @@ +const { logger } = require('@librechat/data-schemas'); +const { PERMISSION_BITS, hasPermissions } = require('librechat-data-provider'); +const { getEffectivePermissions } = require('~/server/services/PermissionService'); +const { getFiles } = require('~/models/File'); +const { getAgent } = require('~/models/Agent'); + +/** + * Checks if user has access to a file through agent permissions + * Files inherit permissions from agents - if you can view the agent, you can access its files + */ +const checkAgentBasedFileAccess = async (userId, fileId) => { + try { + // Find agents that have this file in their tool_resources + const agentsWithFile = await getAgent({ + $or: [ + { 'tool_resources.file_search.file_ids': fileId }, + { 'tool_resources.execute_code.file_ids': fileId }, + { 'tool_resources.ocr.file_ids': fileId }, + ], + }); + + if (!agentsWithFile || agentsWithFile.length === 0) { + return false; + } + + // Check if user has access to any of these agents + for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) { + // Check if user is the agent author + if (agent.author && agent.author.toString() === userId) { + logger.debug(`[fileAccess] User is author of agent ${agent.id}`); + return true; + } + + // Check ACL permissions for VIEW access on the agent + try { + const permissions = await getEffectivePermissions({ + userId, + resourceType: 'agent', + resourceId: agent._id || agent.id, + }); + + if (hasPermissions(permissions, PERMISSION_BITS.VIEW)) { + logger.debug(`[fileAccess] User ${userId} has VIEW permissions on agent ${agent.id}`); + return true; + } + } catch (permissionError) { + logger.warn( + `[fileAccess] Permission check failed for agent ${agent.id}:`, + permissionError.message, + ); + // Continue checking other agents + } + } + + return false; + } catch (error) { + logger.error('[fileAccess] Error checking agent-based access:', error); + return false; + } +}; + +/** + * Middleware to check if user can access a file + * Checks: 1) File ownership, 2) Agent-based access (file inherits agent permissions) + */ +const fileAccess = async (req, res, next) => { + try { + const fileId = req.params.file_id; + const userId = req.user?.id; + + if (!fileId) { + return res.status(400).json({ + error: 'Bad Request', + message: 'file_id is required', + }); + } + + if (!userId) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + } + + // Get the file + const [file] = await getFiles({ file_id: fileId }); + if (!file) { + return res.status(404).json({ + error: 'Not Found', + message: 'File not found', + }); + } + + // Check if user owns the file + if (file.user && file.user.toString() === userId) { + req.fileAccess = { file }; + return next(); + } + + // Check agent-based access (file inherits agent permissions) + const hasAgentAccess = await checkAgentBasedFileAccess(userId, fileId); + if (hasAgentAccess) { + req.fileAccess = { file }; + return next(); + } + + // No access + logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`); + return res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions to access this file', + }); + } catch (error) { + logger.error('[fileAccess] Error checking file access:', error); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to check file access permissions', + }); + } +}; + +module.exports = { + fileAccess, +}; diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js index eab1a6435f..23d90a4f3d 100644 --- a/api/server/routes/files/avatar.js +++ b/api/server/routes/files/avatar.js @@ -3,6 +3,7 @@ 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 { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { logger } = require('~/config'); const router = express.Router(); @@ -18,7 +19,7 @@ router.post('/', async (req, res) => { throw new Error('User ID is undefined'); } - const fileStrategy = req.app.locals.fileStrategy; + const fileStrategy = getFileStrategy(req.app.locals, { isAvatar: true }); const desiredFormat = req.app.locals.imageOutputType; const resizedBuffer = await resizeAvatar({ userId, diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index eb139ab994..b6d945e71c 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -6,7 +6,6 @@ const { isUUID, CacheKeys, FileSources, - PERMISSION_BITS, EModelEndpoint, isAgentsEndpoint, checkOpenAIStorage, @@ -19,61 +18,15 @@ const { } = require('~/server/services/Files/process'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); -const { checkPermission } = require('~/server/services/PermissionService'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); -const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); const { getFiles, batchUpdateFiles } = require('~/models/File'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { cleanFileName } = require('~/server/utils/files'); const { getLogStores } = require('~/cache'); const { logger } = require('~/config'); - -/** - * Checks if user has access to shared agent file through agent ownership or permissions - */ -const checkSharedFileAccess = async (userId, fileId) => { - try { - // Find agents that have this file in their tool_resources - const agentsWithFile = await getAgent({ - $or: [ - { 'tool_resources.file_search.file_ids': fileId }, - { 'tool_resources.execute_code.file_ids': fileId }, - { 'tool_resources.ocr.file_ids': fileId }, - ], - }); - - if (!agentsWithFile || agentsWithFile.length === 0) { - return false; - } - - // Check if user has access to any of these agents - for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) { - // Check if user is the agent author - if (agent.author && agent.author.toString() === userId) { - return true; - } - - // Check if agent is collaborative - if (agent.isCollaborative) { - return true; - } - - // Check if user has access through project membership - if (agent.projectIds && agent.projectIds.length > 0) { - // For now, return true if agent has project IDs (simplified check) - // This could be enhanced to check actual project membership - return true; - } - } - - return false; - } catch (error) { - logger.error('[checkSharedFileAccess] Error:', error); - return false; - } -}; +const { fileAccess } = require('~/server/middleware/accessResources/fileAccess'); const router = express.Router(); @@ -99,69 +52,6 @@ router.get('/', async (req, res) => { } }); -/** - * Get files specific to an agent - * @route GET /files/agent/:agent_id - * @param {string} agent_id - The agent ID to get files for - * @returns {Promise} Array of files attached to the agent - */ -router.get('/agent/:agent_id', async (req, res) => { - try { - const { agent_id } = req.params; - const userId = req.user.id; - - if (!agent_id) { - return res.status(400).json({ error: 'Agent ID is required' }); - } - - // Get the agent to check ownership and attached files - const agent = await getAgent({ id: agent_id }); - - if (!agent) { - // No agent found, return empty array - return res.status(200).json([]); - } - - // Check if user has access to the agent - if (agent.author.toString() !== userId) { - // Non-authors need at least EDIT permission to view agent files - const hasEditPermission = await checkPermission({ - userId, - resourceType: 'agent', - resourceId: agent._id, - requiredPermission: PERMISSION_BITS.EDIT, - }); - - if (!hasEditPermission) { - return res.status(200).json([]); - } - } - - // Collect all file IDs from agent's tool resources - const agentFileIds = []; - if (agent.tool_resources) { - for (const [, resource] of Object.entries(agent.tool_resources)) { - if (resource?.file_ids && Array.isArray(resource.file_ids)) { - agentFileIds.push(...resource.file_ids); - } - } - } - - // If no files attached to agent, return empty array - if (agentFileIds.length === 0) { - return res.status(200).json([]); - } - - // Get only the files attached to this agent - const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 }); - - res.status(200).json(files); - } catch (error) { - logger.error('[/files/agent/:agent_id] Error fetching agent files:', error); - res.status(500).json({ error: 'Failed to fetch agent files' }); - } -}); - router.get('/config', async (req, res) => { try { res.status(200).json(req.app.locals.fileConfig); @@ -198,62 +88,11 @@ router.delete('/', async (req, res) => { const fileIds = files.map((file) => file.file_id); const dbFiles = await getFiles({ file_id: { $in: fileIds } }); - - const ownedFiles = []; - const nonOwnedFiles = []; - const fileMap = new Map(); - - for (const file of dbFiles) { - fileMap.set(file.file_id, file); - if (file.user.toString() === req.user.id) { - ownedFiles.push(file); - } else { - nonOwnedFiles.push(file); - } - } - - // If all files are owned by the user, no need for further checks - if (nonOwnedFiles.length === 0) { - await processDeleteRequest({ req, files: ownedFiles }); - logger.debug( - `[/files] Files deleted successfully: ${ownedFiles - .filter((f) => f.file_id) - .map((f) => f.file_id) - .join(', ')}`, - ); - res.status(200).json({ message: 'Files deleted successfully' }); - return; - } - - // Check access for non-owned files - let authorizedFiles = [...ownedFiles]; - let unauthorizedFiles = []; - - if (req.body.agent_id && nonOwnedFiles.length > 0) { - // Batch check access for all non-owned files - const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id); - const accessMap = await hasAccessToFilesViaAgent( - req.user.id, - nonOwnedFileIds, - req.body.agent_id, - ); - - // Separate authorized and unauthorized files - for (const file of nonOwnedFiles) { - if (accessMap.get(file.file_id)) { - authorizedFiles.push(file); - } else { - unauthorizedFiles.push(file); - } - } - } else { - // No agent context, all non-owned files are unauthorized - unauthorizedFiles = nonOwnedFiles; - } + const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id); if (unauthorizedFiles.length > 0) { return res.status(403).json({ - message: 'You can only delete files you have access to', + message: 'You can only delete your own files', unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id), }); } @@ -294,10 +133,10 @@ router.delete('/', async (req, res) => { .json({ message: 'File associations removed successfully from Azure Assistant' }); } - await processDeleteRequest({ req, files: authorizedFiles }); + await processDeleteRequest({ req, files: dbFiles }); logger.debug( - `[/files] Files deleted successfully: ${authorizedFiles + `[/files] Files deleted successfully: ${files .filter((f) => f.file_id) .map((f) => f.file_id) .join(', ')}`, @@ -351,48 +190,24 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => { } }); -router.get('/download/:userId/:file_id', async (req, res) => { +router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { try { const { userId, file_id } = req.params; logger.debug(`File download requested by user ${userId}: ${file_id}`); - const errorPrefix = `File download requested by user ${userId}`; - const [file] = await getFiles({ file_id }); - - if (!file) { - logger.warn(`${errorPrefix} not found: ${file_id}`); - return res.status(404).send('File not found'); - } - - // Extract actual file owner from S3 filepath (e.g., /uploads/ownerId/filename) - let actualFileOwner = userId; - if (file.filepath && file.filepath.includes('/uploads/')) { - const pathMatch = file.filepath.match(/\/uploads\/([^/]+)\//); - if (pathMatch) { - actualFileOwner = pathMatch[1]; - } - } - - // Check access: either own the file or have shared access through conversations - const isFileOwner = req.user.id === actualFileOwner; - const hasSharedAccess = !isFileOwner && (await checkSharedFileAccess(req.user.id, file_id)); - - if (!isFileOwner && !hasSharedAccess) { - return res.status(403).send('Forbidden'); - } - - if (isFileOwner && userId !== actualFileOwner) { - return res.status(403).send('Forbidden'); - } + // Access already validated by fileAccess middleware + const file = req.fileAccess.file; if (checkOpenAIStorage(file.source) && !file.model) { - logger.warn(`${errorPrefix} has no associated model: ${file_id}`); + logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`); return res.status(400).send('The model used when creating this file is not available'); } const { getDownloadStream } = getStrategyFunctions(file.source); if (!getDownloadStream) { - logger.warn(`${errorPrefix} has no stream method implemented: ${file.source}`); + logger.warn( + `File download requested by user ${userId} has no stream method implemented: ${file.source}`, + ); return res.status(501).send('Not Implemented'); } diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 6cd3fee631..8eecd74a9e 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -87,6 +87,7 @@ const AppService = async (app) => { const turnstileConfig = loadTurnstileConfig(config, configDefaults); const defaultLocals = { + config, ocr, paths, memory, diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 8c2f185baa..15fbe1c555 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -133,6 +133,9 @@ describe('AppService', () => { expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); expect(app.locals).toEqual({ + config: expect.objectContaining({ + fileStrategy: 'testStrategy', + }), socialLogins: ['testLogin'], fileStrategy: 'testStrategy', interfaceConfig: expect.objectContaining({ @@ -775,6 +778,7 @@ describe('AppService updating app.locals and issuing warnings', () => { expect(app.locals).toBeDefined(); expect(app.locals.paths).toBeDefined(); + expect(app.locals.config).toEqual({}); expect(app.locals.fileStrategy).toEqual(FileSources.local); expect(app.locals.socialLogins).toEqual(defaultSocialLogins); expect(app.locals.balance).toEqual( @@ -807,6 +811,7 @@ describe('AppService updating app.locals and issuing warnings', () => { expect(app.locals).toBeDefined(); expect(app.locals.paths).toBeDefined(); + expect(app.locals.config).toEqual(customConfig); expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy); expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins); expect(app.locals.balance).toEqual(customConfig.balance); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 7cebdf85ba..2154334215 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -31,6 +31,7 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { checkCapability } = require('~/server/services/Config'); const { LB_QueueAsyncCall } = require('~/server/utils/queue'); const { getStrategyFunctions } = require('./strategies'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { determineFileType } = require('~/server/utils'); const { logger } = require('~/config'); @@ -319,7 +320,7 @@ const processFileURL = async ({ fileStrategy, userId, URL, fileName, basePath, c */ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { const { file } = req; - const source = req.app.locals.fileStrategy; + const source = getFileStrategy(req.app.locals, { isImage: true }); const { handleImageUpload } = getStrategyFunctions(source); const { file_id, temp_file_id, endpoint } = metadata; @@ -365,7 +366,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { * @returns {Promise<{ filepath: string, filename: string, source: string, type: string}>} */ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) => { - const source = req.app.locals.fileStrategy; + const source = getFileStrategy(req.app.locals, { isImage: true }); const { saveBuffer } = getStrategyFunctions(source); let { buffer, width, height, bytes, filename, file_id, type } = metadata; if (resize) { @@ -595,7 +596,8 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { // Dual storage pattern for RAG files: Storage + Vector DB let storageResult, embeddingResult; - const source = req.app.locals.fileStrategy; + const isImageFile = file.mimetype.startsWith('image'); + const source = getFileStrategy(req.app.locals, { isImage: isImageFile }); if (tool_resource === EToolResources.file_search) { // FIRST: Upload to Storage for permanent backup (S3/local/etc.) @@ -763,7 +765,7 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx type: mime.getType(fileExt), createdAt: formattedDate, updatedAt: formattedDate, - source, + source: getFileStrategy(req.app.locals, { isImage: true }), context: FileContext.assistants_output, file_id, filename, @@ -904,7 +906,7 @@ async function saveBase64Image( } const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint); - const source = req.app.locals.fileStrategy; + const source = getFileStrategy(req.app.locals, { isImage: true }); const { saveBuffer } = getStrategyFunctions(source); const filepath = await saveBuffer({ userId: req.user.id, diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js index 4f8067142b..2ad526194b 100644 --- a/api/server/services/Files/strategies.js +++ b/api/server/services/Files/strategies.js @@ -270,8 +270,12 @@ const getStrategyFunctions = (fileSource) => { return azureMistralOCRStrategy(); } else if (fileSource === FileSources.vertexai_mistral_ocr) { return vertexMistralOCRStrategy(); + } else if (fileSource === FileSources.text) { + return localStrategy(); // Text files use local strategy } else { - throw new Error('Invalid file source'); + throw new Error( + `Invalid file source: ${fileSource}. Available sources: ${Object.values(FileSources).join(', ')}`, + ); } }; diff --git a/api/server/utils/getFileStrategy.js b/api/server/utils/getFileStrategy.js new file mode 100644 index 0000000000..6c408e5102 --- /dev/null +++ b/api/server/utils/getFileStrategy.js @@ -0,0 +1,61 @@ +const { FileContext } = require('librechat-data-provider'); + +/** + * Determines the appropriate file storage strategy based on file type and configuration. + * + * @param {Object} config - App configuration object containing fileStrategy and fileStrategies + * @param {Object} options - File context options + * @param {boolean} options.isAvatar - Whether this is an avatar upload + * @param {boolean} options.isImage - Whether this is an image upload + * @param {string} options.context - File context from FileContext enum + * @returns {string} Storage strategy to use (e.g., 'local', 's3', 'azure') + * + * @example + * // Legacy single strategy + * getFileStrategy({ fileStrategy: 's3' }) // Returns 's3' + * + * @example + * // Granular strategies + * getFileStrategy( + * { + * fileStrategy: 's3', + * fileStrategies: { avatar: 'local', document: 's3' } + * }, + * { isAvatar: true } + * ) // Returns 'local' + */ +function getFileStrategy(appLocals, { isAvatar = false, isImage = false, context = null } = {}) { + // Handle both old (config object) and new (app.locals object) calling patterns + const isAppLocals = appLocals.fileStrategy !== undefined; + const config = isAppLocals ? appLocals.config : appLocals; + const fileStrategy = isAppLocals ? appLocals.fileStrategy : appLocals.fileStrategy; + + // Fallback to legacy single strategy if no granular config + if (!config?.fileStrategies) { + return fileStrategy || 'local'; // Default to 'local' if undefined + } + + const strategies = config.fileStrategies; + const defaultStrategy = strategies.default || fileStrategy || 'local'; + + // Priority order for strategy selection: + // 1. Specific file type strategy + // 2. Default strategy from fileStrategies + // 3. Legacy fileStrategy + // 4. 'local' as final fallback + + let selectedStrategy; + + if (isAvatar || context === FileContext.avatar) { + selectedStrategy = strategies.avatar || defaultStrategy; + } else if (isImage || context === FileContext.image_generation) { + selectedStrategy = strategies.image || defaultStrategy; + } else { + // All other files (documents, attachments, etc.) + selectedStrategy = strategies.document || defaultStrategy; + } + + return selectedStrategy || 'local'; // Final fallback to 'local' +} + +module.exports = { getFileStrategy }; diff --git a/client/src/components/Web/Citation.tsx b/client/src/components/Web/Citation.tsx index c5059d963a..933474ff82 100644 --- a/client/src/components/Web/Citation.tsx +++ b/client/src/components/Web/Citation.tsx @@ -130,9 +130,10 @@ export function Citation(props: CitationComponentProps) { // Setup file download hook const isFileType = refData?.refType === 'file' && (refData as any)?.fileId; + const isLocalFile = isFileType && (refData as any)?.metadata?.storageType === 'local'; const { refetch: downloadFile } = useFileDownload( user?.id ?? '', - isFileType ? (refData as any).fileId : '', + isFileType && !isLocalFile ? (refData as any).fileId : '', ); const handleFileDownload = useCallback( @@ -142,6 +143,15 @@ export function Citation(props: CitationComponentProps) { if (!isFileType || !(refData as any)?.fileId) return; + // Don't allow download for local files + if (isLocalFile) { + showToast({ + status: 'error', + message: localize('com_sources_download_local_unavailable'), + }); + return; + } + try { const stream = await downloadFile(); if (stream.data == null || stream.data === '') { @@ -167,7 +177,7 @@ export function Citation(props: CitationComponentProps) { }); } }, - [downloadFile, isFileType, refData, localize, showToast], + [downloadFile, isFileType, isLocalFile, refData, localize, showToast], ); if (!refData) return null; @@ -187,8 +197,9 @@ export function Citation(props: CitationComponentProps) { label={getCitationLabel()} onMouseEnter={() => setHoveredCitationId(citationId || null)} onMouseLeave={() => setHoveredCitationId(null)} - onClick={isFileType ? handleFileDownload : undefined} + onClick={isFileType && !isLocalFile ? handleFileDownload : undefined} isFile={isFileType} + isLocalFile={isLocalFile} /> ); } diff --git a/client/src/components/Web/SourceHovercard.tsx b/client/src/components/Web/SourceHovercard.tsx index 25f95f92e8..550f308547 100644 --- a/client/src/components/Web/SourceHovercard.tsx +++ b/client/src/components/Web/SourceHovercard.tsx @@ -19,6 +19,7 @@ interface SourceHovercardProps { onMouseLeave?: () => void; onClick?: (e: React.MouseEvent) => void; isFile?: boolean; + isLocalFile?: boolean; children?: ReactNode; } @@ -50,6 +51,7 @@ export function SourceHovercard({ onMouseLeave, onClick, isFile = false, + isLocalFile = false, children, }: SourceHovercardProps) { const localize = useLocalize(); @@ -64,9 +66,16 @@ export function SourceHovercard({ isFile ? ( diff --git a/librechat.example.yaml b/librechat.example.yaml index f6ff905e87..6e41a102a8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -7,9 +7,24 @@ version: 1.2.1 # Cache settings: Set to true to enable caching cache: true -# File strategy s3/firebase +# File storage configuration +# Single strategy for all file types (legacy format, still supported) # fileStrategy: "s3" +# Granular file storage strategies (new format - recommended) +# Allows different storage strategies for different file types +# fileStrategy: +# avatar: "s3" # Storage for user/agent avatar images +# image: "firebase" # Storage for uploaded images in chats +# document: "local" # Storage for document uploads (PDFs, text files, etc.) + +# Available strategies: "local", "s3", "firebase" +# If not specified, defaults to "local" for all file types +# You can mix and match strategies based on your needs: +# - Use S3 for avatars for fast global access +# - Use Firebase for images with automatic optimization +# - Use local storage for documents for privacy/compliance + # Custom interface configuration interface: customWelcome: 'Welcome to LibreChat! Enjoy your experience.' diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 66cb091160..29a453d6d3 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -62,6 +62,15 @@ export enum SettingsViews { export const fileSourceSchema = z.nativeEnum(FileSources); +export const fileStrategiesSchema = z + .object({ + default: fileSourceSchema.optional(), + avatar: fileSourceSchema.optional(), + image: fileSourceSchema.optional(), + document: fileSourceSchema.optional(), + }) + .optional(); + // Helper type to extract the shape of the Zod object schema type SchemaShape = T extends z.ZodObject ? U : never; @@ -821,6 +830,7 @@ export const configSchema = z.object({ interface: interfaceSchema, turnstile: turnstileSchema.optional(), fileStrategy: fileSourceSchema.default(FileSources.local), + fileStrategies: fileStrategiesSchema, actions: z .object({ allowedDomains: z.array(z.string()).optional(),