Skip to content

Commit 58de776

Browse files
authored
🗝️ refactor: Prefer the Recorded Storage Key for Every Download Stream (#15695)
S3 and CloudFront records have carried `storageKey` since #12987, yet six readers still handed `file.filepath` to `getDownloadStream` and re-derived the key by parsing a presigned or CDN URL, while four others had grown their own `storageKey || filepath` expression. One resolver now serves all ten: `resolveDownloadPath` returns the recorded key when present and the path otherwise, so records without a key (local, Firebase, Azure, code output) behave exactly as before, and the share route keeps its local-only query-string strip on top. `resolveStoredS3Key` reuses the same `StoredFileRef` type. Covered by unit cases for the resolver and an S3 case proving a record with a key streams correctly even when its stored URL no longer parses. Closes #15693
1 parent 45bb99a commit 58de776

15 files changed

Lines changed: 82 additions & 13 deletions

File tree

api/server/routes/__tests__/share.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const mockCreateShareContentPreflight = jest.fn((filters, options = {}) => {
7878
});
7979

8080
jest.mock('@librechat/api', () => ({
81+
resolveDownloadPath: (file) => file.storageKey || file.filepath,
8182
assertModelBoundContent: (...args) => mockAssertModelBoundContent(...args),
8283
assertSharedFileMetadataAllowed: (...args) => mockAssertSharedFileMetadataAllowed(...args),
8384
createShareContentPreflight: (...args) => mockCreateShareContentPreflight(...args),

api/server/routes/files/files.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
assertUploadContentAllowed,
1919
hasActiveFilePolicy,
2020
sanitizeFilename,
21+
resolveDownloadPath,
2122
} = require('@librechat/api');
2223
const {
2324
Time,
@@ -700,7 +701,7 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
700701
return res.status(501).send('Not Implemented');
701702
}
702703

703-
const fileStream = await getDownloadStream(req, file.storageKey || file.filepath);
704+
const fileStream = await getDownloadStream(req, resolveDownloadPath(file));
704705

705706
fileStream.on('error', (streamError) => {
706707
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);

api/server/routes/share.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424
createSharedLangfuseSessionResolver,
2525
recordShareLinkRejection,
2626
traceIdForMessage,
27+
resolveDownloadPath,
2728
} = require('@librechat/api');
2829
const {
2930
logger,
@@ -297,7 +298,7 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
297298

298299
// Strip any cache-busting query string (e.g. code-output images add `?v=...`) so
299300
// the local stream resolves the real filename, not a literal `*.png?v=...` path.
300-
const streamPath = (file.storageKey || file.filepath || '').split('?')[0];
301+
const streamPath = (resolveDownloadPath(file) || '').split('?')[0];
301302
const fileStream = await getDownloadStream(req, streamPath);
302303

303304
res.setHeader('X-Content-Type-Options', 'nosniff');

api/server/services/Files/Code/__tests__/process-traversal.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jest.mock('@librechat/api', () => {
2020
const http = require('http');
2121
const https = require('https');
2222
return {
23+
resolveDownloadPath: (file) => file.storageKey || file.filepath,
2324
logAxiosError: jest.fn(),
2425
getBasePath: jest.fn(() => ''),
2526
sanitizeArtifactPath: mockSanitizeArtifactPath,

api/server/services/Files/Code/process.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
CODE_OUTPUT_PREFLIGHT_MAX_COUNT,
3636
sortCodeFilesByDestinationPriority,
3737
normalizeArtifactDeliveryFailure,
38+
resolveDownloadPath,
3839
} = require('@librechat/api');
3940
const {
4041
Tools,
@@ -1430,7 +1431,7 @@ const primeFiles = async (options) => {
14301431
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
14311432
FileSources.execute_code,
14321433
);
1433-
const stream = await getDownloadStream(options.req, file.filepath);
1434+
const stream = await getDownloadStream(options.req, resolveDownloadPath(file));
14341435
/* Reupload preserves the resource identity from the existing
14351436
* ref so codeapi re-buckets under the same sessionKey shape
14361437
* (skill stays skill, user stays user). Without this, a

api/server/services/Files/Code/process.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jest.mock('@librechat/api', () => {
6666
const http = require('http');
6767
const https = require('https');
6868
return {
69+
resolveDownloadPath: (file) => file.storageKey || file.filepath,
6970
logAxiosError: jest.fn(),
7071
/* Behaviourally identical to the real predicate in
7172
* `packages/api/src/files/code/errors.ts`, which owns the contract and

packages/api/src/agents/handlers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import { buildSkillPrimeMessage, isSkillFilePath, SKILL_FILE_PREFIX } from './sk
108108
import { resolveCallerCapabilityProjectionSnapshot } from './callerCapabilities';
109109
import { createSkillContentDigest } from './compatibility';
110110
import { isMissingSandboxPathError } from '~/files/code';
111+
import { resolveDownloadPath } from '~/storage/path';
111112
import { parseFrontmatter } from '../skills/import';
112113
import { cleanCodeToolOutput } from './cleanup';
113114
import { primeSkillFiles } from './skillFiles';
@@ -3269,7 +3270,7 @@ async function loadSkillFileTextForAuthoring({
32693270
return { status: 'error', message: 'Download is not supported for this storage backend.' };
32703271
}
32713272

3272-
const stream = await strategy.getDownloadStream(req, file.filepath);
3273+
const stream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
32733274
const chunks: Uint8Array[] = [];
32743275
let streamedBytes = 0;
32753276
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
@@ -4673,7 +4674,7 @@ async function handleReadFileCall(
46734674
};
46744675
}
46754676

4676-
const stream = await strategy.getDownloadStream(req, file.filepath);
4677+
const stream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
46774678
const chunks: Uint8Array[] = [];
46784679
// Use the larger binary limit as streaming cap; cheaper type-specific
46794680
// checks happen after binary detection on the assembled buffer.

packages/api/src/agents/skillFiles.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getCodeExecutionRouteKey, type CodeExecutionContext } from './execution
2525
import { assertSkillFileContentAllowed } from '~/skills/protection';
2626
import { createSkillContentDigest } from './compatibility';
2727
import { extractInvokedSkillsFromPayload } from './run';
28+
import { resolveDownloadPath } from '~/storage/path';
2829
import { SKILL_FILE_PREFIX } from './skills';
2930

3031
const MAX_INSPECTABLE_SKILL_FILE_BYTES = 10 * 1024 * 1024;
@@ -197,7 +198,7 @@ async function collectSkillUploadFiles(
197198
);
198199
return null;
199200
}
200-
const stream = await strategy.getDownloadStream(req, file.filepath);
201+
const stream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
201202
return { stream, filename: `${SKILL_FILE_PREFIX}${skill.name}/${file.relativePath}` };
202203
}),
203204
);
@@ -402,7 +403,7 @@ async function executePrimeSkillFiles(
402403
logger.warn('[primeSkillFiles] No download stream for stored skill file');
403404
continue;
404405
}
405-
const sourceStream = await strategy.getDownloadStream(req, file.filepath);
406+
const sourceStream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
406407
const buffer = await bufferSkillFileStream(sourceStream);
407408
if (buffer == null) {
408409
throwIfStoredSkillFileMustBeInspectable(req);

packages/api/src/files/encode/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Providers } from '@librechat/agents';
33
import { FileSources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider';
44
import type { IMongoFile } from '@librechat/data-schemas';
55
import type { ServerRequest, StrategyFunctions, ProcessedFile } from '~/types';
6+
import { resolveDownloadPath } from '~/storage/path';
67

78
/**
89
* Extracts the configured file size limit for a specific provider from fileConfig
@@ -55,7 +56,7 @@ export async function getFileStream(
5556
}
5657

5758
const { getDownloadStream } = encodingMethods[source];
58-
const stream = await getDownloadStream(req, file.filepath);
59+
const stream = await getDownloadStream(req, resolveDownloadPath(file));
5960
let buffer: Buffer | null = await getStream.buffer(stream);
6061
const content = buffer.toString('base64');
6162
buffer = null;

packages/api/src/skills/handlers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type { ServerRequest, StrategyFunctions } from '~/types';
3636
import { extractSkillContent, inspectContentWithTraversal } from '~/protection';
3737
import { contentFilterBlockResponse } from '~/middleware/contentFilter';
3838
import { getDeploymentSkillIds } from './deployment';
39+
import { resolveDownloadPath } from '~/storage/path';
3940
import { resolveSkillFilePathParam } from './path';
4041
import { parseSkillMarkdown } from './parse';
4142
import { isBinaryBuffer } from './binary';
@@ -702,7 +703,7 @@ export function createSkillsHandlers(deps: SkillsHandlersDeps): {
702703
'Content-Disposition',
703704
`${isImageMime ? 'inline' : 'attachment'}; filename="${safeName}"`,
704705
);
705-
const stream = await strategy.getDownloadStream(req, file.storageKey || file.filepath);
706+
const stream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
706707
stream.on('error', (err: Error) => {
707708
logger.error('[downloadFile] Stream error:', err);
708709
if (!res.headersSent) {
@@ -736,7 +737,7 @@ export function createSkillsHandlers(deps: SkillsHandlersDeps): {
736737
// destroy (binary) or continue reading (text) in the same iteration.
737738
// N.B. breaking out of `for await...of` destroys the stream via
738739
// iterator.return(), so we must NOT use break + a second loop.
739-
const stream = await strategy.getDownloadStream(req, file.storageKey || file.filepath);
740+
const stream = await strategy.getDownloadStream(req, resolveDownloadPath(file));
740741
const chunks: Buffer[] = [];
741742
let totalBytes = 0;
742743
let binaryChecked = false;

0 commit comments

Comments
 (0)