Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,10 @@ AWS_BUCKET_NAME=
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_PUBLIC_ACCESS=false
AZURE_CONTAINER_NAME=files
# Optional full blob-endpoint override (sovereign clouds / custom domains).
# Used by SAS signing and the authenticated download path; defaults to
# https://<account>.blob.core.windows.net when unset.
AZURE_STORAGE_BLOB_ENDPOINT=

#========================#
# Shared Links #
Expand Down
4 changes: 4 additions & 0 deletions api/cache/getLogStores.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const namespaces = {
[CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES),
[CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES),
[CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES),
[CacheKeys.AZURE_EXPIRY_INTERVAL]: standardCache(
CacheKeys.AZURE_EXPIRY_INTERVAL,
Time.THIRTY_MINUTES,
),
[CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES),
[CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES),
[CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE),
Expand Down
33 changes: 28 additions & 5 deletions api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { needsRefreshAzure, getNewAzureURL } = require('~/server/services/Files/Azure/crud');

/** DI maps: dispatch avatar URL check and refresh by storage source */
const avatarNeedsRefreshBySource = {
[FileSources.s3]: (url) => needsRefresh(url, 3600),
[FileSources.azure_blob]: (url) => needsRefreshAzure(url, 3600),
};
const getNewAvatarUrlBySource = {
[FileSources.s3]: getNewS3URL,
[FileSources.azure_blob]: getNewAzureURL,
};
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
Expand All @@ -42,20 +53,32 @@ const getUserController = async (req, res) => {
delete userData.password;
delete userData.totpSecret;
delete userData.backupCodes;
if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
if (!avatarNeedsRefresh) {

// Signed-URL avatar refresh (S3 / Azure Blob) β€” dispatches by active file strategy
const strategy = appConfig.fileStrategy;
const checkNeedsRefresh = avatarNeedsRefreshBySource[strategy];
const getNewAvatarUrl = getNewAvatarUrlBySource[strategy];
if (checkNeedsRefresh && getNewAvatarUrl && userData.avatar) {
if (!checkNeedsRefresh(userData.avatar)) {
return res.status(200).send(userData);
}
const originalAvatar = userData.avatar;
try {
userData.avatar = await getNewS3URL(userData.avatar);
const refreshed = await getNewAvatarUrl(userData.avatar);
if (!refreshed) {
// Not a refreshable URL for this provider (OAuth provider avatar,
// legacy /uploads/ path, S3 URL after migrating to Azure, etc.).
// Keep the existing avatar β€” never persist `undefined`.
return res.status(200).send(userData);
}
userData.avatar = refreshed;
await db.updateUser(userData.id, { avatar: userData.avatar });
} catch (error) {
userData.avatar = originalAvatar;
logger.error('Error getting new S3 URL for avatar:', error);
logger.error(`Error refreshing ${strategy} avatar URL:`, error);
}
}

res.status(200).send(userData);
};

Expand Down
68 changes: 53 additions & 15 deletions api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const {
stripFileIdsFromToolResources,
} = require('@librechat/api');
const {
Time,
Tools,
CacheKeys,
Constants,
Expand All @@ -39,11 +38,12 @@ 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 { getFileStrategy, getFileURLRefreshCacheTime } = require('~/server/utils/getFileStrategy');
const { filterFile } = require('~/server/services/Files/process');
const { getCachedTools } = require('~/server/services/Config');
const { resolveConfigServers } = require('~/server/services/MCP');
const { getMCPServersRegistry } = require('~/config');
const { refreshAzureUrl } = require('~/server/services/Files/Azure/crud');
const { getLogStores } = require('~/cache');
const db = require('~/models');

Expand Down Expand Up @@ -304,6 +304,24 @@ const pruneToolResourceFileIdsForOwner = async ({ tool_resources, ownerId, logPr
}
};

/** DI map: dispatches avatar URL refresh by storage source */
const urlRefreshersBySource = {
[FileSources.s3]: refreshS3Url,
[FileSources.azure_blob]: refreshAzureUrl,
};

/**
* DI map: picks the cache namespace for the agent-list avatar-refresh
* cache by storage source. Without this, the agent list cached Azure
* avatar URLs in the S3 cache namespace with a 30-minute hardcoded TTL β€”
* outliving Azure's 5-minute default URL TTL by ~25 minutes and serving
* expired SAS URLs for most of the cache window.
*/
const avatarRefreshCacheKeyByStrategy = {
[FileSources.s3]: CacheKeys.S3_EXPIRY_INTERVAL,
[FileSources.azure_blob]: CacheKeys.AZURE_EXPIRY_INTERVAL,
};

/**
* Creates an Agent.
* @route POST /Agents
Expand Down Expand Up @@ -464,14 +482,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {

agent.version = agent.versions ? agent.versions.length : 0;

if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const refreshFn = agent.avatar && urlRefreshersBySource[agent.avatar.source];
if (refreshFn) {
try {
agent.avatar = {
...agent.avatar,
filepath: await refreshS3Url(agent.avatar),
};
const newPath = await refreshFn(agent.avatar);
agent.avatar = { ...agent.avatar, filepath: newPath };
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
logger.warn('[/Agents/:id] Failed to refresh signed URL', e);
}
}

Expand Down Expand Up @@ -925,10 +942,18 @@ const getListAgentsHandler = async (req, res) => {
});

/**
* Refresh all S3 avatars for this user's accessible agent set (not only the current page)
* This addresses page-size limits preventing refresh of agents beyond the first page
* Refresh all signed-URL avatars (S3 + Azure) for this user's accessible
* agent set (not only the current page). Addresses page-size limits
* preventing refresh of agents beyond the first page.
*
* Cache namespace and TTL are strategy-aware: hardcoding
* `S3_EXPIRY_INTERVAL` with `Time.THIRTY_MINUTES` outlived Azure's 5-min
* default URL TTL by ~25 minutes, serving expired SAS URLs from cache.
*/
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const strategy = req.config?.fileStrategy;
const refreshCacheKey =
avatarRefreshCacheKeyByStrategy[strategy] ?? CacheKeys.S3_EXPIRY_INTERVAL;
const cache = getLogStores(refreshCacheKey);
const refreshKey = `${userId}:agents_avatar_refresh`;
let cachedRefresh = await cache.get(refreshKey);
const isValidCachedRefresh =
Expand All @@ -945,15 +970,16 @@ const getListAgentsHandler = async (req, res) => {
agents: fullList?.data ?? [],
userId,
refreshS3Url,
refreshAzureUrl,
updateAgent: db.updateAgent,
});
cachedRefresh = { urlCache };
await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
await cache.set(refreshKey, cachedRefresh, getFileURLRefreshCacheTime(strategy));
} catch (err) {
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
}
} else {
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
logger.debug('[/Agents] Avatar refresh already checked, skipping');
}

// Use the new ACL-aware function
Expand Down Expand Up @@ -992,10 +1018,16 @@ const getListAgentsHandler = async (req, res) => {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true;
}
// Only replay cached URLs onto avatars whose source is still a signed-URL
// provider. If the agent's avatar source changed (e.g. to `local`) after
// the cache entry was written, the stale signed URL would otherwise
// overwrite the new filepath until cache expiry.
if (
urlCache &&
agent?.id &&
agent?.avatar?.source === FileSources.s3 &&
agent?.avatar &&
(agent.avatar.source === FileSources.s3 ||
agent.avatar.source === FileSources.azure_blob) &&
urlCache[agent.id]
) {
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
Expand Down Expand Up @@ -1092,7 +1124,13 @@ const uploadAgentAvatarHandler = async (req, res) => {
});

try {
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
// Strategy-aware: invalidate the cache namespace the active file
// strategy populated. Hardcoding S3_EXPIRY_INTERVAL leaked stale
// Azure entries through avatar uploads β€” the agent list kept
// serving the old avatar until the cache entry expired naturally.
const avatarRefreshCacheKey =
avatarRefreshCacheKeyByStrategy[fileStrategy] ?? CacheKeys.S3_EXPIRY_INTERVAL;
const avatarCache = getLogStores(avatarRefreshCacheKey);
await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
} catch (cacheErr) {
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
Expand Down
34 changes: 28 additions & 6 deletions api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const {
verifyAgentUploadPermission,
} = require('@librechat/api');
const {
Time,
isUUID,
CacheKeys,
FileSources,
Expand All @@ -29,28 +28,51 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { checkPermission } = require('~/server/services/PermissionService');
const { refreshAzureFileUrls } = require('~/server/services/Files/Azure/crud');
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
const { getFileURLRefreshCacheTime } = require('~/server/utils/getFileStrategy');
const { cleanFileName, getContentDisposition } = require('~/server/utils/files');
const { getLogStores } = require('~/cache');
const { Readable } = require('stream');
const db = require('~/models');

const router = express.Router();

/**
* Dispatch table for refreshing signed file URLs by storage source.
* Adding a new provider only requires another entry here.
*/
const fileUrlRefreshBySource = {
[FileSources.s3]: {
cacheKey: CacheKeys.S3_EXPIRY_INTERVAL,
refreshFiles: refreshS3FileUrls,
},
[FileSources.azure_blob]: {
cacheKey: CacheKeys.AZURE_EXPIRY_INTERVAL,
refreshFiles: refreshAzureFileUrls,
},
};

router.get('/', async (req, res) => {
try {
const appConfig = req.config;
const files = await db.getFiles({ user: req.user.id });
if (appConfig.fileStrategy === FileSources.s3) {
const refresher = fileUrlRefreshBySource[appConfig.fileStrategy];
if (refresher) {
try {
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const cache = getLogStores(refresher.cacheKey);
const alreadyChecked = await cache.get(req.user.id);
if (!alreadyChecked) {
await refreshS3FileUrls(files, db.batchUpdateFiles);
await cache.set(req.user.id, true, Time.THIRTY_MINUTES);
await refresher.refreshFiles(files, db.batchUpdateFiles);
const cacheTime = getFileURLRefreshCacheTime(appConfig.fileStrategy);
await cache.set(req.user.id, true, cacheTime);
}
} catch (error) {
logger.warn('[/files] Error refreshing S3 file URLs:', error);
logger.warn(
'[/files] Error refreshing file URLs for strategy:',
appConfig.fileStrategy,
error,
);
}
}
res.status(200).send(files);
Expand Down
Loading