mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🌩️ feat: CloudFront CDN File Strategy (#12193)
* 🌩️ feat: CloudFront CDN File Strategy + signed cookies Squashed from PR #12193: - feat(storage): add CloudFront CDN file strategy - feat(auth): add CloudFront signed cookie support Note: package.json/package-lock.json dependency additions are intentionally omitted from this commit and will be re-added via `npm install` after rebase to avoid lock-file merge conflicts. The two new peer deps that need to be re-installed are: - @aws-sdk/client-cloudfront@^3.1032.0 - @aws-sdk/cloudfront-signer@^3.1012.0 Also fixes 4 missing destructured names in AuthService.spec.js (getUserById, generateToken, generateRefreshToken, createSession) that were referenced in tests but not imported from the mocked '~/models'. * 📦 chore: install CloudFront SDK deps for PR #12193 Adds the two AWS CloudFront packages required by the rebased CloudFront CDN strategy: - @aws-sdk/client-cloudfront - @aws-sdk/cloudfront-signer Following the @aws-sdk/client-s3 pattern: - api/package.json: regular dependency (runtime resolution) - packages/api/package.json: peerDependency Generated by `npm install` against the freshly rebased lock file to avoid the merge conflicts that came from the original PR's lock-file edits being made against an older base of dev. * 🐛 fix: CI failures + review findings on CloudFront PR #12193 CI fixes - Rename packages/data-provider/src/__tests__/cloudfront-config.test.ts → src/cloudfront-config.spec.ts. Jest's default testMatch picks up __tests__/ directories even inside dist/, so the compiled .d.ts shell was being executed as an empty test suite. Moving to .spec.ts (matching the rest of the package) avoids the dist/ pickup. - Add cookieExpiry: 1800 to CloudFront crud.test makeConfig: the schema applies a default so CloudFrontFullConfig requires it. Review findings addressed - #1 (Codex + comprehensive): Normalize CloudFront domain with /\/+$/ regex (and key with /^\/+/ regex) in buildCloudFrontUrl, matching the cookie code so resource policy and file URLs stay aligned even when the configured domain has multiple trailing slashes. Added tests. - #2: Move DEFAULT_BASE_PATH out of s3Config into shared packages/api/src/storage/constants.ts. ImageService no longer imports S3-specific config. - #3: getCloudFrontConfig() returns Readonly<CloudFrontFullConfig> | null to discourage mutation of the cached signing config. - #4: Add cross-field refinement tests for cloudfrontConfigSchema (invalidateOnDelete-without-distributionId, imageSigning="cookies"-without-cookieDomain). - #6: Revert unrelated MCP comment re-indentation in librechat.example.yaml. - #7: Add azure_blob to the strategy list comment. Skipped - #5 (extractKeyFromS3Url with CloudFront URLs): existing deleteFileFromCloudFront tests already cover the path-equivalence assumption; renaming the helper is real refactor work beyond this PR's scope. - #8, #9 (NIT, low confidence): leaving for author judgement. * 🧹 chore: drop dead DEFAULT_BASE_PATH from s3Config test mock After moving DEFAULT_BASE_PATH to ~/storage/constants, crud.ts no longer reads it from s3Config — so the entry in the s3Config jest mock was misleading dead config. The tests still pass because the unmocked real constants module provides the value. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
4583d5a926
commit
187ab787da
35 changed files with 2364 additions and 294 deletions
|
|
@ -11,6 +11,7 @@ const {
|
|||
math,
|
||||
isEnabled,
|
||||
checkEmailConfig,
|
||||
setCloudFrontCookies,
|
||||
isEmailDomainAllowed,
|
||||
shouldUseSecureCookie,
|
||||
resolveAppConfigForUser,
|
||||
|
|
@ -440,6 +441,9 @@ const setAuthTokens = async (userId, res, _session = null) => {
|
|||
secure: shouldUseSecureCookie(),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
setCloudFrontCookies(res);
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
|
||||
|
|
@ -557,6 +561,9 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
|
|||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
|
||||
setCloudFrontCookies(res);
|
||||
|
||||
return appAuthToken;
|
||||
} catch (error) {
|
||||
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ jest.mock('@librechat/api', () => ({
|
|||
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
|
||||
shouldUseSecureCookie: jest.fn(() => false),
|
||||
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
||||
setCloudFrontCookies: jest.fn(() => true),
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
findUser: jest.fn(),
|
||||
|
|
@ -40,10 +41,17 @@ const {
|
|||
shouldUseSecureCookie,
|
||||
isEmailDomainAllowed,
|
||||
resolveAppConfigForUser,
|
||||
setCloudFrontCookies,
|
||||
} = require('@librechat/api');
|
||||
const { findUser } = require('~/models');
|
||||
const {
|
||||
findUser,
|
||||
getUserById,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
createSession,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { setOpenIDAuthTokens, requestPasswordReset } = require('./AuthService');
|
||||
const { setOpenIDAuthTokens, requestPasswordReset, setAuthTokens } = require('./AuthService');
|
||||
|
||||
/** Helper to build a mock Express response */
|
||||
function mockResponse() {
|
||||
|
|
@ -339,3 +347,67 @@ describe('requestPasswordReset', () => {
|
|||
expect(result.message).toContain('If an account with that email exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudFront cookie integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('setOpenIDAuthTokens', () => {
|
||||
const validTokenset = {
|
||||
id_token: 'the-id-token',
|
||||
access_token: 'the-access-token',
|
||||
refresh_token: 'the-refresh-token',
|
||||
};
|
||||
|
||||
it('calls setCloudFrontCookies with response object', () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
setOpenIDAuthTokens(validTokenset, req, res, 'user-123');
|
||||
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it('succeeds even when setCloudFrontCookies returns false', () => {
|
||||
setCloudFrontCookies.mockReturnValue(false);
|
||||
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
const result = setOpenIDAuthTokens(validTokenset, req, res, 'user-123');
|
||||
|
||||
expect(result).toBe('the-id-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAuthTokens', () => {
|
||||
beforeEach(() => {
|
||||
getUserById.mockResolvedValue({ _id: 'user-123' });
|
||||
generateToken.mockResolvedValue('mock-access-token');
|
||||
generateRefreshToken.mockReturnValue('mock-refresh-token');
|
||||
createSession.mockResolvedValue({
|
||||
session: { expiration: new Date(Date.now() + 604800000) },
|
||||
refreshToken: 'mock-refresh-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setCloudFrontCookies with response object', async () => {
|
||||
const res = mockResponse();
|
||||
|
||||
await setAuthTokens('user-123', res);
|
||||
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it('succeeds even when setCloudFrontCookies returns false', async () => {
|
||||
setCloudFrontCookies.mockReturnValue(false);
|
||||
|
||||
const res = mockResponse();
|
||||
|
||||
const result = await setAuthTokens('user-123', res);
|
||||
|
||||
expect(result).toBe('mock-access-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,12 @@ const base64Only = new Set([
|
|||
EModelEndpoint.bedrock,
|
||||
]);
|
||||
|
||||
const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3, FileSources.firebase]);
|
||||
const blobStorageSources = new Set([
|
||||
FileSources.azure_blob,
|
||||
FileSources.s3,
|
||||
FileSources.firebase,
|
||||
FileSources.cloudfront,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Encodes and formats the given files.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,20 @@ const { FileSources } = require('librechat-data-provider');
|
|||
const {
|
||||
getS3URL,
|
||||
saveURLToS3,
|
||||
ImageService,
|
||||
parseDocument,
|
||||
uploadFileToS3,
|
||||
S3ImageService,
|
||||
saveBufferToS3,
|
||||
getS3FileStream,
|
||||
deleteFileFromS3,
|
||||
getCloudFrontURL,
|
||||
uploadMistralOCR,
|
||||
saveURLToCloudFront,
|
||||
uploadAzureMistralOCR,
|
||||
uploadFileToCloudFront,
|
||||
saveBufferToCloudFront,
|
||||
getCloudFrontFileStream,
|
||||
deleteFileFromCloudFront,
|
||||
uploadGoogleVertexMistralOCR,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -37,15 +43,21 @@ const {
|
|||
const { resizeImageBuffer } = require('./images/resize');
|
||||
const { updateUser, updateFile } = require('~/models');
|
||||
|
||||
const s3ImageService = new S3ImageService({
|
||||
const imageServiceDeps = {
|
||||
resizeImageBuffer,
|
||||
updateUser,
|
||||
updateFile,
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImageToS3 = (params) => s3ImageService.uploadImageToS3(params);
|
||||
const s3ImageService = new ImageService(saveBufferToS3, imageServiceDeps);
|
||||
const uploadImageToS3 = (params) => s3ImageService.uploadImage(params);
|
||||
const prepareImageURLS3 = (_req, file) => s3ImageService.prepareImageURL(file);
|
||||
const processS3Avatar = (params) => s3ImageService.processAvatar(params);
|
||||
|
||||
const cloudFrontImageService = new ImageService(saveBufferToCloudFront, imageServiceDeps);
|
||||
const uploadImageToCloudFront = (params) => cloudFrontImageService.uploadImage(params);
|
||||
const prepareCloudFrontImageURL = (_req, file) => cloudFrontImageService.prepareImageURL(file);
|
||||
const processCloudFrontAvatar = (params) => cloudFrontImageService.processAvatar(params);
|
||||
const {
|
||||
saveBufferToAzure,
|
||||
saveURLToAzure,
|
||||
|
|
@ -109,6 +121,22 @@ const s3Strategy = () => ({
|
|||
getDownloadStream: getS3FileStream,
|
||||
});
|
||||
|
||||
/**
|
||||
* CloudFront CDN Strategy Functions
|
||||
* Uses S3 for storage, CloudFront for URL delivery
|
||||
*/
|
||||
const cloudfrontStrategy = () => ({
|
||||
handleFileUpload: uploadFileToCloudFront,
|
||||
saveURL: saveURLToCloudFront,
|
||||
getFileURL: getCloudFrontURL,
|
||||
deleteFile: deleteFileFromCloudFront,
|
||||
saveBuffer: saveBufferToCloudFront,
|
||||
prepareImagePayload: prepareCloudFrontImageURL,
|
||||
processAvatar: processCloudFrontAvatar,
|
||||
handleImageUpload: uploadImageToCloudFront,
|
||||
getDownloadStream: getCloudFrontFileStream,
|
||||
});
|
||||
|
||||
/**
|
||||
* Azure Blob Storage Strategy Functions
|
||||
*
|
||||
|
|
@ -291,6 +319,8 @@ const getStrategyFunctions = (fileSource) => {
|
|||
return vectorStrategy();
|
||||
} else if (fileSource === FileSources.s3) {
|
||||
return s3Strategy();
|
||||
} else if (fileSource === FileSources.cloudfront) {
|
||||
return cloudfrontStrategy();
|
||||
} else if (fileSource === FileSources.execute_code) {
|
||||
return codeOutputStrategy();
|
||||
} else if (fileSource === FileSources.mistral_ocr) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue