Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3fb0a91
📄 feat: Add Local AnyDoc and pdf-inspector Document Extraction
berry-13 Sep 17, 2026
db22d88
fix: settle the dev rebase on one delivery marker and one admission rule
berry-13 Sep 17, 2026
cfd6c1e
style: wrap the type import prettier requires
berry-13 Sep 17, 2026
66615be
fix: keep one parser admission controller and refuse an unroutable ty…
berry-13 Sep 17, 2026
0d8cebe
fix: send the document's own name to the services that store it
berry-13 Sep 17, 2026
2f73193
fix: admit legacy ocr uploads through the parser gate
berry-13 Oct 2, 2026
c428d79
fix: mark the whole-PDF pdfjs fallback as possibly incomplete
berry-13 Oct 2, 2026
a4604a8
fix: offer file search for parsed prose documents in the upload chooser
berry-13 Oct 2, 2026
cc9a510
style: apply dev's class order to the extracted text views
berry-13 Oct 2, 2026
dbaf057
fix: let the first configured parse set the parser admission bounds
berry-13 Oct 2, 2026
58dda95
docs: replace dashes in the parser comments with plain punctuation
berry-13 Oct 2, 2026
e5a0cd3
test: attach document scenarios through the composer palette
berry-13 Oct 2, 2026
7772244
fix: wait for the parser child's IPC channel before treating its exit…
berry-13 Oct 3, 2026
43cc052
fix: apply parser bounds and the partial-text policy to upload fallba…
berry-13 Oct 3, 2026
a117f97
fix: stream named OpenAI uploads from disk instead of buffering them
berry-13 Oct 3, 2026
da29041
fix: bind share snapshot backfills to the snapshot the viewer read
berry-13 Oct 3, 2026
7a9b343
fix: give the upload fallback parse the request's cancellation and re…
berry-13 Oct 3, 2026
932d17c
fix: compare the read publication revision in share snapshot backfills
berry-13 Oct 3, 2026
e55c133
refactor: build named provider uploads in packages/api
berry-13 Oct 3, 2026
677a566
fix: offer file search only for types the endpoint allowlist accepts
berry-13 Oct 4, 2026
374d0ce
test: upload two same-name files through request-owned staging
berry-13 Oct 4, 2026
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
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"@azure/identity": "^4.13.1",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.30.0",
"@firecrawl/anydoc": "0.1.3",
"@firecrawl/pdf-inspector": "1.12.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "5.1.6",
"@librechat/agents": "^4.0.1",
Expand Down
20 changes: 20 additions & 0 deletions api/server/routes/__tests__/share.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,26 @@ describe('share-scoped file routes', () => {
expect(getFiles).toHaveBeenCalledWith({ file_id: 'file-1' }, null, {});
});

it('serves a parsed snapshot as text on both the preview and the file route', async () => {
getSharedLinkFile.mockResolvedValue({
file: { file_id: 'file-1', source: 'text', filename: 'report.docx' },
hasSnapshots: true,
});
getFiles.mockResolvedValue([{ status: 'ready', text: '# Parsed report', textFormat: 'text' }]);

const app = buildApp();
const preview = await request(app).get('/api/share/share-123/files/file-1/preview');
expect(preview.status).toBe(200);
expect(preview.body.text).toBe('# Parsed report');

const binary = await request(app).get('/api/share/share-123/files/file-1');
expect(binary.status).toBe(200);
expect(binary.headers['content-type']).toContain('text/plain');
expect(binary.text).toBe('# Parsed report');
/** No durable object exists for a parsed record, so no storage strategy is consulted. */
expect(mockGetStrategyFunctions).not.toHaveBeenCalled();
});

it('404s for a file not in the snapshot without rebuilding it', async () => {
getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: true });

Expand Down
31 changes: 31 additions & 0 deletions api/server/routes/files/files.agents.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,37 @@ describe('File Routes - Agent Files Endpoint', () => {
errorLogSpy.mockRestore();
});

it('returns a stable 413 for an oversized document parser input', async () => {
processAgentFileUpload.mockRejectedValueOnce(
Object.assign(new Error('PRIVATE-UPLOAD.docx exceeds the 15MB parser limit'), {
code: 'PARSER_INPUT_LIMIT',
userErrorStatusCode: 413,
}),
);
const errorLogSpy = jest.spyOn(logger, 'error').mockImplementation(() => {});
const testApp = createAppWithUser(otherUserId, SystemRoles.USER, {
filters: {
files: {
pii: {
fields: ['name'],
},
},
},
});

const response = await request(testApp).post('/files').send({
endpoint: 'agents',
file_id: uuidv4(),
});

expect(response.status).toBe(413);
expect(response.body).toEqual({
message: 'Document exceeds the supported file size limit',
});
expect(JSON.stringify(response.body)).not.toContain('PRIVATE-UPLOAD');
errorLogSpy.mockRestore();
});

it('preserves legacy upload error details when file protection is inactive', async () => {
const legacyMessage = 'Invalid file format: .legacy';
processAgentFileUpload.mockRejectedValueOnce(
Expand Down
14 changes: 13 additions & 1 deletion api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,19 @@ router.get('/agent/:agent_id', async (req, res) => {
router.get('/config', async (req, res) => {
try {
const appConfig = req.config;
res.status(200).json(appConfig.fileConfig);
res.status(200).json({
...appConfig.fileConfig,
ocr: {
...appConfig.fileConfig?.ocr,
enabled: appConfig.ocr != null,
},
/* Whether a RAG text service exists to route documents to. The client mirrors the
* upload path's routing, and that decision reads the environment, not the config. */
text: {
...appConfig.fileConfig?.text,
enabled: !!process.env.RAG_API_URL,
Comment thread
berry-13 marked this conversation as resolved.
},
});
} catch (error) {
logger.error('[/files] Error getting fileConfig', error);
res.status(400).json({ message: 'Error in request', error: error.message });
Expand Down
52 changes: 52 additions & 0 deletions api/server/routes/files/files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,58 @@ describe('File Routes - Delete with Agent Access', () => {
});
});

/**
* The client mirrors the upload path's routing to decide what it may offer, and both
* of these read the environment rather than the YAML, so the config endpoint is where
* that knowledge crosses over.
*/
describe('GET /files/config', () => {
const originalRagUrl = process.env.RAG_API_URL;

afterEach(() => {
requestConfig = {};
if (originalRagUrl === undefined) {
delete process.env.RAG_API_URL;
} else {
process.env.RAG_API_URL = originalRagUrl;
}
});

it('reports whether an OCR strategy and a RAG text service are configured', async () => {
process.env.RAG_API_URL = 'http://rag-api.test';
requestConfig = { ocr: { strategy: 'mistral_ocr' }, fileConfig: {} };

const response = await request(app).get('/files/config');

expect(response.status).toBe(200);
expect(response.body.ocr.enabled).toBe(true);
expect(response.body.text.enabled).toBe(true);
});

it('reports both as absent when neither is configured', async () => {
delete process.env.RAG_API_URL;
requestConfig = { fileConfig: {} };

const response = await request(app).get('/files/config');

expect(response.status).toBe(200);
expect(response.body.ocr.enabled).toBe(false);
expect(response.body.text.enabled).toBe(false);
});

it('keeps the configured text allowlist alongside the derived flag', async () => {
process.env.RAG_API_URL = 'http://rag-api.test';
requestConfig = { fileConfig: { text: { supportedMimeTypes: ['^text/plain$'] } } };

const response = await request(app).get('/files/config');

expect(response.body.text).toEqual({
supportedMimeTypes: ['^text/plain$'],
enabled: true,
});
});
});

describe('POST /files/usage', () => {
const createQueuedFile = async (expiresAt) => {
const ownFileId = uuidv4();
Expand Down
6 changes: 5 additions & 1 deletion api/server/routes/files/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ const initialize = async () => {
router.use(checkBan);
router.use(uaParser);

const upload = await createMulterInstance();
/* Per-request staging: two uploads of the same filename by the same user otherwise
* share one temporary path, and every stage that reopens it (the archive guard, the
* parser child, storage, the cleanup that deletes it) can read or remove bytes that
* belong to the other request. */
const upload = await createMulterInstance({ uniqueTempPath: true });
router.post('/speech/stt', upload.single('audio'), restoreTenantContextFromReq);

/* Important: speech route must be added before the upload limiters */
Expand Down
12 changes: 12 additions & 0 deletions api/server/routes/files/index.limiters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,17 @@ jest.mock('~/server/routes/assistants/v1', () => ({

describe('file route limiter wiring', () => {
let app;
/* Captured during initialization: the suite clears mocks before each test, so the
* call that built the upload middleware is gone by the time one runs. */
let multerOptions;

beforeAll(async () => {
const { initialize } = require('./index');
const { createMulterInstance } = require('./multer');
app = express();
app.use(express.json());
app.use('/api/files', await initialize());
multerOptions = createMulterInstance.mock.calls.at(-1)?.[0];
});

beforeEach(() => {
Expand All @@ -79,6 +84,13 @@ describe('file route limiter wiring', () => {
hits.usage = 0;
});

/* Shared staging is a correctness problem, not a tuning choice: two uploads of the
* same filename by one user would otherwise read and delete each other's bytes
* through every stage that reopens the temporary path. */
it('stages uploads at a request-owned temporary path', () => {
expect(multerOptions).toEqual(expect.objectContaining({ uniqueTempPath: true }));
});

it('meters POST /usage with the usage limiter, never leaving it unlimited', async () => {
const response = await request(app)
.post('/api/files/usage')
Expand Down
39 changes: 32 additions & 7 deletions api/server/routes/files/multer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sanitizeFilename, createCustomError } = require('@librechat/api');
const { sanitizeFilename, createCustomError, isAdmissibleUploadType } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
mergeFileConfig,
inferMimeType,
isAgentsEndpoint,
resolveEffectiveMimeType,
getEndpointFileConfig,
fileConfig: defaultFileConfig,
} = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');

Expand Down Expand Up @@ -71,8 +70,13 @@ const collectSupportedMimeTypes = (customFileConfig, endpointFileConfig) => {
return merged;
};

/**
* Admission reads the upload exactly as routing does. A client that types by magic bytes
* calls a `.docx` an archive, and resolving that here keeps a narrowed endpoint allowlist
* from refusing a document the parser downstream would have accepted.
*/
const normalizeUploadMimeType = (file) => {
const mimeType = inferMimeType(file.originalname || '', file.mimetype || '');
const mimeType = resolveEffectiveMimeType(file.originalname || '', file.mimetype || '');
if (mimeType && file.mimetype !== mimeType) {
file.mimetype = mimeType;
}
Expand Down Expand Up @@ -103,8 +107,15 @@ const createFileFilter = (customFileConfig, resolveEndpoint) => {
const resolved = resolveEndpoint?.(req);
const endpoint = resolved?.endpoint ?? req.body.endpoint;
const endpointType = resolved?.endpointType ?? req.body.endpointType;
/* The principal-merged config, which `configMiddleware` puts on the request before
* this route runs. The instance-level one was resolved once at startup, so a tenant,
* role or user override would be invisible here and the upload refused before the
* post-upload gate could apply the configuration that actually governs it. */
const effectiveFileConfig = req.config?.fileConfig
? mergeFileConfig(req.config.fileConfig)
: customFileConfig;
const endpointFileConfig = getEndpointFileConfig({
fileConfig: customFileConfig,
fileConfig: effectiveFileConfig,
endpoint,
endpointType,
});
Expand All @@ -115,10 +126,24 @@ const createFileFilter = (customFileConfig, resolveEndpoint) => {
* question is only whether any configured endpoint accepts the type. Narrowing to
* `agents` would make the later provider check able to reject but never to permit. */
const supportedMimeTypes = isAgentsEndpoint(endpoint)
? collectSupportedMimeTypes(customFileConfig, endpointFileConfig)
? collectSupportedMimeTypes(effectiveFileConfig, endpointFileConfig)
: endpointFileConfig.supportedMimeTypes;

if (!defaultFileConfig.checkType(mimeType, supportedMimeTypes)) {
/* An admin who names a type in `documentParser.supportedMimeTypes` has said the
* server parses it, so this filter admits those too. Deliberately not scoped to
* context uploads here: this runs while the file part is still streaming, before the
* fields that follow it have been parsed, and a multipart client may legally send
* `tool_resource` after the file. `filterFile` applies that scope on a complete body
* a moment later, before any provider is handed the upload, so the only thing this
* admits is a temporary file that the next gate deletes. */
const admitted = isAdmissibleUploadType({
mimeType,
fileConfig: effectiveFileConfig,
endpointMimeTypes: supportedMimeTypes,
admitParserTypes: true,
});

if (!admitted) {
return cb(
createCustomError(415, 'Unsupported file type: ' + (file.mimetype || mimeType)),
false,
Expand Down
Loading
Loading