🔒 feat: Implement Granular File Storage Strategies and Access Control Middleware

This commit is contained in:
“Praneeth 2025-07-25 16:50:18 +02:00 committed by Danny Avila
parent 74e029e78f
commit ff54cbffd9
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
13 changed files with 269 additions and 210 deletions

View file

@ -28,6 +28,7 @@ const {
} = require('~/server/services/PermissionService'); } = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { refreshS3Url } = require('~/server/services/Files/S3/crud'); const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process'); const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action'); const { updateAction, getActions } = require('~/models/Action');
@ -505,7 +506,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
const buffer = await fs.readFile(req.file.path); 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({ const resizedBuffer = await resizeAvatar({
userId: req.user.id, userId: req.user.id,

View file

@ -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,
};

View file

@ -3,6 +3,7 @@ const express = require('express');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { filterFile } = require('~/server/services/Files/process'); const { filterFile } = require('~/server/services/Files/process');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { logger } = require('~/config'); const { logger } = require('~/config');
const router = express.Router(); const router = express.Router();
@ -18,7 +19,7 @@ router.post('/', async (req, res) => {
throw new Error('User ID is undefined'); 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 desiredFormat = req.app.locals.imageOutputType;
const resizedBuffer = await resizeAvatar({ const resizedBuffer = await resizeAvatar({
userId, userId,

View file

@ -6,7 +6,6 @@ const {
isUUID, isUUID,
CacheKeys, CacheKeys,
FileSources, FileSources,
PERMISSION_BITS,
EModelEndpoint, EModelEndpoint,
isAgentsEndpoint, isAgentsEndpoint,
checkOpenAIStorage, checkOpenAIStorage,
@ -19,61 +18,15 @@ const {
} = require('~/server/services/Files/process'); } = require('~/server/services/Files/process');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { checkPermission } = require('~/server/services/PermissionService');
const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
const { getFiles, batchUpdateFiles } = require('~/models/File'); const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAssistant } = require('~/models/Assistant'); const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent'); const { getAgent } = require('~/models/Agent');
const { cleanFileName } = require('~/server/utils/files'); const { cleanFileName } = require('~/server/utils/files');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
const { logger } = require('~/config'); const { logger } = require('~/config');
const { fileAccess } = require('~/server/middleware/accessResources/fileAccess');
/**
* 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 router = express.Router(); 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<TFile[]>} 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) => { router.get('/config', async (req, res) => {
try { try {
res.status(200).json(req.app.locals.fileConfig); 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 fileIds = files.map((file) => file.file_id);
const dbFiles = await getFiles({ file_id: { $in: fileIds } }); const dbFiles = await getFiles({ file_id: { $in: fileIds } });
const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id);
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;
}
if (unauthorizedFiles.length > 0) { if (unauthorizedFiles.length > 0) {
return res.status(403).json({ 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), 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' }); .json({ message: 'File associations removed successfully from Azure Assistant' });
} }
await processDeleteRequest({ req, files: authorizedFiles }); await processDeleteRequest({ req, files: dbFiles });
logger.debug( logger.debug(
`[/files] Files deleted successfully: ${authorizedFiles `[/files] Files deleted successfully: ${files
.filter((f) => f.file_id) .filter((f) => f.file_id)
.map((f) => f.file_id) .map((f) => f.file_id)
.join(', ')}`, .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 { try {
const { userId, file_id } = req.params; const { userId, file_id } = req.params;
logger.debug(`File download requested by user ${userId}: ${file_id}`); logger.debug(`File download requested by user ${userId}: ${file_id}`);
const errorPrefix = `File download requested by user ${userId}`; // Access already validated by fileAccess middleware
const [file] = await getFiles({ file_id }); const file = req.fileAccess.file;
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');
}
if (checkOpenAIStorage(file.source) && !file.model) { 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'); return res.status(400).send('The model used when creating this file is not available');
} }
const { getDownloadStream } = getStrategyFunctions(file.source); const { getDownloadStream } = getStrategyFunctions(file.source);
if (!getDownloadStream) { 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'); return res.status(501).send('Not Implemented');
} }

View file

@ -86,6 +86,7 @@ const AppService = async (app) => {
const turnstileConfig = loadTurnstileConfig(config, configDefaults); const turnstileConfig = loadTurnstileConfig(config, configDefaults);
const defaultLocals = { const defaultLocals = {
config,
ocr, ocr,
paths, paths,
memory, memory,

View file

@ -133,6 +133,9 @@ describe('AppService', () => {
expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); expect(process.env.CDN_PROVIDER).toEqual('testStrategy');
expect(app.locals).toEqual({ expect(app.locals).toEqual({
config: expect.objectContaining({
fileStrategy: 'testStrategy',
}),
socialLogins: ['testLogin'], socialLogins: ['testLogin'],
fileStrategy: 'testStrategy', fileStrategy: 'testStrategy',
interfaceConfig: expect.objectContaining({ interfaceConfig: expect.objectContaining({
@ -775,6 +778,7 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined(); expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined(); expect(app.locals.paths).toBeDefined();
expect(app.locals.config).toEqual({});
expect(app.locals.fileStrategy).toEqual(FileSources.local); expect(app.locals.fileStrategy).toEqual(FileSources.local);
expect(app.locals.socialLogins).toEqual(defaultSocialLogins); expect(app.locals.socialLogins).toEqual(defaultSocialLogins);
expect(app.locals.balance).toEqual( expect(app.locals.balance).toEqual(
@ -807,6 +811,7 @@ describe('AppService updating app.locals and issuing warnings', () => {
expect(app.locals).toBeDefined(); expect(app.locals).toBeDefined();
expect(app.locals.paths).toBeDefined(); expect(app.locals.paths).toBeDefined();
expect(app.locals.config).toEqual(customConfig);
expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy); expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy);
expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins); expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins);
expect(app.locals.balance).toEqual(customConfig.balance); expect(app.locals.balance).toEqual(customConfig.balance);

View file

@ -31,6 +31,7 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { checkCapability } = require('~/server/services/Config'); const { checkCapability } = require('~/server/services/Config');
const { LB_QueueAsyncCall } = require('~/server/utils/queue'); const { LB_QueueAsyncCall } = require('~/server/utils/queue');
const { getStrategyFunctions } = require('./strategies'); const { getStrategyFunctions } = require('./strategies');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { determineFileType } = require('~/server/utils'); const { determineFileType } = require('~/server/utils');
const { logger } = require('~/config'); 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 processImageFile = async ({ req, res, metadata, returnFile = false }) => {
const { file } = req; const { file } = req;
const source = req.app.locals.fileStrategy; const source = getFileStrategy(req.app.locals, { isImage: true });
const { handleImageUpload } = getStrategyFunctions(source); const { handleImageUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id, endpoint } = metadata; 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}>} * @returns {Promise<{ filepath: string, filename: string, source: string, type: string}>}
*/ */
const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) => { 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); const { saveBuffer } = getStrategyFunctions(source);
let { buffer, width, height, bytes, filename, file_id, type } = metadata; let { buffer, width, height, bytes, filename, file_id, type } = metadata;
if (resize) { if (resize) {
@ -595,7 +596,8 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
// Dual storage pattern for RAG files: Storage + Vector DB // Dual storage pattern for RAG files: Storage + Vector DB
let storageResult, embeddingResult; 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) { if (tool_resource === EToolResources.file_search) {
// FIRST: Upload to Storage for permanent backup (S3/local/etc.) // 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), type: mime.getType(fileExt),
createdAt: formattedDate, createdAt: formattedDate,
updatedAt: formattedDate, updatedAt: formattedDate,
source, source: getFileStrategy(req.app.locals, { isImage: true }),
context: FileContext.assistants_output, context: FileContext.assistants_output,
file_id, file_id,
filename, filename,
@ -904,7 +906,7 @@ async function saveBase64Image(
} }
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint); 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 { saveBuffer } = getStrategyFunctions(source);
const filepath = await saveBuffer({ const filepath = await saveBuffer({
userId: req.user.id, userId: req.user.id,

View file

@ -270,8 +270,12 @@ const getStrategyFunctions = (fileSource) => {
return azureMistralOCRStrategy(); return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) { } else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy(); return vertexMistralOCRStrategy();
} else if (fileSource === FileSources.text) {
return localStrategy(); // Text files use local strategy
} else { } else {
throw new Error('Invalid file source'); throw new Error(
`Invalid file source: ${fileSource}. Available sources: ${Object.values(FileSources).join(', ')}`,
);
} }
}; };

View file

@ -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 };

View file

@ -130,9 +130,10 @@ export function Citation(props: CitationComponentProps) {
// Setup file download hook // Setup file download hook
const isFileType = refData?.refType === 'file' && (refData as any)?.fileId; const isFileType = refData?.refType === 'file' && (refData as any)?.fileId;
const isLocalFile = isFileType && (refData as any)?.metadata?.storageType === 'local';
const { refetch: downloadFile } = useFileDownload( const { refetch: downloadFile } = useFileDownload(
user?.id ?? '', user?.id ?? '',
isFileType ? (refData as any).fileId : '', isFileType && !isLocalFile ? (refData as any).fileId : '',
); );
const handleFileDownload = useCallback( const handleFileDownload = useCallback(
@ -142,6 +143,15 @@ export function Citation(props: CitationComponentProps) {
if (!isFileType || !(refData as any)?.fileId) return; 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 { try {
const stream = await downloadFile(); const stream = await downloadFile();
if (stream.data == null || stream.data === '') { 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; if (!refData) return null;
@ -187,8 +197,9 @@ export function Citation(props: CitationComponentProps) {
label={getCitationLabel()} label={getCitationLabel()}
onMouseEnter={() => setHoveredCitationId(citationId || null)} onMouseEnter={() => setHoveredCitationId(citationId || null)}
onMouseLeave={() => setHoveredCitationId(null)} onMouseLeave={() => setHoveredCitationId(null)}
onClick={isFileType ? handleFileDownload : undefined} onClick={isFileType && !isLocalFile ? handleFileDownload : undefined}
isFile={isFileType} isFile={isFileType}
isLocalFile={isLocalFile}
/> />
); );
} }

View file

@ -19,6 +19,7 @@ interface SourceHovercardProps {
onMouseLeave?: () => void; onMouseLeave?: () => void;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
isFile?: boolean; isFile?: boolean;
isLocalFile?: boolean;
children?: ReactNode; children?: ReactNode;
} }
@ -50,6 +51,7 @@ export function SourceHovercard({
onMouseLeave, onMouseLeave,
onClick, onClick,
isFile = false, isFile = false,
isLocalFile = false,
children, children,
}: SourceHovercardProps) { }: SourceHovercardProps) {
const localize = useLocalize(); const localize = useLocalize();
@ -64,9 +66,16 @@ export function SourceHovercard({
isFile ? ( isFile ? (
<button <button
onClick={onClick} onClick={onClick}
className="ml-1 inline-block h-5 max-w-36 cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium text-blue-600 no-underline transition-colors hover:bg-surface-hover dark:border-border-medium dark:text-blue-400 dark:hover:bg-surface-tertiary" className={`ml-1 inline-block h-5 max-w-36 items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-xl border border-border-heavy bg-surface-secondary px-2 text-xs font-medium no-underline transition-colors ${
isLocalFile
? 'cursor-default text-text-tertiary opacity-60'
: 'cursor-pointer text-blue-600 hover:bg-surface-hover dark:text-blue-400 dark:hover:bg-surface-tertiary'
} dark:border-border-medium`}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
title={
isLocalFile ? localize('com_sources_download_local_unavailable') : undefined
}
> >
{label} {label}
</button> </button>

View file

@ -7,9 +7,24 @@ version: 1.2.1
# Cache settings: Set to true to enable caching # Cache settings: Set to true to enable caching
cache: true cache: true
# File strategy s3/firebase # File storage configuration
# Single strategy for all file types (legacy format, still supported)
# fileStrategy: "s3" # 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 # Custom interface configuration
interface: interface:
customWelcome: 'Welcome to LibreChat! Enjoy your experience.' customWelcome: 'Welcome to LibreChat! Enjoy your experience.'

View file

@ -62,6 +62,15 @@ export enum SettingsViews {
export const fileSourceSchema = z.nativeEnum(FileSources); 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 // Helper type to extract the shape of the Zod object schema
type SchemaShape<T> = T extends z.ZodObject<infer U> ? U : never; type SchemaShape<T> = T extends z.ZodObject<infer U> ? U : never;
@ -822,6 +831,7 @@ export const configSchema = z.object({
interface: interfaceSchema, interface: interfaceSchema,
turnstile: turnstileSchema.optional(), turnstile: turnstileSchema.optional(),
fileStrategy: fileSourceSchema.default(FileSources.local), fileStrategy: fileSourceSchema.default(FileSources.local),
fileStrategies: fileStrategiesSchema,
actions: z actions: z
.object({ .object({
allowedDomains: z.array(z.string()).optional(), allowedDomains: z.array(z.string()).optional(),