LibreChat/api/server/services/Files/process.js
Danny Avila fe30e3db32
🪫 fix: Back Off Failed Retention Deletes (#15608)
* 🧹 fix: Stop Undeletable Files Starving the Retention Sweep

`getExpiredFiles` returns the oldest `expiredAt` first, capped at `limit`,
and `processDeleteRequest` leaves the record in place when storage deletion
fails. Nothing records the failure, so the same files come back at the head
of the next batch an hour later, forever: no backoff, no cap, and — once
`limit` of them cannot be deleted — no file that expires afterwards is ever
swept again. On the deployment behind #15511 that is ~29k stranded objects
permanently occupying a 100-slot queue, which is why fixing the Code
Interpreter side alone (LibreChat-AI/code-interpreter#85) would not have
resumed deletion there.

Failures are now recorded on the file. `deletionRetryAt` holds it back with
a backoff doubling from one sweep interval to a day, and `deletionAttempts`
retires it from the query entirely once it reaches
`FILE_RETENTION_SWEEP_MAX_ATTEMPTS` (10, so roughly five days of retries).
Both fields are absent on existing records and absence means "never
attempted", so nothing already in the collection changes eligibility.

The record itself is kept rather than deleted — the reference is what an
operator needs to reconcile a bucket the sweep could not clear, and dropping
it would restore the silence that made this leak invisible. Raising
`FILE_RETENTION_SWEEP_MAX_ATTEMPTS` re-admits everything previously given up
on, which is the supported way to resume once the storage-side failure is
fixed; the give-up is logged with that instruction.

The counter is incremented server-side with `$inc` so concurrent sweeps on
separate nodes cannot overwrite each other's progress toward the cap, and a
failure to record a failure is logged and skipped rather than aborting the
rest of the batch.

* 🧭 fix: Delete Code Environment Files Through the Route That Exists

`deleteCodeEnvFile` tried `/sessions/:sid/objects/:fid` before falling back
to `/files/:sid/:fid`. Both have been there since #13424, but only the
second is mounted by any released codeapi — the first gained DELETE in
LibreChat-AI/code-interpreter#85 — so every deletion paid a guaranteed 404
and a wasted round trip, and #15511 read that 404 as the whole bug.

Call `/files/:sid/:fid` directly. It is the safe direction to collapse
toward: codeapi has mounted it since its first release, so this works
against older deployments as well as post-#85 ones, whereas keeping the
other path would not.

Collapsing the loop tightens two behaviours that only existed to serve it.
A 405 now surfaces instead of being swallowed on the way to a second
attempt; there is no second route to try, and a service that refuses the
method should say so. A 404 is still treated as "already gone" — that is
the only thing it can now mean — but it is logged rather than passed over
in silence, because a 404 caused by a misconfigured base URL looks
identical and this branch drops the file's metadata record either way.

* 🔒 fix: Settle sweep retry state from the write, not the read

Two findings from the review of 0e52924.

- `recordFailure` derived the attempt number by adding one to the count the
  batch had queried. Two nodes sweeping the same file read the same value,
  so both believed themselves to be the same attempt: each `$inc` landed,
  the stored count crossed `FILE_RETENTION_SWEEP_MAX_ATTEMPTS`, and neither
  caller ever saw the threshold. The file drops out of the query — the
  starvation guard still holds — but the give-up is never reported, and
  that log line is the operator's only notice, and carries the instruction
  for resuming. Return the count from the increment itself so every caller
  gets a distinct attempt number and exactly one observes the cap.

  The same staleness shortened the backoff, so `deferExpiredFile` now
  writes with `$max`: a deferral can only move later, and a node that
  computed a shorter delay cannot pull the file forward past one another
  node already committed.

- The backoff doubled from a hard-coded hour while claiming to start from
  one sweep interval. At the default they coincide; away from it the
  schedule stops meaning anything — on a six-hour sweep the first three
  attempts all land on consecutive passes, and on a five-minute one the
  first retry skips twelve. Derive the base from
  `FILE_RETENTION_SWEEP_INTERVAL_MS`, floored at a minute so a
  pathologically short interval cannot spend the whole give-up budget on a
  transient outage.

* 🧯 fix: Keep the give-up notice and the retry budget honest

Four findings from the review of ee1bac5.

- The give-up was reported after the deferral write, inside the same catch.
  Once the increment lands the counter is durable, so `getExpiredFiles`
  already excludes the file; a deferral that then failed left only the
  generic recording error and dropped the one line naming the file and
  saying how to resume. Report it as soon as the increment returns, and let
  the deferral fail on its own.

- Retry deadlines were measured from `Date.now()` after the deletion I/O,
  but `startExpiredFileSweep` arms its interval before the sweep runs. A
  one-interval delay therefore expired just *after* the next scheduled
  pass, which skipped the file and pushed its first retry out by a whole
  extra interval — and the same drift applied to every delay that is an
  exact multiple. Anchor deadlines to the sweep's start instead.

- The threshold used `>=`, so once two nodes pushed a file past the cap
  every one of them past it logged the give-up: one error per replica per
  exhausted file rather than one actionable notice. Only the attempt that
  lands exactly on the cap reports it.

- Retry state outlived the content it described. `processCodeOutput` reuses
  a record for a repeated `(filename, conversationId)` — new bytes, new
  storage key, and a fresh `expiredAt` from `getRetentionExpiry` — while
  `createFile` and `updateFile` set only supplied fields. A record carried
  to the cap by its previous content stayed excluded from the sweep
  forever, stranding the new object exactly as this PR set out to prevent;
  a partially failed one started the new object's budget already spent.
  Both write paths now clear the fields: the budget belongs to the storage
  a record currently points at.

The retry-state fixtures in `file.spec.ts` were seeded through `createFile`,
which now clears them, so they drive the real methods the sweep uses.

* 🎯 fix: Clear the retry budget only when a retention lifecycle starts

Both findings from the review of 8d26f9b, and both are consequences of that
commit's reset rather than of the original change.

- The reset was unconditional on every `createFile`/`updateFile`, on the
  reasoning that those paths write content. Two of them do not.
  `prepareImages{Local,Azure,Firebase}` call `updateFile({ file_id })` with
  nothing but the id — a TTL touch — every time an existing image is
  encoded for another chat, and the deferred preview uses the same method
  to transition `status`. Either handed a stranded record a fresh set of
  attempts and another give-up notice, on repeat, defeating the cap for the
  files most likely to be stranded.

  Gate it on the write actually setting `expiredAt`. The budget belongs to
  a retention lifecycle: that is the write which starts a new one, it is
  what `processCodeOutput` supplies when it repurposes a record, and a
  record with no retention deadline is never swept, so the fields are inert
  there anyway.

- Both retry writes bumped `updatedAt`. `processCodeOutput` falls back to
  `updatedAt` as the writer-order stamp for records that predate
  `metadata.sourceDispatchedAt`, so a failed sweep landing mid-harvest read
  as a newer content writer and the harvest dropped its attachment. Mark
  them `timestamps: false`, for the reason `claimCodeFile` already does:
  bookkeeping is not a content write.

* 🅿️ refactor: Park exhausted files instead of excluding them

Three review rounds in a row found defects in how the give-up cap interacts
with record reuse, each in the fix for the last. That is a design error, not
a bug list: a permanent exclusion has to be bound precisely to the content
lifecycle it was recorded against, and File records outlive their content.
`processCodeOutput` repurposes a row for a repeated
`(filename, conversationId)`, `createFile`/`updateFile` set only supplied
fields, and `getRetentionExpiry` returns `{}` on a lookup failure so the row
inherits its old deadline — three separate ways for bookkeeping to survive
into a lifecycle it does not describe, each needing its own guard, and the
two retry writes needing to be lifecycle-conditional on top.

Remove the category instead. `deletionRetryAt` becomes the sweep's only
hold, and reaching `FILE_RETENTION_SWEEP_MAX_ATTEMPTS` parks the file for a
month rather than excluding it. The bound on the batch is the same — a
stranded file costs one slot a month instead of one an hour — but a deadline
that outlives its content can only delay the next object, never lose it, so
nothing outside the sweep has to reason about this state at all.

That deletes more than it adds:

- `createFile` and `updateFile` go back to their original form. No reset, so
  no question of which writes install content, and `prepareImages*` and the
  deferred preview stop mattering here.
- `getExpiredFiles` loses `maxAttempts` and its `$and`; eligibility is one
  `$or` on the deadline.
- The interleaving race between the increment and the deferral degrades from
  a stranded object to a delayed one.

Working through a large backlog is throughput-bound either way: every
attempt costs a slot in the bounded batch, so N stranded files need N ×
`FILE_RETENTION_SWEEP_MAX_ATTEMPTS` passes to settle. Lower that limit when
recovering a deployment that has accumulated many.
2026-09-04 15:38:17 -04:00

1454 lines
48 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const mime = require('mime');
const { v4 } = require('uuid');
const {
isUUID,
megabyte,
FileContext,
FileSources,
imageExtRegex,
EModelEndpoint,
EToolResources,
mergeCodeEnvRef,
mergeFileConfig,
AgentCapabilities,
checkOpenAIStorage,
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const {
sanitizeFilename,
parseText,
processAudioFile,
extractInspectableFileText,
assertExtractedTextInspectable,
getFileExtractionLogDetails,
getUploadExtractedTextPlan,
UPLOAD_EXTRACTED_TEXT_PLANS,
inspectContent,
extractFileContent,
hasActiveFileFieldPolicy,
sendUploadSuccess,
getStorageMetadata,
contentFilterBlockResponse,
sweepExpiredFiles: sweepExpiredFilesWithDeps,
startExpiredFileSweep: startExpiredFileSweepWithDeps,
} = require('@librechat/api');
const {
convertImage,
resizeAndConvert,
resizeImageBuffer,
} = require('~/server/services/Files/images');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { checkCapability } = require('~/server/services/Config');
const { LB_QueueAsyncCall } = require('~/server/utils/queue');
const { getRetentionExpiry, getAgentFileRetentionExpiry } = require('./retention');
const { getStrategyFunctions } = require('./strategies');
const { determineFileType } = require('~/server/utils');
const { STTService } = require('./Audio/STTService');
const db = require('~/models');
/**
* Creates a modular file upload wrapper that ensures filename sanitization
* across all storage strategies. This prevents storage-specific implementations
* from having to handle sanitization individually.
*
* @param {Function} uploadFunction - The storage strategy's upload function
* @returns {Function} - Wrapped upload function with sanitization
*/
const createSanitizedUploadWrapper = (uploadFunction) => {
return async (params) => {
const { req, file, file_id, ...restParams } = params;
// Create a modified file object with sanitized original name
// This ensures consistent filename handling across all storage strategies
const sanitizedFile = {
...file,
originalname: sanitizeFilename(file.originalname),
};
return uploadFunction({ req, file: sanitizedFile, file_id, ...restParams });
};
};
const hasCodeEnvRef = (file) =>
file?.metadata?.codeEnvRef != null || file?.metadata?.codeEnvRefs != null;
const isMissingStorageError = (err) => {
const code = err?.code ?? err?.status ?? err?.statusCode ?? err?.response?.status;
if ([404, '404', 'ENOENT', 'NoSuchKey', 'NotFound', 'ResourceNotFound'].includes(code)) {
return true;
}
return /(?:file|object|blob|key|resource) (?:not found|does not exist)|no such (?:file|key)/i.test(
String(err?.message ?? ''),
);
};
/**
* Enqueues the delete operation to the leaky bucket queue if necessary, or adds it directly to promises.
*
* @param {object} params - The passed parameters.
* @param {ServerRequest} params.req - The express request object.
* @param {MongoFile} params.file - The file object to delete.
* @param {Function} params.deleteFile - The delete file function.
* @param {Promise[]} params.promises - The array of promises to await.
* @param {Set<string>} params.resolvedFileIds - File IDs whose storage delete succeeded.
* @param {Set<string>} params.failedFileIds - File IDs whose storage delete failed.
* @param {OpenAI | undefined} [params.openai] - If an OpenAI file, the initialized OpenAI client.
*/
function enqueueDeleteOperation({
req,
file,
deleteFile,
promises,
resolvedFileIds,
failedFileIds,
openai,
}) {
if (checkOpenAIStorage(file.source)) {
// Enqueue to leaky bucket
promises.push(
new Promise((resolve, reject) => {
LB_QueueAsyncCall(
() => deleteFile(req, file, openai),
[],
(err, result) => {
if (err) {
if (isMissingStorageError(err)) {
resolvedFileIds.add(file.file_id);
logger.warn('File storage was already missing during delete', err);
resolve(result);
return;
}
failedFileIds.add(file.file_id);
logger.error('Error deleting file from OpenAI source', err);
reject(err);
} else {
resolvedFileIds.add(file.file_id);
resolve(result);
}
},
);
}),
);
} else {
// Add directly to promises
promises.push(
deleteFile(req, file)
.then(() => resolvedFileIds.add(file.file_id))
.catch((err) => {
if (isMissingStorageError(err)) {
resolvedFileIds.add(file.file_id);
logger.warn('File storage was already missing during delete', err);
return;
}
failedFileIds.add(file.file_id);
logger.error('Error deleting file', err);
return Promise.reject(err);
}),
);
}
}
const getDeleteMethod = ({ source, deletionMethods }) => {
if (deletionMethods[source]) {
return deletionMethods[source];
}
const { deleteFile } = getStrategyFunctions(source);
if (!deleteFile) {
throw new Error(`Delete function not implemented for ${source}`);
}
deletionMethods[source] = deleteFile;
return deleteFile;
};
const createDeleteFileWithSecondaryStorage = ({ source, deleteFile, deletionMethods }) => {
return async (req, file, openai) => {
const secondaryDeleteMethods = [];
if (file.embedded === true && source !== FileSources.vectordb) {
secondaryDeleteMethods.push(
getDeleteMethod({ source: FileSources.vectordb, deletionMethods }),
);
}
if (hasCodeEnvRef(file) && source !== FileSources.execute_code) {
secondaryDeleteMethods.push(
getDeleteMethod({ source: FileSources.execute_code, deletionMethods }),
);
}
try {
await deleteFile(req, file, openai);
} catch (err) {
if (!isMissingStorageError(err)) {
throw err;
}
logger.warn('Primary file storage was already missing during delete', err);
}
await Promise.all(
secondaryDeleteMethods.map((secondaryDeleteFile) => secondaryDeleteFile(req, file)),
);
};
};
// TODO: refactor as currently only image files can be deleted this way
// as other filetypes will not reside in public path
/**
* Deletes a list of files from the server filesystem and the database.
*
* @param {Object} params - The params object.
* @param {MongoFile[]} params.files - The file objects to delete.
* @param {ServerRequest} params.req - The express request object.
* @param {DeleteFilesBody} params.req.body - The request body.
* @param {string} [params.req.body.agent_id] - The agent ID if file uploaded is associated to an agent.
* @param {string} [params.req.body.assistant_id] - The assistant ID if file uploaded is associated to an assistant.
* @param {string} [params.req.body.tool_resource] - The tool resource if assistant file uploaded is associated to a tool resource.
*
* @returns {Promise<{ deletedFileIds: string[], failedFileIds: string[] }>}
* @throws {Error} When storage deletion cannot be scheduled or file metadata cleanup fails.
*/
const processDeleteRequest = async ({ req, files }) => {
const appConfig = req.config;
const resolvedFileIds = new Set();
const failedFileIds = new Set();
const deletionMethods = {};
const promises = [];
/** @type {Record<string, OpenAI | undefined>} */
const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined };
const initializeClients = async () => {
if (appConfig.endpoints?.[EModelEndpoint.assistants]) {
const openAIClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.assistants,
});
client[FileSources.openai] = openAIClient.openai;
}
if (!appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
return;
}
const azureClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.azureAssistants,
});
client[FileSources.azure] = azureClient.openai;
};
if (req.body.assistant_id !== undefined) {
await initializeClients();
}
const agentFiles = [];
for (const file of files) {
const source = file.source ?? FileSources.local;
if (req.body.agent_id && req.body.tool_resource) {
agentFiles.push({
tool_resource: req.body.tool_resource,
file_id: file.file_id,
});
}
if (source === FileSources.text) {
resolvedFileIds.add(file.file_id);
continue;
}
if (checkOpenAIStorage(source) && !client[source]) {
await initializeClients();
}
const openai = client[source];
if (req.body.assistant_id && req.body.tool_resource) {
promises.push(
deleteResourceFileId({
req,
openai,
file_id: file.file_id,
assistant_id: req.body.assistant_id,
tool_resource: req.body.tool_resource,
}),
);
} else if (req.body.assistant_id) {
promises.push(openai.beta.assistants.files.del(req.body.assistant_id, file.file_id));
}
const deleteFile = getDeleteMethod({ source, deletionMethods });
enqueueDeleteOperation({
req,
file,
deleteFile: createDeleteFileWithSecondaryStorage({ source, deleteFile, deletionMethods }),
promises,
resolvedFileIds,
failedFileIds,
openai,
});
}
if (agentFiles.length > 0) {
promises.push(
db.removeAgentResourceFiles({
agent_id: req.body.agent_id,
files: agentFiles,
}),
);
}
await Promise.allSettled(promises);
const deletedFileIds = [...resolvedFileIds];
let metadataDeletedFileIds = deletedFileIds;
if (deletedFileIds.length > 0) {
try {
await db.deleteFiles(deletedFileIds);
} catch (error) {
logger.error('Error deleting file metadata after storage deletion', error);
deletedFileIds.forEach((fileId) => failedFileIds.add(fileId));
metadataDeletedFileIds = [];
throw error;
}
if (metadataDeletedFileIds.length > 0) {
try {
await db.removeAgentResourceFilesFromAllAgents({ file_ids: metadataDeletedFileIds });
} catch (error) {
logger.error('Error cleaning up orphaned agent file references', error);
}
}
}
return {
deletedFileIds: metadataDeletedFileIds,
failedFileIds: [...failedFileIds],
};
};
/**
* Deletes expired file storage before removing the corresponding File records.
*
* Mongo TTL indexes delete only the metadata document, so file retention uses
* this application sweep for records with `expiredAt` instead.
*
* @param {object} params
* @param {AppConfig} params.appConfig
* @param {number} [params.limit]
* @param {() => Promise<AppConfig>} [params.loadAppConfig]
* @returns {Promise<{ scanned: number, deleted: number, failed: number }>}
*/
async function sweepExpiredFiles(options = {}) {
return sweepExpiredFilesWithDeps(options, {
getExpiredFiles: db.getExpiredFiles,
processDeleteRequest,
incrementFileDeletionAttempts: db.incrementFileDeletionAttempts,
deferExpiredFile: db.deferExpiredFile,
logger,
});
}
function startExpiredFileSweep(options = {}) {
return startExpiredFileSweepWithDeps(options, {
sweepExpiredFiles,
runAsSystem,
logger,
});
}
/**
* Processes a file URL using a specified file handling strategy. This function accepts a strategy name,
* fetches the corresponding file processing functions (for saving and retrieving file URLs), and then
* executes these functions in sequence. It first saves the file using the provided URL and then retrieves
* the URL of the saved file. If any error occurs during this process, it logs the error and throws an
* exception with an appropriate message.
*
* @param {Object} params - The parameters object.
* @param {FileSources} params.fileStrategy - The file handling strategy to use.
* Must be a value from the `FileSources` enum, which defines different file
* handling strategies (like saving to Firebase, local storage, etc.).
* @param {string} params.userId - The user's unique identifier. Used for creating user-specific paths or
* references in the file handling process.
* @param {string} params.URL - The URL of the file to be processed.
* @param {string} params.fileName - The name that will be used to save the file (including extension)
* @param {string} params.basePath - The base path or directory where the file will be saved or retrieved from.
* @param {FileContext} params.context - The context of the file (e.g., 'avatar', 'image_generation', etc.)
* @param {string} [params.tenantId] - Optional tenant identifier for tenant-prefixed storage paths.
* @param {ServerRequest} [params.req] - Request context used to apply data retention metadata.
* @returns {Promise<MongoFile>} A promise that resolves to the DB representation (MongoFile)
* of the processed file. It throws an error if the file processing fails at any stage.
*/
const processFileURL = async ({
fileStrategy,
userId,
URL,
fileName,
basePath,
context,
tenantId,
req,
}) => {
const { saveURL, getFileURL } = getStrategyFunctions(fileStrategy);
try {
const savedFile = await saveURL({ userId, URL, fileName, basePath, tenantId });
if (!savedFile) {
throw new Error(`Strategy "${fileStrategy}" did not save "${fileName}"`);
}
const {
bytes = 0,
type = '',
dimensions = {},
} = typeof savedFile === 'string' ? {} : savedFile;
const fallbackFileName =
fileStrategy === FileSources.local || fileStrategy === FileSources.firebase
? `${userId}/${fileName}`
: fileName;
const filepath =
typeof savedFile === 'string'
? savedFile
: (savedFile.filepath ??
(await getFileURL({ userId, fileName: fallbackFileName, basePath, tenantId })));
if (!filepath) {
throw new Error(`Strategy "${fileStrategy}" did not return a file URL for "${fileName}"`);
}
const storageMetadata = getStorageMetadata({
filepath,
source: fileStrategy,
storageKey: typeof savedFile === 'string' ? undefined : savedFile.storageKey,
storageRegion: typeof savedFile === 'string' ? undefined : savedFile.storageRegion,
});
return await db.createFile(
{
user: userId,
file_id: v4(),
bytes,
filepath,
...storageMetadata,
filename: fileName,
source: fileStrategy,
type,
context,
...(await getRetentionExpiry(req)),
tenantId,
width: dimensions.width,
height: dimensions.height,
},
true,
);
} catch (error) {
logger.error(`Error while processing the image with ${fileStrategy}:`, error);
throw new Error(`Failed to process the image with ${fileStrategy}. ${error.message}`);
}
};
/**
* Applies the current strategy for image uploads.
* Saves file metadata to the database with an expiry TTL.
*
* @param {Object} params - The parameters object.
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} [params.res] - The Express response object.
* @param {ImageMetadata} params.metadata - Additional metadata for the file.
* @param {boolean} params.returnFile - Whether to return the file metadata or return response as normal.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processImageFile = async ({ req, res, metadata, returnFile = false, sseStream }) => {
const { file } = req;
const appConfig = req.config;
const source = getFileStrategy(appConfig, { isImage: true });
const { handleImageUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id, endpoint } = metadata;
const { filepath, bytes, width, height, storageKey, storageRegion } = await handleImageUpload({
req,
file,
file_id,
endpoint,
});
const storageMetadata = getStorageMetadata({ filepath, source, storageKey, storageRegion });
const result = await db.createFile(
{
user: req.user.id,
file_id,
temp_file_id,
bytes,
filepath,
...storageMetadata,
filename: file.originalname,
context: FileContext.message_attachment,
source,
type: `image/${appConfig.imageOutputType}`,
...(await getRetentionExpiry(req)),
width,
height,
tenantId: req.user.tenantId,
},
true,
);
if (returnFile) {
return result;
}
sendUploadSuccess(res, sseStream, 'File uploaded and processed successfully', result);
};
/**
* Applies the current strategy for image uploads and
* returns minimal file metadata, without saving to the database.
*
* @param {Object} params - The parameters object.
* @param {ServerRequest} params.req - The Express request object.
* @param {FileContext} params.context - The context of the file (e.g., 'avatar', 'image_generation', etc.)
* @param {boolean} [params.resize=true] - Whether to resize and convert the image to target format. Default is `true`.
* @param {{ buffer: Buffer, width: number, height: number, bytes: number, filename: string, type: string, file_id: string }} [params.metadata] - Required metadata for the file if resize is false.
* @returns {Promise<{ filepath: string, filename: string, source: string, type: string}>}
*/
const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) => {
const appConfig = req.config;
const source = getFileStrategy(appConfig, { isImage: true });
const { saveBuffer } = getStrategyFunctions(source);
let { buffer, width, height, bytes, filename, file_id, type } = metadata;
if (resize) {
file_id = v4();
type = `image/${appConfig.imageOutputType}`;
({ buffer, width, height, bytes } = await resizeAndConvert({
inputBuffer: buffer,
desiredFormat: appConfig.imageOutputType,
}));
filename = `${path.basename(req.file.originalname, path.extname(req.file.originalname))}.${
appConfig.imageOutputType
}`;
}
const fileName = `${file_id}-${filename}`;
const filepath = await saveBuffer({
userId: req.user.id,
fileName,
buffer,
tenantId: req.user.tenantId,
});
const storageMetadata = getStorageMetadata({ filepath, source });
return await db.createFile(
{
user: req.user.id,
file_id,
bytes,
filepath,
...storageMetadata,
filename,
context,
source,
type,
width,
...(await getRetentionExpiry(req)),
height,
tenantId: req.user.tenantId,
},
true,
);
};
/**
* Applies the current strategy for file uploads.
* Saves file metadata to the database with an expiry TTL.
* Files must be deleted from the server filesystem manually.
*
* @param {Object} params - The parameters object.
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {FileMetadata} params.metadata - Additional metadata for the file.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processFileUpload = async ({ req, res, metadata, sseStream }) => {
const appConfig = req.config;
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
const assistantSource =
metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
// Use the configured file strategy for regular file uploads (not vectordb)
const source = isAssistantUpload ? assistantSource : appConfig.fileStrategy;
const { handleFileUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id = null } = metadata;
/** @type {OpenAI | undefined} */
let openai;
if (checkOpenAIStorage(source)) {
({ openai } = await getOpenAIClient({ req }));
}
const { file } = req;
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
const {
id,
bytes,
filename,
filepath: _filepath,
storageKey: _storageKey,
storageRegion: _storageRegion,
embedded,
height,
width,
} = await sanitizedUploadFn({
req,
file,
file_id,
openai,
});
if (isAssistantUpload && !metadata.message_file && !metadata.tool_resource) {
await openai.beta.assistants.files.create(metadata.assistant_id, {
file_id: id,
});
} else if (isAssistantUpload && !metadata.message_file) {
await addResourceFileId({
req,
openai,
file_id: id,
assistant_id: metadata.assistant_id,
tool_resource: metadata.tool_resource,
});
}
let filepath = isAssistantUpload ? `${openai.baseURL}/files/${id}` : _filepath;
let storageMetadata = getStorageMetadata({
filepath,
source,
storageKey: _storageKey,
storageRegion: _storageRegion,
});
if (isAssistantUpload && file.mimetype.startsWith('image')) {
const result = await processImageFile({
req,
file,
metadata: { file_id: v4() },
returnFile: true,
});
filepath = result.filepath;
storageMetadata = getStorageMetadata({
filepath,
source: result.source,
storageKey: result.storageKey,
storageRegion: result.storageRegion,
});
}
const result = await db.createFile(
{
user: req.user.id,
file_id: id ?? file_id,
temp_file_id,
bytes,
filepath,
...storageMetadata,
filename: filename ?? sanitizeFilename(file.originalname),
context: isAssistantUpload ? FileContext.assistants : FileContext.message_attachment,
model: isAssistantUpload ? req.body.model : undefined,
type: file.mimetype,
...(await getRetentionExpiry(req)),
embedded,
source,
height,
width,
tenantId: req.user.tenantId,
},
true,
);
sendUploadSuccess(res, sseStream, 'File uploaded and processed successfully', result);
};
/**
* Applies the current strategy for file uploads.
* Saves file metadata to the database with an expiry TTL.
* Files must be deleted from the server filesystem manually.
*
* @param {Object} params - The parameters object.
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {FileMetadata} params.metadata - Additional metadata for the file.
* @param {import('@librechat/api').UploadSseStream | null} [params.sseStream] - Active upload SSE stream, if enabled.
* @returns {Promise<void>}
*/
const processAgentFileUpload = async ({ req, res, metadata, sseStream }) => {
const { file } = req;
const appConfig = req.config;
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
let messageAttachment = !!metadata.message_file;
if (agent_id && !tool_resource && !messageAttachment) {
throw new Error('No tool resource provided for agent file upload');
}
if (tool_resource === EToolResources.file_search && file.mimetype.startsWith('image')) {
throw new Error('Image uploads are not supported for file search tool resources');
}
if (!messageAttachment && !agent_id) {
throw new Error('No agent ID provided for agent file upload');
}
const isImage = file.mimetype.startsWith('image');
let fileInfoMetadata;
const entity_id = messageAttachment === true ? undefined : agent_id;
const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads';
if (tool_resource === EToolResources.execute_code) {
const isCodeEnabled = await checkCapability(req, AgentCapabilities.execute_code);
if (!isCodeEnabled) {
throw new Error('Code execution is not enabled for Agents');
}
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code);
const stream = fs.createReadStream(file.path);
/* Resource identity for codeapi's sessionKey:
* - chat attachments (messageAttachment=true): `kind: 'user'`, codeapi
* buckets under `<tenant>:user:<authContext.userId>` regardless of `id`.
* - agent setup files (messageAttachment=false): `kind: 'agent'`, shared
* per agent identity. `id` carries the agent id. */
const codeKind = messageAttachment === true ? 'user' : 'agent';
const codeId = messageAttachment === true ? req.user.id : agent_id;
/* Upload under the same sanitized filename LC stores in its DB
* (`fileInfo.filename` below uses `sanitizeFilename(originalname)`).
* Codeapi/file_server use this as the on-disk name in the sandbox
* — `/mnt/data/<filename>` — and `primeFiles`'s `toolContext` text
* + `_injected_files.name` both reference `file.filename`. Sending
* the unsanitized `file.originalname` here makes the sandbox path
* (with spaces / special chars) drift from what LC tells the model
* is available, causing FileNotFoundError on the first reference. */
const sandboxFilename = sanitizeFilename(file.originalname);
const uploaded = await uploadCodeEnvFile({
req,
stream,
filename: sandboxFilename,
kind: codeKind,
id: codeId,
});
/* Persist under the structured `codeEnvRef` shape — the only key the
* post-cutover schema (`metadata.codeEnvRef`) and downstream readers
* (`primeFiles`, `getCodeFilesByIds`, `categorizeFileForToolResources`,
* controller filtering) accept. Storing under the legacy
* `fileIdentifier` key would be silently dropped by mongoose strict
* mode and the file would lose its sandbox reference on subsequent
* priming turns. */
fileInfoMetadata = mergeCodeEnvRef(undefined, {
kind: codeKind,
id: codeId,
storage_session_id: uploaded.storage_session_id,
file_id: uploaded.file_id,
executionProfile: 'default',
});
} else if (tool_resource === EToolResources.file_search) {
const isFileSearchEnabled = await checkCapability(req, AgentCapabilities.file_search);
if (!isFileSearchEnabled) {
throw new Error('File search is not enabled for Agents');
}
// Note: File search processing continues to dual storage logic below
} else if (tool_resource === EToolResources.context) {
const { file_id, temp_file_id = null } = metadata;
const getExtractionLogDetails = (error) =>
getFileExtractionLogDetails({
filters: appConfig?.filters,
filename: file.originalname,
fileId: file_id,
error,
});
const { fileLabel: extractionFileLabel } = getExtractionLogDetails(undefined);
/**
* @param {object} params
* @param {string} params.text
* @param {number} params.bytes
* @param {string} params.filepath
* @param {string} params.type
* @param {boolean} params.isTranscript
* @return {Promise<void>}
*/
const createTextFile = async ({
text,
bytes,
filepath,
type = 'text/plain',
isTranscript = false,
}) => {
if (!isTranscript) {
assertExtractedTextInspectable({
filters: appConfig?.filters,
text,
});
}
const textBytes = Buffer.byteLength(text, 'utf8');
if (textBytes > 15 * megabyte) {
throw new Error(
`Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`,
);
}
if (
hasActiveFileFieldPolicy(appConfig?.filters, [
isTranscript ? 'transcript' : 'extracted_text',
])
) {
const content = isTranscript ? { transcript: text } : { extractedText: text };
const finding = inspectContent(extractFileContent(content), {
filters: appConfig.filters,
});
if (finding != null) {
const blockResponse = contentFilterBlockResponse(finding);
if (sseStream) {
sseStream.sendError({
...blockResponse,
code: 400,
temp_file_id,
tool_resource,
display_to_user: true,
});
} else {
res.status(400).json(blockResponse);
}
return;
}
}
const retentionExpiry = await getAgentFileRetentionExpiry({
req,
messageAttachment,
tool_resource,
});
const fileInfo = {
...removeNullishValues({
text,
bytes,
file_id,
temp_file_id,
user: req.user.id,
type,
filepath: filepath ?? file.path,
source: FileSources.text,
filename: file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
tenantId: req.user.tenantId,
}),
...retentionExpiry,
};
if (!messageAttachment && tool_resource) {
await db.addAgentResourceFile({
file_id,
agent_id,
tool_resource,
updatingUserId: req?.user?.id,
});
}
const result = await db.createFile(fileInfo, true);
sendUploadSuccess(res, sseStream, 'Agent file uploaded and processed successfully', result);
};
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const extractedTextPlan = getUploadExtractedTextPlan({
endpoint: metadata.endpoint,
toolResource: tool_resource,
mimeType: file.mimetype,
fileConfig,
ocrConfigured: appConfig?.ocr != null,
ragConfigured: !!process.env.RAG_API_URL,
});
const shouldUseConfiguredOCR = extractedTextPlan === UPLOAD_EXTRACTED_TEXT_PLANS.configuredOCR;
const shouldUseConfiguredText = extractedTextPlan === UPLOAD_EXTRACTED_TEXT_PLANS.configuredRAG;
const shouldUseDocumentParser =
extractedTextPlan === UPLOAD_EXTRACTED_TEXT_PLANS.documentParser;
const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser;
const resolveDocumentText = async () => {
if (shouldUseConfiguredOCR) {
try {
const ocrStrategy = appConfig?.ocr?.strategy ?? FileSources.document_parser;
const { handleFileUpload } = getStrategyFunctions(ocrStrategy);
return await handleFileUpload({ req, file, loadAuthValues });
} catch (err) {
const { errorMetadata } = getExtractionLogDetails(err);
logger.error(
`[processAgentFileUpload] Configured OCR failed for ${extractionFileLabel}, falling back to document_parser:`,
errorMetadata,
);
}
}
try {
const { handleFileUpload } = getStrategyFunctions(FileSources.document_parser);
return await handleFileUpload({ req, file, loadAuthValues });
} catch (err) {
const { errorMetadata } = getExtractionLogDetails(err);
logger.error(
`[processAgentFileUpload] Document parser failed for ${extractionFileLabel}:`,
errorMetadata,
);
}
};
if (shouldUseConfiguredOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
throw new Error('OCR capability is not enabled for Agents');
}
if (shouldUseOCR) {
const ocrResult = await extractInspectableFileText({
filters: appConfig?.filters,
extract: resolveDocumentText,
});
if (ocrResult) {
const { text, bytes, filepath: ocrFileURL } = ocrResult;
return await createTextFile({ text, bytes, filepath: ocrFileURL });
}
throw new Error(
`Unable to extract text from "${file.originalname}". The document may be image-based and requires an OCR service to process.`,
);
}
const shouldUseSTT = fileConfig.checkType(
file.mimetype,
fileConfig.stt?.supportedMimeTypes || [],
);
if (shouldUseSTT) {
const sttService = await STTService.getInstance();
const { text, bytes } = await processAudioFile({ req, file, sttService });
return await createTextFile({ text, bytes, type: file.mimetype, isTranscript: true });
}
const shouldUseText = fileConfig.checkType(
file.mimetype,
fileConfig.text?.supportedMimeTypes || [],
);
if (!shouldUseText) {
throw new Error(`File type ${file.mimetype} is not supported for text parsing.`);
}
/**
* A document type the admin routed to configured text extraction: prefer RAG `/text`, but fall
* back to the built-in document parser (not raw native text) when RAG is unavailable, so a
* transient outage doesn't degrade a docx/pdf to unreadable bytes. Only the RAG extraction is
* inside the fallback catch: a downstream persistence failure (size guard, DB, agent-resource
* mutation) must surface as itself, not trigger a second extraction attempt.
*/
if (shouldUseConfiguredText) {
let configuredText;
try {
configuredText = await parseText({ req, file, file_id, allowNativeFallback: false });
} catch (err) {
const { errorMetadata } = getExtractionLogDetails(err);
logger.warn(
`[processAgentFileUpload] Configured RAG text extraction unavailable for ${extractionFileLabel}, using built-in document parser:`,
errorMetadata,
);
const documentText = await extractInspectableFileText({
filters: appConfig?.filters,
extract: resolveDocumentText,
});
if (!documentText) {
throw new Error(
`Unable to extract text from "${file.originalname}". RAG text extraction was unavailable and the built-in parser produced no result.`,
);
}
const { text, bytes, filepath: docFileURL } = documentText;
return await createTextFile({ text, bytes, filepath: docFileURL });
}
return await createTextFile({
text: configuredText.text,
bytes: configuredText.bytes,
type: file.mimetype,
});
}
const { text, bytes } = await extractInspectableFileText({
filters: appConfig?.filters,
extract: () => parseText({ req, file, file_id }),
});
return await createTextFile({ text, bytes, type: file.mimetype });
}
// Dual storage pattern for RAG files: Storage + Vector DB
let storageResult, embeddingResult;
const isImageFile = file.mimetype.startsWith('image');
const source = getFileStrategy(appConfig, { isImage: isImageFile });
if (tool_resource === EToolResources.file_search) {
// FIRST: Upload to Storage for permanent backup (S3/local/etc.)
const { handleFileUpload } = getStrategyFunctions(source);
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
storageResult = await sanitizedUploadFn({
req,
file,
file_id,
basePath,
entity_id,
});
// SECOND: Upload to Vector DB
const { uploadVectors } = require('./VectorDB/crud');
embeddingResult = await uploadVectors({
req,
file,
file_id,
entity_id,
});
// Vector status will be stored at root level, no need for metadata
fileInfoMetadata = {};
} else {
// Standard single storage for non-RAG files
const { handleFileUpload } = getStrategyFunctions(source);
const sanitizedUploadFn = createSanitizedUploadWrapper(handleFileUpload);
storageResult = await sanitizedUploadFn({
req,
file,
file_id,
basePath,
entity_id,
});
}
let {
bytes,
filename,
filepath: _filepath,
storageKey: _storageKey,
storageRegion: _storageRegion,
height,
width,
} = storageResult;
// For RAG files, use embedding result; for others, use storage result
let embedded = storageResult.embedded;
if (tool_resource === EToolResources.file_search) {
embedded = embeddingResult?.embedded;
filename = embeddingResult?.filename || filename;
}
let filepath = _filepath;
let storageMetadata = getStorageMetadata({
filepath,
source,
storageKey: _storageKey,
storageRegion: _storageRegion,
});
if (!messageAttachment && tool_resource) {
await db.addAgentResourceFile({
file_id,
agent_id,
tool_resource,
updatingUserId: req?.user?.id,
});
}
if (isImage) {
const result = await processImageFile({
req,
file,
metadata: { file_id: v4() },
returnFile: true,
});
filepath = result.filepath;
storageMetadata = getStorageMetadata({
filepath,
source: result.source,
storageKey: result.storageKey,
storageRegion: result.storageRegion,
});
}
const retentionExpiry = await getAgentFileRetentionExpiry({
req,
messageAttachment,
tool_resource,
});
const fileInfo = {
...removeNullishValues({
user: req.user.id,
file_id,
temp_file_id,
bytes,
filepath,
...storageMetadata,
filename: filename ?? sanitizeFilename(file.originalname),
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
model: messageAttachment ? undefined : req.body.model,
metadata: fileInfoMetadata,
type: file.mimetype,
embedded,
source,
height,
width,
tenantId: req.user.tenantId,
}),
...retentionExpiry,
};
const result = await db.createFile(fileInfo, true);
sendUploadSuccess(res, sseStream, 'Agent file uploaded and processed successfully', result);
};
/**
* @param {object} params - The params object.
* @param {OpenAI} params.openai - The OpenAI client instance.
* @param {string} params.file_id - The ID of the file to retrieve.
* @param {string} params.userId - The user ID.
* @param {string} [params.filename] - The name of the file. `undefined` for `file_citation` annotations.
* @param {boolean} [params.saveFile=false] - Whether to save the file metadata to the database.
* @param {boolean} [params.updateUsage=false] - Whether to update file usage in database.
*/
const processOpenAIFile = async ({
openai,
file_id,
userId,
filename,
saveFile = false,
updateUsage = false,
}) => {
const _file = await openai.files.retrieve(file_id);
const originalName = filename ?? (_file.filename ? path.basename(_file.filename) : undefined);
const filepath = `${openai.baseURL}/files/${userId}/${file_id}${
originalName ? `/${originalName}` : ''
}`;
const type = mime.getType(originalName ?? file_id);
const source =
openai.req.body.endpoint === EModelEndpoint.azureAssistants
? FileSources.azure
: FileSources.openai;
const file = {
..._file,
type,
file_id,
filepath,
usage: 1,
user: userId,
context: _file.purpose,
source,
model: openai.req.body.model,
filename: originalName ?? file_id,
...(await getRetentionExpiry(openai.req)),
tenantId: openai.req?.user?.tenantId,
};
if (saveFile) {
await db.createFile(file, true);
} else if (updateUsage) {
try {
await db.updateFileUsage({
file_id,
user: userId,
tenantId: openai.req?.user?.tenantId,
});
} catch (error) {
logger.error('Error updating file usage', error);
}
}
return file;
};
/**
* Process OpenAI image files, convert to target format, save and return file metadata.
* @param {object} params - The params object.
* @param {ServerRequest} params.req - The Express request object.
* @param {Buffer} params.buffer - The image buffer.
* @param {string} params.file_id - The file ID.
* @param {string} params.filename - The filename.
* @param {string} params.fileExt - The file extension.
* @returns {Promise<MongoFile>} The file metadata.
*/
const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => {
const currentDate = new Date();
const formattedDate = currentDate.toISOString();
const appConfig = req.config;
const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`);
// Create only one file record with the correct information
const file = {
..._file,
usage: 1,
user: req.user.id,
type: mime.getType(fileExt),
createdAt: formattedDate,
updatedAt: formattedDate,
source: getFileStrategy(appConfig, { isImage: true }),
context: FileContext.assistants_output,
file_id,
filename,
...(await getRetentionExpiry(req)),
tenantId: req.user.tenantId,
};
try {
await db.createFile(file, true);
} catch (error) {
logger.warn('Error saving OpenAI image output file metadata', error);
}
return file;
};
/**
* Retrieves and processes an OpenAI file based on its type.
*
* @param {Object} params - The params passed to the function.
* @param {OpenAIClient} params.openai - The OpenAI client instance.
* @param {RunClient} params.client - The LibreChat client instance: either refers to `openai` or `streamRunManager`.
* @param {string} params.file_id - The ID of the file to retrieve.
* @param {string} [params.basename] - The basename of the file (if image); e.g., 'image.jpg'. `undefined` for `file_citation` annotations.
* @param {boolean} [params.unknownType] - Whether the file type is unknown.
* @returns {Promise<{file_id: string, filepath: string, source: string, bytes?: number, width?: number, height?: number} | null>}
* - Returns null if `file_id` is not defined; else, the file metadata if successfully retrieved and processed.
*/
async function retrieveAndProcessFile({
openai,
client,
file_id,
basename: _basename,
unknownType,
}) {
if (!file_id) {
return null;
}
let basename = _basename;
const processArgs = { openai, file_id, filename: basename, userId: client.req.user.id };
// If no basename provided, return only the file metadata
if (!basename) {
return await processOpenAIFile({ ...processArgs, saveFile: true });
}
const fileExt = path.extname(basename);
if (client.attachedFileIds?.has(file_id) || client.processedFileIds?.has(file_id)) {
return processOpenAIFile({ ...processArgs, updateUsage: true });
}
/**
* @returns {Promise<Buffer>} The file data buffer.
*/
const getDataBuffer = async () => {
const response = await openai.files.content(file_id);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
};
let dataBuffer;
if (unknownType || !fileExt || imageExtRegex.test(basename)) {
try {
dataBuffer = await getDataBuffer();
} catch (error) {
logger.error('Error downloading file from OpenAI:', error);
dataBuffer = null;
}
}
if (!dataBuffer) {
return await processOpenAIFile({ ...processArgs, saveFile: true });
}
// If the filetype is unknown, inspect the file
if (dataBuffer && (unknownType || !fileExt)) {
const detectedExt = await determineFileType(dataBuffer);
const isImageOutput = detectedExt && imageExtRegex.test('.' + detectedExt);
if (!isImageOutput) {
return await processOpenAIFile({ ...processArgs, saveFile: true });
}
return await processOpenAIImageOutput({
file_id,
req: client.req,
buffer: dataBuffer,
filename: basename,
fileExt: detectedExt,
});
} else if (dataBuffer && imageExtRegex.test(basename)) {
return await processOpenAIImageOutput({
file_id,
req: client.req,
buffer: dataBuffer,
filename: basename,
fileExt,
});
} else {
logger.debug(`[retrieveAndProcessFile] Non-image file type detected: ${basename}`);
return await processOpenAIFile({ ...processArgs, saveFile: true });
}
}
/**
* Converts a base64 string to a buffer.
* @param {string} base64String
* @returns {Buffer<ArrayBufferLike>}
*/
function base64ToBuffer(base64String) {
try {
const typeMatch = base64String.match(/^data:([A-Za-z-+/]+);base64,/);
const type = typeMatch ? typeMatch[1] : '';
const base64Data = base64String.replace(/^data:([A-Za-z-+/]+);base64,/, '');
if (!base64Data) {
throw new Error('Invalid base64 string');
}
return {
buffer: Buffer.from(base64Data, 'base64'),
type,
};
} catch (error) {
throw new Error(`Failed to convert base64 to buffer: ${error.message}`);
}
}
async function saveBase64Image(
url,
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
) {
const appConfig = req.config;
const effectiveResolution = resolution ?? appConfig.fileConfig?.imageGeneration ?? 'high';
const file_id = _file_id ?? v4();
let filename = `${file_id}-${_filename}`;
const { buffer: inputBuffer, type: declaredType } = base64ToBuffer(url);
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint);
/** Sharp re-encodes what it resizes, so the bytes being saved are not necessarily in the
* format the data URL declared — an SVG arrives here and is rasterized to PNG. The record has
* to describe the bytes, because `file.type` is handed to providers verbatim as `media_type`
* (Anthropic), `inlineData.mimeType` (Google), and the `data:` prefix (OpenAI). */
const type = image.type ?? declaredType;
if (!path.extname(_filename)) {
const extension = mime.getExtension(type);
if (extension) {
filename += `.${extension}`;
} else {
throw new Error(`Could not determine file extension from MIME type: ${type}`);
}
}
const source = getFileStrategy(appConfig, { isImage: true });
const { saveBuffer } = getStrategyFunctions(source);
const filepath = await saveBuffer({
userId: req.user.id,
fileName: filename,
buffer: image.buffer,
tenantId: req.user.tenantId,
});
const storageMetadata = getStorageMetadata({ filepath, source });
return await db.createFile(
{
type,
source,
context,
file_id,
filepath,
...storageMetadata,
filename,
user: req.user.id,
bytes: image.bytes,
width: image.width,
...(await getRetentionExpiry(req)),
height: image.height,
tenantId: req.user.tenantId,
},
true,
);
}
/**
* Filters a file based on its size and the endpoint origin.
*
* @param {Object} params - The parameters for the function.
* @param {ServerRequest} params.req - The request object from Express.
* @param {string} [params.req.endpoint]
* @param {string} [params.req.file_id]
* @param {number} [params.req.width]
* @param {number} [params.req.height]
* @param {number} [params.req.version]
* @param {boolean} [params.image] - Whether the file expected is an image.
* @param {boolean} [params.isAvatar] - Whether the file expected is a user or entity avatar.
* @returns {void}
*
* @throws {Error} If a file exception is caught (invalid file size or type, lack of metadata).
*/
function filterFile({ req, image, isAvatar }) {
const { file } = req;
const { endpoint, endpointType, file_id, width, height } = req.body;
if (!file_id && !isAvatar) {
throw new Error('No file_id provided');
}
if (file.size === 0) {
throw new Error('Empty file uploaded');
}
/* parse to validate api call, throws error on fail */
if (!isAvatar) {
isUUID.parse(file_id);
}
if (!endpoint && !isAvatar) {
throw new Error('No endpoint provided');
}
const appConfig = req.config;
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const endpointFileConfig = getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
});
const fileSizeLimit =
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
if (file.size > fileSizeLimit) {
throw new Error(
`File size limit of ${fileSizeLimit / megabyte} MB exceeded for ${
isAvatar ? 'avatar upload' : `${endpoint} endpoint`
}`,
);
}
const isSupportedMimeType = fileConfig.checkType(
file.mimetype,
endpointFileConfig.supportedMimeTypes,
);
if (!isSupportedMimeType) {
throw new Error('Unsupported file type');
}
if (!image || isAvatar === true) {
return;
}
if (!width) {
throw new Error('No width provided');
}
if (!height) {
throw new Error('No height provided');
}
}
module.exports = {
filterFile,
processFileURL,
saveBase64Image,
processImageFile,
uploadImageBuffer,
sweepExpiredFiles,
startExpiredFileSweep,
processFileUpload,
processDeleteRequest,
processAgentFileUpload,
retrieveAndProcessFile,
};