mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
* 🔒 fix: Bound `/files/usage` TTL Hold Instead of Clearing It `POST /files/usage` marks queued attachments so the 1-hour upload-window TTL cannot reap them before the client queue drains. It did this by calling `updateFilesUsage`, which unsets `expiresAt` outright, turning every touched upload into a permanently retained file. The client queue is ephemeral browser state, so this also leaks in normal use: a closed tab or cleared queue leaves nothing referencing the files, but their TTL is already gone. The same mechanism let an authenticated user pin arbitrary owned uploads indefinitely, and the route was excluded from the file limiters, so the touch was entirely unmetered. Make the operation match its intent, a renewable hold rather than a release: - Add `extendFilesTTL`, which pushes `expiresAt` forward by a bounded window in a single owner-scoped `updateMany`. Two filter guards keep it safe under client-supplied ids: `$exists: true` so an already-released file never has a TTL re-added (that would schedule a live file for deletion), and `$lt` so a hold only ever moves the deadline later. The owner scope is a required argument, so an unscoped call is a no-op rather than a cross-user update. - `handleFilesUsageRequest` now holds for 24h instead of clearing, and no longer increments `usage`, since a queue touch is not a send. The real release still happens at drain, where `updateFilesUsage` marks the files used against an actual message. - Give `/usage` its own per-user limiter. Keeping it off the upload quota was intentional, leaving it unmetered was not. Abandoned queues are now reaped on schedule, and a replayed touch can only ever re-assert the same bounded window. * 🔒 fix: Anchor the `/files/usage` hold to upload time Codex review onb687922. The hold derived each new deadline from `Date.now()`, so a caller touching once a day advanced it by another 24h every time, far below the rate limit. That left indefinite preservation reachable and made the PR's replay claim wrong: the window was bounded per call but not in aggregate. Anchor the deadline to the file's immutable `createdAt` instead of the request clock. `extendFilesTTL` now takes a lifetime and sets `expiresAt = max(expiresAt, createdAt + holdMs)` in an aggregation pipeline, so the target is a fixed point per file and replay is inert rather than merely bounded. `$max` keeps the widen-only property and the `expiresAt: {$exists: true}` filter still refuses to resurrect a released TTL; `createdAt: {$exists: true}` fail-closes when the anchor is absent. The update runs with `timestamps: false`: a hold is TTL bookkeeping, not a content write, and bumping `updatedAt` also made every re-touch count as a modification, hiding whether the deadline actually moved. Also drop four `.node_modules-*` symlinks that `git add -A` swept in from an npm install. They pointed at absolute paths on one machine, so every other checkout got dangling entries. Added the pattern to .gitignore so a workspace install cannot reintroduce them. * 🔒 fix: Track the configured approval window in the `/files/usage` hold Codex review on9277620. `endpoints.agents.checkpointer.ttl` is a positive int with no upper bound, and its docs invite raising it for longer review windows. It drives the pending-action expiry, so a run can legitimately stay paused past 24h. The fixed 24h lifetime would then let Mongo reap an attachment while its approval was still live, and the later queue drain would send a file that no longer exists. Replace the fixed constant with `resolveFilesUsageHoldMs`, which adds the configured approval window to a 24h baseline covering upload, enqueue, and the run reaching its pause. The route reads the window from the same `getApprovalTtlMs(checkpointerCfg)` the pending action uses, so the two stay in lockstep. The replay bound is unaffected: the window is a per-deployment constant and the deadline is still `createdAt + holdMs`, so a replayed touch re-asserts the same instant and `$max` skips the write. Only an operator config change moves it, never a client. * 🔒 fix: Renew the `/files/usage` hold across queued runs, under a ceiling Codex review on2bd3c52. The drain sends one queued item per run completion, and each item starts a run that may itself pause for the full approval window. Since the hold was taken once at enqueue and pinned to the upload time, an item several places back could sit through multiple approval windows and lose its attachment while its chip and the live approval were still there. Another regression from this PR: the old `$unset` made retention permanent, so deep queues happened to work. The queue is unbounded, so no fixed lifetime covers it. Split the hold into a renewable window and a ceiling: expiresAt = max(expiresAt, min(now + renewMs, createdAt + maxLifetimeMs)) `renewMs` covers one run's wait and is granted from now, so a queue that is still draining re-asserts it at each transition; `useQueueDrain` now marks the remaining items' files whenever it pops one. `maxLifetimeMs` is measured from the immutable upload time and clamps every renewal, so repeated touches converge on a ceiling instead of advancing per call, which keeps the replay bound from the previous round intact. This also tightens abandonment: a queue nobody drains now lapses one `renewMs` after its last touch instead of surviving to the ceiling. `useQueueDrain`'s spec gained a QueryClientProvider, since the renewal goes through react-query. * 🔒 fix: Renew queued holds on a heartbeat, and stop dropping batches Codex review onf616bed. Three gaps in the renewal added last commit: - `collectQueuedFileIds` returned early at the server's 10-id cap, so a remainder holding more than one batch renewed only its first message and left the rest on their enqueue-time hold. Collect everything and split into capped requests instead of truncating. - A refused `ask()` restores the popped item, but renewal ran before the send and covered only the pre-existing remainder. Since the run-end signal is already consumed, nothing would touch that item again. Renewal now runs after `ask` and includes the restored item. - A single run can interrupt for approval more than once, each pause running to the configured window, so renewing only at drain transitions leaves a gap longer than `renewMs` with no renewal in it. The ceiling cannot help when nothing renews. The third is the same structural gap as the previous round along a new axis: renewal tied to discrete events loses the file whenever two events are further apart than the hold. Rather than hook each transition, renew on a 30 minute heartbeat while anything is queued, which is far below the smallest hold (24h) and so covers any single gap regardless of cause. Still bounded: every renewal is clamped against the file's upload time, so the ceiling is unchanged. A queue nobody has open emits no heartbeat and lapses one `renewMs` after its last touch, preserving the abandonment behaviour. * 🔒 fix: Cover the pre-migration queue, first tick, and `/usage/` Codex review on892a27d. - The heartbeat watched only the active conversation id, but `drainNext` merges in the `NEW_CONVO` queue, which outlives the URL update: items queued during the first turn stay keyed there until that run ends. It now renews the union of both, deduped since they are the same atom before migration. - The interval installed without firing, so returning to a conversation whose hold was nearly up waited out a full period before the first renewal. It now renews immediately, then on each tick. - Express's non-strict routing sends `POST /files/usage/` to the same handler with `req.path === '/usage/'`, so the exact comparison pushed it onto both upload limiters. A trailing-slash client would have spent its upload quota, and collected file-upload violations, on metadata heartbeats. Matching now tolerates the trailing slash. Firing on effect start also made the drain-time renewal redundant: popping an item changes the held set, so the renewal effect re-runs on its own. The one case it cannot see is a refused send, where restoring the item leaves the set identical, so that branch keeps an explicit renewal and the rest is removed. Net one request per transition instead of two.
728 lines
23 KiB
JavaScript
728 lines
23 KiB
JavaScript
const fs = require('fs').promises;
|
|
const express = require('express');
|
|
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
|
const {
|
|
logAxiosError,
|
|
getApprovalTtlMs,
|
|
refreshS3FileUrls,
|
|
handleFilesUsageRequest,
|
|
shouldUseUploadSse,
|
|
startUploadSseStream,
|
|
resolveUploadErrorMessage,
|
|
verifyAgentUploadPermission,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Time,
|
|
isUUID,
|
|
CacheKeys,
|
|
FileSources,
|
|
ResourceType,
|
|
EModelEndpoint,
|
|
EToolResources,
|
|
PermissionBits,
|
|
checkOpenAIStorage,
|
|
isAssistantsEndpoint,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
filterFile,
|
|
processFileUpload,
|
|
processDeleteRequest,
|
|
processAgentFileUpload,
|
|
} = require('~/server/services/Files/process');
|
|
const { fileAccess } = require('~/server/middleware/accessResources/fileAccess');
|
|
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 { cleanFileName, getContentDisposition } = require('~/server/utils/files');
|
|
const { getLogStores } = require('~/cache');
|
|
const { Readable } = require('stream');
|
|
const db = require('~/models');
|
|
|
|
const router = express.Router();
|
|
const AGENT_TOOL_RESOURCE_KEYS = new Set([
|
|
EToolResources.execute_code,
|
|
EToolResources.file_search,
|
|
EToolResources.image_edit,
|
|
EToolResources.context,
|
|
EToolResources.ocr,
|
|
]);
|
|
|
|
const isAgentToolResourceKey = (toolResource) =>
|
|
typeof toolResource === 'string' && AGENT_TOOL_RESOURCE_KEYS.has(toolResource);
|
|
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
const files = await db.getFiles({ user: req.user.id });
|
|
if (appConfig.fileStrategy === FileSources.s3) {
|
|
try {
|
|
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
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);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[/files] Error refreshing S3 file URLs:', error);
|
|
}
|
|
}
|
|
res.status(200).send(files);
|
|
} catch (error) {
|
|
logger.error('[/files] Error getting files:', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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' });
|
|
}
|
|
|
|
const agent = await db.getAgent({ id: agent_id });
|
|
if (!agent) {
|
|
return res.status(200).json([]);
|
|
}
|
|
|
|
if (agent.author.toString() !== userId) {
|
|
const hasEditPermission = await checkPermission({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.EDIT,
|
|
});
|
|
|
|
if (!hasEditPermission) {
|
|
return res.status(200).json([]);
|
|
}
|
|
}
|
|
|
|
const agentFileIds = new Set();
|
|
if (agent.tool_resources) {
|
|
for (const [, resource] of Object.entries(agent.tool_resources)) {
|
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
|
resource.file_ids.forEach((fileId) => agentFileIds.add(fileId));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (agentFileIds.size === 0) {
|
|
return res.status(200).json([]);
|
|
}
|
|
|
|
const files = await db.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 {
|
|
const appConfig = req.config;
|
|
res.status(200).json(appConfig.fileConfig);
|
|
} catch (error) {
|
|
logger.error('[/files] Error getting fileConfig', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /files/usage
|
|
*
|
|
* Owner-scoped TTL hold for uploads sitting in a client-side queue (mid-run
|
|
* queued messages), so the upload-window TTL cannot reap them before drain.
|
|
* Extends the deadline rather than clearing it; the real release happens at
|
|
* send. The approval window is passed through so a queue waiting on a paused
|
|
* run outlives that pause. Thin wrapper: validation, cap, hold window, and
|
|
* best-effort semantics live in `@librechat/api` (`handleFilesUsageRequest`).
|
|
*/
|
|
router.post('/usage', async (req, res) => {
|
|
try {
|
|
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
|
|
const { status, body } = await handleFilesUsageRequest(req.user ?? {}, req.body ?? {}, {
|
|
extendFilesTTL: db.extendFilesTTL,
|
|
approvalTtlMs: getApprovalTtlMs(checkpointerCfg),
|
|
});
|
|
return res.status(status).json(body);
|
|
} catch (error) {
|
|
logger.error('[/files/usage] Failed to mark files used', error);
|
|
return res.status(500).json({ code: 'FILES_USAGE_FAILED' });
|
|
}
|
|
});
|
|
|
|
router.delete('/', async (req, res) => {
|
|
try {
|
|
const { files: _files } = req.body;
|
|
|
|
/** @type {MongoFile[]} */
|
|
const files = _files.filter((file) => {
|
|
if (!file.file_id) {
|
|
return false;
|
|
}
|
|
if (!file.filepath) {
|
|
return false;
|
|
}
|
|
|
|
if (/^(file|assistant)-/.test(file.file_id)) {
|
|
return true;
|
|
}
|
|
|
|
return isUUID.safeParse(file.file_id).success;
|
|
});
|
|
|
|
if (files.length === 0) {
|
|
res.status(204).json({ message: 'Nothing provided to delete' });
|
|
return;
|
|
}
|
|
|
|
const fileIds = files.map((file) => file.file_id);
|
|
const dbFiles = await db.getFiles({ file_id: { $in: fileIds } });
|
|
|
|
if (req.body.agent_id && req.body.tool_resource) {
|
|
if (!isAgentToolResourceKey(req.body.tool_resource)) {
|
|
return res.status(400).json({ message: 'Invalid agent tool resource' });
|
|
}
|
|
|
|
const agent = await db.getAgent({
|
|
id: req.body.agent_id,
|
|
});
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({ message: 'Agent not found' });
|
|
}
|
|
|
|
const hasAgentEditAccess =
|
|
agent.author?.toString() === req.user.id.toString() ||
|
|
(await checkPermission({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.EDIT,
|
|
}));
|
|
if (!hasAgentEditAccess) {
|
|
return res.status(403).json({
|
|
message: 'You can only delete files you have access to',
|
|
unauthorizedFiles: files.map((file) => file.file_id),
|
|
});
|
|
}
|
|
|
|
const toolResourceFiles = agent.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
|
|
const agentFiles = files
|
|
.filter((f) => toolResourceFiles.includes(f.file_id))
|
|
.map((file) => ({ tool_resource: req.body.tool_resource, file_id: file.file_id }));
|
|
if (agentFiles.length === 0) {
|
|
res.status(200).json({ message: 'File associations removed successfully from agent' });
|
|
return;
|
|
}
|
|
|
|
await db.removeAgentResourceFiles({
|
|
agent_id: req.body.agent_id,
|
|
files: agentFiles,
|
|
});
|
|
res.status(200).json({ message: 'File associations removed successfully from agent' });
|
|
return;
|
|
}
|
|
|
|
const ownedFiles = [];
|
|
const nonOwnedFiles = [];
|
|
|
|
for (const file of dbFiles) {
|
|
if (file.user.toString() === req.user.id.toString()) {
|
|
ownedFiles.push(file);
|
|
} else {
|
|
nonOwnedFiles.push(file);
|
|
}
|
|
}
|
|
|
|
if (dbFiles.length > 0 && 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;
|
|
}
|
|
|
|
const authorizedFiles = [...ownedFiles];
|
|
const unauthorizedFiles = nonOwnedFiles;
|
|
|
|
if (unauthorizedFiles.length > 0) {
|
|
return res.status(403).json({
|
|
message: 'You can only delete files you own',
|
|
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
|
});
|
|
}
|
|
|
|
/* Handle assistant unlinking even if no valid files to delete */
|
|
if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
|
|
const assistant = await db.getAssistant({
|
|
id: req.body.assistant_id,
|
|
});
|
|
|
|
const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
|
|
const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
|
|
|
|
await processDeleteRequest({ req, files: assistantFiles });
|
|
res.status(200).json({ message: 'File associations removed successfully from assistant' });
|
|
return;
|
|
} else if (
|
|
req.body.assistant_id &&
|
|
req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
|
|
) {
|
|
await processDeleteRequest({ req, files: req.body.files });
|
|
return res
|
|
.status(200)
|
|
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
|
}
|
|
|
|
await processDeleteRequest({ req, files: authorizedFiles });
|
|
|
|
logger.debug(
|
|
`[/files] Files deleted successfully: ${authorizedFiles
|
|
.filter((f) => f.file_id)
|
|
.map((f) => f.file_id)
|
|
.join(', ')}`,
|
|
);
|
|
res.status(200).json({ message: 'Files deleted successfully' });
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting files:', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
function isValidID(str) {
|
|
return /^[A-Za-z0-9_-]{21}$/.test(str);
|
|
}
|
|
|
|
router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|
try {
|
|
const { session_id, fileId } = req.params;
|
|
const logPrefix = `Session ID: ${session_id} | File ID: ${fileId} | Code output download requested by user `;
|
|
logger.debug(logPrefix);
|
|
|
|
if (!session_id || !fileId) {
|
|
return res.status(400).send('Bad request');
|
|
}
|
|
|
|
if (!isValidID(session_id) || !isValidID(fileId)) {
|
|
logger.debug(`${logPrefix} invalid session_id or fileId`);
|
|
return res.status(400).send('Bad request');
|
|
}
|
|
|
|
const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
|
|
if (!getDownloadStream) {
|
|
logger.warn(
|
|
`${logPrefix} has no stream method implemented for ${FileSources.execute_code} source`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
/* Code-output downloads are always user-private — `processCodeOutput`
|
|
* persists every code-execution artifact under
|
|
* `metadata.codeEnvRef.kind === 'user'` regardless of which skill
|
|
* the run invoked. Pass `kind: 'user'` + `id: <userId>` so codeapi's
|
|
* `sessionAuth` resolves the matching `<tenant>:user:<userId>`
|
|
* sessionKey; without these query params it 400s with
|
|
* "kind must be one of: skill, agent, user". */
|
|
/** @type {AxiosResponse<ReadableStream> | undefined} */
|
|
const response = await getDownloadStream(
|
|
`${session_id}/${fileId}`,
|
|
{
|
|
kind: 'user',
|
|
id: req.user.id,
|
|
},
|
|
req,
|
|
);
|
|
res.set(response.headers);
|
|
response.data.pipe(res);
|
|
} catch (error) {
|
|
/* `logAxiosError` redacts buffer/stream response bodies — without
|
|
* it, a stream-typed axios failure dumps the entire `Readable`'s
|
|
* internal state (megabytes of socket + readableState) into the
|
|
* log line. Plain `logger.error(error)` would do that here. */
|
|
logAxiosError({ message: 'Error downloading code-output file', error });
|
|
res.status(500).send('Error downloading file');
|
|
}
|
|
});
|
|
|
|
/* Lazy-sweep cutoff: pending records older than this are marked failed
|
|
* on the next poll. 2min is well past the 60s render ceiling, so any
|
|
* `pending` past it is definitively orphaned. Tighter than the boot
|
|
* sweep (5min) since this runs per-request, not per-instance. */
|
|
const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
|
|
|
|
/**
|
|
* Poll the lifecycle status of a code-execution file's inline preview.
|
|
*
|
|
* Deferred-preview flow: the immediate persist step writes the file
|
|
* record at `status: 'pending'`; the background render transitions
|
|
* it to `'ready'` (with `text` + `textFormat`) or `'failed'` (with
|
|
* `previewError`). The frontend's `useFilePreview` React Query hook
|
|
* polls this endpoint at ~2.5s intervals while `status === 'pending'`,
|
|
* then auto-stops on terminal status.
|
|
*
|
|
* Returns the smallest viable shape:
|
|
* - `status` always present (defaults to `'ready'` for legacy records
|
|
* that never had the field — clients treat absent as ready).
|
|
* - `text` and `textFormat` only when status is 'ready' AND text
|
|
* is non-null (preserves the security contract from PR #12934 —
|
|
* office bucket files MUST NOT receive plain-text fallbacks).
|
|
* - `previewError` only when status is 'failed'.
|
|
*
|
|
* Lazy-sweeps stale `pending` records on the spot — see
|
|
* `PREVIEW_LAZY_SWEEP_CUTOFF_MS` for the rationale.
|
|
*
|
|
* Reuses the `fileAccess` middleware so ACL is identical to download.
|
|
*
|
|
* @route GET /files/:file_id/preview
|
|
*/
|
|
router.get('/:file_id/preview', fileAccess, async (req, res) => {
|
|
try {
|
|
const { file_id } = req.params;
|
|
/* `fileAccess` already fetched the record (sans `text`, the default
|
|
* projection drops it). Reuse for the lifecycle check; only re-fetch
|
|
* with `text` on a terminal ready response — the typical lifecycle
|
|
* is N pending polls + 1 ready, so this avoids ~N redundant text
|
|
* reads per file. */
|
|
let file = req.fileAccess.file;
|
|
/* Lazy sweep: if stuck `pending` past the cutoff, mark `failed`
|
|
* conditional on the observed `updatedAt` (concurrent legitimate
|
|
* updates win). */
|
|
if (file.status === 'pending' && file.updatedAt instanceof Date) {
|
|
const ageMs = Date.now() - file.updatedAt.getTime();
|
|
if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) {
|
|
const swept = await db.updateFile(
|
|
{ file_id, status: 'failed', previewError: 'orphaned' },
|
|
{ status: 'pending', updatedAt: file.updatedAt },
|
|
);
|
|
if (swept) {
|
|
file = swept;
|
|
logger.info(
|
|
`[/files/:file_id/preview] Lazy-swept orphaned pending record ${file_id} (age ${Math.round(ageMs / 1000)}s)`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
/* Default to 'ready' for back-compat: legacy records pre-date the
|
|
* field, and non-office files never get a status set on persist. */
|
|
const status = file.status ?? 'ready';
|
|
const payload = { file_id, status };
|
|
if (status === 'ready') {
|
|
const withText = await db.findFileById(file_id);
|
|
if (withText?.text != null) {
|
|
payload.text = withText.text;
|
|
payload.textFormat = withText.textFormat ?? null;
|
|
}
|
|
} else if (status === 'failed' && file.previewError) {
|
|
payload.previewError = file.previewError;
|
|
}
|
|
return res.status(200).json(payload);
|
|
} catch (error) {
|
|
logger.error('[/files/:file_id/preview] Error fetching preview status:', error);
|
|
return res
|
|
.status(500)
|
|
.json({ error: 'Internal Server Error', message: 'Failed to fetch preview status' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns a strategy-managed signed URL for an already-authorized file record.
|
|
*/
|
|
const getDirectDownloadURL = async ({
|
|
req,
|
|
file,
|
|
customFilename = cleanFileName(file.filename),
|
|
}) => {
|
|
const { getDownloadURL } = getStrategyFunctions(file.source);
|
|
if (!getDownloadURL) {
|
|
return null;
|
|
}
|
|
|
|
return getDownloadURL({
|
|
req,
|
|
file,
|
|
customFilename,
|
|
contentType: file.type || 'application/octet-stream',
|
|
});
|
|
};
|
|
|
|
// Security allowlist: excludes internal ids, owner/tenant identifiers, and extracted text.
|
|
// `filepath` stays included because cached TFile records need it for previews/deletes.
|
|
const DOWNLOAD_METADATA_FIELDS = [
|
|
'conversationId',
|
|
'message',
|
|
'file_id',
|
|
'temp_file_id',
|
|
'bytes',
|
|
'model',
|
|
'embedded',
|
|
'filename',
|
|
'filepath',
|
|
'storageKey',
|
|
'storageRegion',
|
|
'object',
|
|
'type',
|
|
'usage',
|
|
'context',
|
|
'source',
|
|
'filterSource',
|
|
'width',
|
|
'height',
|
|
'expiresAt',
|
|
'preview',
|
|
'textFormat',
|
|
'status',
|
|
'previewError',
|
|
'createdAt',
|
|
'updatedAt',
|
|
];
|
|
|
|
const getDownloadFileMetadata = (file) => {
|
|
const rawFile = typeof file.toObject === 'function' ? file.toObject() : file;
|
|
return DOWNLOAD_METADATA_FIELDS.reduce((metadata, field) => {
|
|
if (rawFile[field] !== undefined) {
|
|
metadata[field] = rawFile[field];
|
|
}
|
|
return metadata;
|
|
}, {});
|
|
};
|
|
|
|
router.get('/download-url/:userId/:file_id', fileAccess, async (req, res) => {
|
|
try {
|
|
const { userId, file_id } = req.params;
|
|
logger.debug(`File download URL requested by user ${userId}: ${file_id}`);
|
|
|
|
const file = req.fileAccess.file;
|
|
if (checkOpenAIStorage(file.source) && !file.model) {
|
|
logger.warn(
|
|
`File download URL 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 filename = cleanFileName(file.filename);
|
|
const downloadURL = checkOpenAIStorage(file.source)
|
|
? null
|
|
: await getDirectDownloadURL({ req, file, customFilename: filename });
|
|
|
|
if (!downloadURL) {
|
|
logger.debug(
|
|
`File download URL requested by user ${userId} is not supported for source: ${file.source}`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(200).json({
|
|
url: downloadURL,
|
|
filename,
|
|
type: file.type || 'application/octet-stream',
|
|
metadata: getDownloadFileMetadata(file),
|
|
});
|
|
} catch (error) {
|
|
logger.error('[DOWNLOAD URL ROUTE] Error generating file download URL:', error);
|
|
res.status(500).send('Error generating file download URL');
|
|
}
|
|
});
|
|
|
|
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}`);
|
|
|
|
// Access already validated by fileAccess middleware
|
|
const file = req.fileAccess.file;
|
|
|
|
if (checkOpenAIStorage(file.source) && !file.model) {
|
|
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, getDownloadURL } = getStrategyFunctions(file.source);
|
|
if (!getDownloadStream && !getDownloadURL) {
|
|
logger.warn(
|
|
`File download requested by user ${userId} has no download method implemented: ${file.source}`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
const setHeaders = () => {
|
|
res.setHeader('Content-Disposition', getContentDisposition(file.filename));
|
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
res.setHeader(
|
|
'X-File-Metadata',
|
|
encodeURIComponent(JSON.stringify(getDownloadFileMetadata(file))),
|
|
);
|
|
};
|
|
|
|
if (checkOpenAIStorage(file.source)) {
|
|
req.body = { model: file.model };
|
|
const endpointMap = {
|
|
[FileSources.openai]: EModelEndpoint.assistants,
|
|
[FileSources.azure]: EModelEndpoint.azureAssistants,
|
|
};
|
|
const { openai } = await getOpenAIClient({
|
|
req,
|
|
res,
|
|
overrideEndpoint: endpointMap[file.source],
|
|
});
|
|
logger.debug(`Downloading file ${file_id} from OpenAI`);
|
|
const passThrough = await getDownloadStream(file_id, openai);
|
|
setHeaders();
|
|
logger.debug(`File ${file_id} downloaded from OpenAI`);
|
|
|
|
// Handle both Node.js and Web streams
|
|
const stream =
|
|
passThrough.body && typeof passThrough.body.getReader === 'function'
|
|
? Readable.fromWeb(passThrough.body)
|
|
: passThrough.body;
|
|
|
|
stream.pipe(res);
|
|
} else {
|
|
if (getDownloadURL && req.query.direct === 'true') {
|
|
try {
|
|
const downloadURL = await getDirectDownloadURL({ req, file });
|
|
if (downloadURL) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.redirect(302, downloadURL);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
'[DOWNLOAD ROUTE] Falling back to stream after URL generation failed:',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!getDownloadStream) {
|
|
logger.warn(
|
|
`File download requested by user ${userId} has no stream method implemented: ${file.source}`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
const fileStream = await getDownloadStream(req, file.storageKey || file.filepath);
|
|
|
|
fileStream.on('error', (streamError) => {
|
|
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
|
|
});
|
|
|
|
setHeaders();
|
|
fileStream.pipe(res);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[DOWNLOAD ROUTE] Error downloading file:', error);
|
|
res.status(500).send('Error downloading file');
|
|
}
|
|
});
|
|
|
|
router.post('/', async (req, res) => {
|
|
const metadata = req.body;
|
|
let cleanup = true;
|
|
|
|
/** Opened only once auth/validation has passed, right before the potentially
|
|
* long-running upload processing begins — see `startUploadSseStream`. */
|
|
let sseStream = null;
|
|
const openSseStreamIfRequested = () => {
|
|
if (shouldUseUploadSse(req)) {
|
|
sseStream = startUploadSseStream(res);
|
|
}
|
|
};
|
|
|
|
try {
|
|
filterFile({ req });
|
|
|
|
metadata.temp_file_id = metadata.file_id;
|
|
metadata.file_id = req.file_id;
|
|
|
|
if (isAssistantsEndpoint(metadata.endpoint)) {
|
|
openSseStreamIfRequested();
|
|
return await processFileUpload({ req, res, metadata, sseStream });
|
|
}
|
|
|
|
let skipUploadAuth = false;
|
|
try {
|
|
skipUploadAuth = await hasCapability(req.user, SystemCapabilities.MANAGE_AGENTS);
|
|
} catch (err) {
|
|
logger.warn(`[/files] capability check failed, denying bypass: ${err.message}`);
|
|
}
|
|
|
|
if (!skipUploadAuth) {
|
|
const denied = await verifyAgentUploadPermission({
|
|
req,
|
|
res,
|
|
metadata,
|
|
getAgent: db.getAgent,
|
|
checkPermission,
|
|
});
|
|
if (denied) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
openSseStreamIfRequested();
|
|
return await processAgentFileUpload({ req, res, metadata, sseStream });
|
|
} catch (error) {
|
|
const message = resolveUploadErrorMessage(error);
|
|
logger.error('[/files] Error processing file:', error);
|
|
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
cleanup = false;
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting file:', error);
|
|
}
|
|
|
|
let errorStatusCode = 500;
|
|
if (error.userErrorStatusCode) {
|
|
errorStatusCode = error.userErrorStatusCode;
|
|
}
|
|
|
|
if (sseStream) {
|
|
sseStream.sendError({
|
|
message,
|
|
code: errorStatusCode,
|
|
temp_file_id: metadata.temp_file_id,
|
|
tool_resource: metadata.tool_resource,
|
|
display_to_user: true,
|
|
});
|
|
} else {
|
|
res.status(errorStatusCode).json({ message });
|
|
}
|
|
} finally {
|
|
if (cleanup) {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting file after file processing:', error);
|
|
}
|
|
} else {
|
|
logger.debug('[/files] File processing completed without cleanup');
|
|
}
|
|
if (sseStream) {
|
|
sseStream.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|