Skip to content

Commit be54587

Browse files
lia-by-librechat[bot]cursoragentZsanz3Lia
authored
🪒 fix: Strip Cache-Bust Suffixes From Every Local File Read (#15991)
* 🧼 fix: Strip Cache-Bust Query Before Local Vision Encode Reused code-interpreter images persist filepath with a ?v= suffix. prepareImagesLocal now strips it before disk encode, matching crud/share. Co-authored-by: Zsanz3 <Zsanz3@users.noreply.github.com> * 🧪 test: Isolate prepareImagesLocal query-strip coverage Mock sharp and resize so the local vision-encode test does not load the rest of the file-strategy graph. Co-authored-by: Zsanz3 <Zsanz3@users.noreply.github.com> * 🪒 fix: Strip Cache-Bust Suffixes From Every Local File Read Reused code-interpreter outputs persist a `?v=<timestamp>` suffix on the file document's filepath, and local storage resolves that field into a filesystem path. prepareImagesLocal already handled it after the cherry-picked commits; getLocalFileStream did not, so every download-stream consumer (the download route, provisioning, agent and skill file reads) still hit ENOENT for a regenerated image. Move the strip into one documented helper and call it from all three local read paths. * 🧱 refactor: Move Cache-Bust Stripping Into the Storage Package `/api` holds wiring, not behavior, so `stripCacheBust` moves out of a new CJS helper and into `packages/api/src/storage/path.ts`, beside `resolveDownloadPath` — the function that already decides what a stored filepath means to a download. Its doc comment now states why the two differ: a remote strategy's query string can carry a presigned signature, so only the local paths may strip. The local storage modules keep just the call into it. Semantics are covered by the storage package's own test; the `/api` specs pin the wiring. Addresses the Codex P1 finding on 5f8dbf3. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Zsanz3 <Zsanz3@users.noreply.github.com> Co-authored-by: Lia <lia@librechat.ai>
1 parent 8fa56b0 commit be54587

6 files changed

Lines changed: 200 additions & 6 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Wiring coverage: `stripCacheBust`'s own behavior is covered in
3+
* `packages/api/src/storage/__tests__/path.test.ts`. These tests assert that the local read paths
4+
* hand their raw filepath to it, so a reused code output resolves to the file that exists on disk.
5+
*/
6+
jest.mock('@librechat/api', () => ({
7+
deleteRagFile: jest.fn(),
8+
stripCacheBust: jest.fn((filepath) => filepath.split('?')[0]),
9+
}));
10+
jest.mock('@librechat/data-schemas', () => ({
11+
logger: { warn: jest.fn(), error: jest.fn() },
12+
}));
13+
14+
const mockTmpBase = require('fs').mkdtempSync(
15+
require('path').join(require('os').tmpdir(), 'local-cache-bust-'),
16+
);
17+
18+
jest.mock('~/config/paths', () => {
19+
const path = require('path');
20+
return {
21+
publicPath: path.join(mockTmpBase, 'public'),
22+
uploads: path.join(mockTmpBase, 'uploads'),
23+
imageOutput: path.join(mockTmpBase, 'public', 'images'),
24+
};
25+
});
26+
27+
const fs = require('fs');
28+
const path = require('path');
29+
const { stripCacheBust } = require('@librechat/api');
30+
const { getLocalFileStream } = require('../crud');
31+
32+
const imageOutput = path.join(mockTmpBase, 'public', 'images');
33+
const uploads = path.join(mockTmpBase, 'uploads');
34+
35+
const makeReq = () => ({
36+
user: { id: 'user-1' },
37+
config: { paths: { publicPath: path.join(mockTmpBase, 'public'), uploads, imageOutput } },
38+
});
39+
40+
const readStream = (stream) =>
41+
new Promise((resolve, reject) => {
42+
const chunks = [];
43+
stream.on('data', (chunk) => chunks.push(chunk));
44+
stream.on('error', reject);
45+
stream.on('end', () => resolve(Buffer.concat(chunks).toString()));
46+
});
47+
48+
describe('getLocalFileStream cache-busted filepaths', () => {
49+
beforeAll(() => {
50+
fs.mkdirSync(path.join(imageOutput, 'user-1'), { recursive: true });
51+
fs.mkdirSync(path.join(uploads, 'user-1'), { recursive: true });
52+
fs.writeFileSync(path.join(imageOutput, 'user-1', 'chart.png'), 'image-bytes');
53+
fs.writeFileSync(path.join(uploads, 'user-1', 'doc.pdf'), 'upload-bytes');
54+
});
55+
56+
afterAll(() => {
57+
fs.rmSync(mockTmpBase, { recursive: true, force: true });
58+
});
59+
60+
it('streams a reused code-output image whose filepath carries `?v=`', async () => {
61+
const requested = '/images/user-1/chart.png?v=1789460622697';
62+
63+
const stream = await getLocalFileStream(makeReq(), requested);
64+
65+
await expect(readStream(stream)).resolves.toBe('image-bytes');
66+
expect(stripCacheBust).toHaveBeenCalledWith(requested);
67+
});
68+
69+
it('streams an upload whose filepath carries a query string', async () => {
70+
const requested = '/uploads/user-1/doc.pdf?manual=true';
71+
72+
const stream = await getLocalFileStream(makeReq(), requested);
73+
74+
await expect(readStream(stream)).resolves.toBe('upload-bytes');
75+
expect(stripCacheBust).toHaveBeenCalledWith(requested);
76+
});
77+
78+
it('still streams a filepath without a query string', async () => {
79+
const stream = await getLocalFileStream(makeReq(), '/images/user-1/chart.png');
80+
81+
await expect(readStream(stream)).resolves.toBe('image-bytes');
82+
});
83+
84+
it('still rejects traversal hidden behind a query string', async () => {
85+
await expect(getLocalFileStream(makeReq(), '/images/../../../etc/passwd?v=1')).rejects.toThrow(
86+
'Invalid file path',
87+
);
88+
});
89+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
jest.mock('sharp', () => ({}));
2+
jest.mock('@librechat/api', () => ({
3+
stripCacheBust: jest.fn((filepath) => filepath.split('?')[0]),
4+
}));
5+
jest.mock('../../images/resize', () => ({ resizeImageBuffer: jest.fn() }));
6+
jest.mock('~/models', () => ({
7+
updateUser: jest.fn(),
8+
updateFile: jest.fn(async (doc) => doc),
9+
}));
10+
11+
const fs = require('fs');
12+
const os = require('os');
13+
const path = require('path');
14+
const { updateFile } = require('~/models');
15+
const { prepareImagesLocal } = require('../images');
16+
17+
describe('prepareImagesLocal', () => {
18+
let tmpDir;
19+
let publicPath;
20+
let imageOutput;
21+
22+
beforeEach(() => {
23+
jest.clearAllMocks();
24+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepare-images-local-'));
25+
publicPath = path.join(tmpDir, 'public');
26+
imageOutput = path.join(tmpDir, 'images');
27+
fs.mkdirSync(path.join(publicPath, 'images', 'user-1'), { recursive: true });
28+
});
29+
30+
afterEach(() => {
31+
fs.rmSync(tmpDir, { recursive: true, force: true });
32+
});
33+
34+
const makeReq = () => ({
35+
user: { id: 'user-1' },
36+
config: { paths: { publicPath, imageOutput } },
37+
});
38+
39+
it('strips a cache-busting query string before encoding from disk', async () => {
40+
const relativePath = '/images/user-1/chart.png';
41+
fs.writeFileSync(path.join(publicPath, relativePath), Buffer.from('fake-png-bytes'));
42+
43+
const [updated, encoded] = await prepareImagesLocal(makeReq(), {
44+
file_id: 'file-1',
45+
filepath: `${relativePath}?v=1789460622697`,
46+
});
47+
48+
expect(updateFile).toHaveBeenCalledWith({ file_id: 'file-1' });
49+
expect(updated).toEqual({ file_id: 'file-1' });
50+
expect(encoded).toBe(Buffer.from('fake-png-bytes').toString('base64'));
51+
});
52+
53+
it('encodes a filepath without a query string', async () => {
54+
const relativePath = '/images/user-1/chart.png';
55+
fs.writeFileSync(path.join(publicPath, relativePath), Buffer.from('plain-png-bytes'));
56+
57+
const [, encoded] = await prepareImagesLocal(makeReq(), {
58+
file_id: 'file-2',
59+
filepath: relativePath,
60+
});
61+
62+
expect(encoded).toBe(Buffer.from('plain-png-bytes').toString('base64'));
63+
});
64+
});

api/server/services/Files/Local/crud.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const path = require('path');
33
const axios = require('axios');
44
const {
55
deleteRagFile,
6+
stripCacheBust,
67
assertRemoteFileURL,
78
getRemoteFileFetchMaxBytes,
89
getRemoteFileFetchTimeoutMs,
@@ -231,8 +232,8 @@ const deleteLocalFile = async (req, file) => {
231232
const appConfig = req.config;
232233
const { publicPath, uploads } = appConfig.paths;
233234

234-
/** Filepath stripped of query parameters (e.g., ?manual=true) */
235-
const cleanFilepath = file.filepath.split('?')[0];
235+
/** Filepath stripped of query parameters (e.g., ?manual=true, ?v=<timestamp>) */
236+
const cleanFilepath = stripCacheBust(file.filepath);
236237

237238
await deleteRagFile({ userId: req.user.id, file });
238239

@@ -321,12 +322,14 @@ async function uploadLocalFile({ req, file, file_id }) {
321322
* Retrieves a readable stream for a file from local storage.
322323
*
323324
* @param {ServerRequest} req - The request object from Express
324-
* @param {string} filepath - The filepath.
325+
* @param {string} requestedFilepath - The filepath, which may carry a cache-busting query string.
325326
* @returns {ReadableStream} A readable stream of the file.
326327
*/
327-
async function getLocalFileStream(req, filepath) {
328+
async function getLocalFileStream(req, requestedFilepath) {
328329
try {
329330
const appConfig = req.config;
331+
/** Reused code outputs persist a `?v=<timestamp>` suffix that no file on disk carries */
332+
const filepath = stripCacheBust(requestedFilepath);
330333
if (filepath.includes('/uploads/')) {
331334
const basePath = filepath.split('/uploads/')[1];
332335

api/server/services/Files/Local/images.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const fs = require('fs');
22
const path = require('path');
33
const sharp = require('sharp');
4+
const { stripCacheBust } = require('@librechat/api');
45
const { resizeImageBuffer } = require('../images/resize');
56
const { updateUser, updateFile } = require('~/models');
67

@@ -97,7 +98,7 @@ async function prepareImagesLocal(req, file) {
9798
if (!fs.existsSync(userPath)) {
9899
fs.mkdirSync(userPath, { recursive: true });
99100
}
100-
const filepath = path.join(publicPath, file.filepath);
101+
const filepath = path.join(publicPath, stripCacheBust(file.filepath));
101102

102103
const promises = [];
103104
promises.push(updateFile({ file_id: file.file_id }));

packages/api/src/storage/__tests__/path.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resolveDownloadPath } from '../path';
1+
import { resolveDownloadPath, stripCacheBust } from '../path';
22

33
describe('resolveDownloadPath', () => {
44
it('prefers the recorded object key over the stored URL', () => {
@@ -28,3 +28,21 @@ describe('resolveDownloadPath', () => {
2828
expect(resolveDownloadPath({ filepath: url })).toBe(url);
2929
});
3030
});
31+
32+
describe('stripCacheBust', () => {
33+
it('removes the cache-buster a regenerated code output persists', () => {
34+
expect(stripCacheBust('/images/u1/chart.png?v=1789460622697')).toBe('/images/u1/chart.png');
35+
});
36+
37+
it('removes everything from the first question mark onward', () => {
38+
expect(stripCacheBust('/uploads/u1/doc.pdf?manual=true&v=2')).toBe('/uploads/u1/doc.pdf');
39+
});
40+
41+
it('leaves a path without a query string untouched', () => {
42+
expect(stripCacheBust('/images/u1/chart.png')).toBe('/images/u1/chart.png');
43+
});
44+
45+
it('keeps a name whose sanitized form cannot contain a question mark', () => {
46+
expect(stripCacheBust('/uploads/u1/what_is_this_.pdf')).toBe('/uploads/u1/what_is_this_.pdf');
47+
});
48+
});

packages/api/src/storage/path.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,22 @@ export type StoredFileRef = Pick<TFile, 'filepath'> & { storageKey?: string | nu
1212
export function resolveDownloadPath(file: StoredFileRef): string {
1313
return file.storageKey || file.filepath;
1414
}
15+
16+
/**
17+
* Strips the query string from a stored `filepath` before it is resolved on disk.
18+
*
19+
* A regenerated code-interpreter output persists a cache-busting `?v=<timestamp>` suffix on its
20+
* file document's `filepath` (`processCodeOutput`), and shared-link cache validators fold that
21+
* field in deliberately (`buildShareFileEtag`), so the suffix cannot be dropped at write time.
22+
* Local storage is the only strategy that turns `filepath` into a filesystem path, so it is the
23+
* only one that has to remove the suffix before reading or deleting. A remote strategy receives a
24+
* URL whose query string can carry a presigned signature, which is why `resolveDownloadPath`
25+
* hands its result over untouched.
26+
*
27+
* Unambiguous for local paths because `sanitizeFilename` replaces `?` with `_` in every stored
28+
* name, so no file on disk carries one.
29+
*/
30+
export function stripCacheBust(filepath: string): string {
31+
const queryIndex = filepath.indexOf('?');
32+
return queryIndex === -1 ? filepath : filepath.slice(0, queryIndex);
33+
}

0 commit comments

Comments
 (0)