mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +01:00
🔒 feat: Implement Granular File Storage Strategies and Access Control Middleware
This commit is contained in:
parent
74e029e78f
commit
ff54cbffd9
13 changed files with 269 additions and 210 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
124
api/server/middleware/accessResources/fileAccess.js
Normal file
124
api/server/middleware/accessResources/fileAccess.js
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
61
api/server/utils/getFileStrategy.js
Normal file
61
api/server/utils/getFileStrategy.js
Normal 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 };
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue