diff --git a/api/package.json b/api/package.json index c5b588610e..c7bdd1145a 100644 --- a/api/package.json +++ b/api/package.json @@ -36,7 +36,9 @@ "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", + "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", + "@aws-sdk/cloudfront-signer": "^3.1036.0", "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 381bfc58b2..ae1c94a7c9 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,5 +1,5 @@ const cookies = require('cookie'); -const { isEnabled } = require('@librechat/api'); +const { isEnabled, clearCloudFrontCookies } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { logoutUser } = require('~/server/services/AuthService'); const { getOpenIdConfig } = require('~/strategies'); @@ -44,6 +44,7 @@ const logoutController = async (req, res) => { res.clearCookie('openid_id_token'); res.clearCookie('openid_user_id'); res.clearCookie('token_provider'); + clearCloudFrontCookies(res); const response = { message }; if ( isOpenIdUser && diff --git a/api/server/controllers/auth/LogoutController.spec.js b/api/server/controllers/auth/LogoutController.spec.js index c9294fdcec..ff02f5237e 100644 --- a/api/server/controllers/auth/LogoutController.spec.js +++ b/api/server/controllers/auth/LogoutController.spec.js @@ -4,9 +4,13 @@ const mockLogoutUser = jest.fn(); const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; const mockIsEnabled = jest.fn(); const mockGetOpenIdConfig = jest.fn(); +const mockClearCloudFrontCookies = jest.fn(); jest.mock('cookie'); -jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args) })); +jest.mock('@librechat/api', () => ({ + isEnabled: (...args) => mockIsEnabled(...args), + clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args), +})); jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); jest.mock('~/server/services/AuthService', () => ({ logoutUser: (...args) => mockLogoutUser(...args), @@ -255,6 +259,15 @@ describe('LogoutController', () => { expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id'); expect(res.clearCookie).toHaveBeenCalledWith('token_provider'); }); + + it('calls clearCloudFrontCookies on successful logout', async () => { + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res); + }); }); describe('URL length limit and logout_hint fallback', () => { diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 816a0eac5b..40b3c1a725 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -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); diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index c8abafdbe5..df89f3f2c9 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -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'); + }); + }); +}); diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 93d0aebd4b..2f075229aa 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -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. diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js index 47b39cb87b..8a88e9d2e1 100644 --- a/api/server/services/Files/strategies.js +++ b/api/server/services/Files/strategies.js @@ -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) { diff --git a/librechat.example.yaml b/librechat.example.yaml index 1b8fa43ee8..dd275718a2 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -18,12 +18,28 @@ cache: true # image: "firebase" # Storage for uploaded images in chats # document: "local" # Storage for document uploads (PDFs, text files, etc.) -# Available strategies: "local", "s3", "firebase" +# Available strategies: "local", "s3", "firebase", "azure_blob", "cloudfront" # If not specified, defaults to "local" for all file types # You can mix and match strategies based on your needs: # - Use S3 for avatars for fast global access # - Use Firebase for images with automatic optimization # - Use local storage for documents for privacy/compliance +# - Use CloudFront for CDN-accelerated delivery (requires S3 + cloudfront config below) + +# CloudFront CDN Configuration (optional) +# Use when fileStrategy: "cloudfront" or fileStrategies includes cloudfront +# Requires: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME +# For signed cookies/URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY +# cloudfront: +# domain: "https://cdn.example.com" # CloudFront domain (CNAME recommended for cookies) +# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true +# invalidateOnDelete: false # Create cache invalidation on file delete +# imageSigning: "none" # "none" (public) | "cookies" (signed cookies) +# # When imageSigning: "cookies", API + CloudFront must share a parent domain: +# # API: api.example.com, CloudFront CNAME: cdn.example.com, cookieDomain: ".example.com" +# cookieDomain: ".example.com" # Required for "cookies" - shared parent domain +# cookieExpiry: 1800 # Cookie lifetime in seconds (max: 604800 / 7 days, default: 1800 / 30 min) +# urlExpiry: 3600 # Reserved for future signed-URL mode (not yet implemented) # Custom interface configuration interface: @@ -116,7 +132,7 @@ interface: # - create: Allow users to create and manage new MCP servers # - share: Allow users to share MCP servers with other users # - public: Allow users to share MCP servers publicly (with everyone) - + # Creation / edit MCP server config Dialog config example # trustCheckbox: # label: diff --git a/package-lock.json b/package-lock.json index 183eff0c19..d578383bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,9 @@ "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", + "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", + "@aws-sdk/cloudfront-signer": "^3.1036.0", "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", @@ -3275,6 +3277,112 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-cloudfront": { + "version": "3.1042.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1042.0.tgz", + "integrity": "sha512-LE79zvUbdvXGExuJBbJAvvNWtnxwVXYYuyQMU3pzVv4gPUknsR3WTsaEhrdR8CkRM2wXq3ZKOPSyph3C2ZYa7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-kendra": { "version": "3.1041.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1041.0.tgz", @@ -3537,6 +3645,20 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/cloudfront-signer": { + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/cloudfront-signer/-/cloudfront-signer-3.1036.0.tgz", + "integrity": "sha512-AXEl7lGvlbWbN0Xi0dd0XVfzVnA39dUyMILPcTk6Q5DkwmPgp6M/K6A6Ejbjajhoe8C1AUF4O1+hDc7DPxnBxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/url-parser": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.974.8", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", @@ -19204,19 +19326,6 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", - "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/chunked-blob-reader": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", @@ -20175,13 +20284,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", - "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -44305,7 +44413,9 @@ "peerDependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", + "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", + "@aws-sdk/cloudfront-signer": "^3.1036.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", @@ -44328,6 +44438,7 @@ "ioredis": "^5.3.2", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", diff --git a/packages/api/package.json b/packages/api/package.json index ca3f4fd868..6a8a408a97 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,7 +25,7 @@ "test:cache-integration:mcp": "jest --testPathPatterns=\"src/mcp/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false", "test:cache-integration:stream": "jest --testPathPatterns=\"src/stream/.*\\.stream_integration\\.spec\\.ts$\" --coverage=false --runInBand --forceExit", "test:cache-integration": "npm run test:cache-integration:core && npm run test:cache-integration:cluster && npm run test:cache-integration:mcp && npm run test:cache-integration:stream", - "test:s3-integration": "jest --testPathPatterns=\"src/storage/s3/.*\\.s3_integration\\.spec\\.ts$\" --coverage=false --runInBand", + "test:s3-integration": "jest --testPathPatterns=\"src/storage/s3/.*\\.integration\\.spec\\.ts$\" --coverage=false --runInBand", "verify": "npm run test:ci", "b:clean": "bun run rimraf dist", "b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs", @@ -90,7 +90,9 @@ "peerDependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", + "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", + "@aws-sdk/cloudfront-signer": "^3.1036.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", diff --git a/packages/api/src/app/__tests__/cdn.test.ts b/packages/api/src/app/__tests__/cdn.test.ts new file mode 100644 index 0000000000..cc85fb594c --- /dev/null +++ b/packages/api/src/app/__tests__/cdn.test.ts @@ -0,0 +1,133 @@ +import { FileSources } from 'librechat-data-provider'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +jest.mock('~/cdn/firebase', () => ({ + initializeFirebase: jest.fn(), +})); + +jest.mock('~/cdn/azure', () => ({ + initializeAzureBlobService: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('~/cdn/s3', () => ({ + initializeS3: jest.fn(), +})); + +jest.mock('~/cdn/cloudfront', () => ({ + initializeCloudFront: jest.fn(), +})); + +import type { AppConfig } from '@librechat/data-schemas'; +import { initializeFileStorage } from '../cdn'; +import { initializeFirebase } from '~/cdn/firebase'; +import { initializeAzureBlobService } from '~/cdn/azure'; +import { initializeS3 } from '~/cdn/s3'; +import { initializeCloudFront } from '~/cdn/cloudfront'; + +const baseAppConfig: AppConfig = { + config: {}, + fileStrategy: FileSources.local, + imageOutputType: 'png', +}; + +describe('initializeFileStorage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes S3 when fileStrategy is s3', () => { + const appConfig = { ...baseAppConfig, fileStrategy: FileSources.s3 } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeS3).toHaveBeenCalledTimes(1); + expect(initializeFirebase).not.toHaveBeenCalled(); + expect(initializeCloudFront).not.toHaveBeenCalled(); + }); + + it('initializes Firebase when fileStrategy is firebase', () => { + const appConfig = { ...baseAppConfig, fileStrategy: FileSources.firebase } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeFirebase).toHaveBeenCalledTimes(1); + expect(initializeS3).not.toHaveBeenCalled(); + expect(initializeCloudFront).not.toHaveBeenCalled(); + }); + + it('initializes strategy from fileStrategies when fileStrategy is local', () => { + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.local, + fileStrategies: { avatar: FileSources.s3 }, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeS3).toHaveBeenCalledTimes(1); + expect(initializeFirebase).not.toHaveBeenCalled(); + expect(initializeCloudFront).not.toHaveBeenCalled(); + }); + + it('does not initialize S3 twice when fileStrategy and fileStrategies both use s3', () => { + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.s3, + fileStrategies: { image: FileSources.s3 }, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeS3).toHaveBeenCalledTimes(1); + }); + + it('initializes multiple different strategies from fileStrategies', () => { + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.s3, + fileStrategies: { avatar: FileSources.firebase }, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeS3).toHaveBeenCalledTimes(1); + expect(initializeFirebase).toHaveBeenCalledTimes(1); + }); + + it('initializes CloudFront with config when fileStrategy is cloudfront', () => { + const cloudfrontConfig = { domain: 'https://d123.cloudfront.net' }; + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.cloudfront, + cloudfront: cloudfrontConfig, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeCloudFront).toHaveBeenCalledTimes(1); + expect(initializeCloudFront).toHaveBeenCalledWith(cloudfrontConfig); + }); + + it('logs an error and does not initialize CloudFront when cloudfront config is absent', () => { + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.cloudfront, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeCloudFront).not.toHaveBeenCalled(); + }); + + it('initializes CloudFront from fileStrategies when fileStrategy is local, but avatar is configured for CloudFront', () => { + const cloudfrontConfig = { domain: 'https://d123.cloudfront.net' }; + const appConfig = { + ...baseAppConfig, + fileStrategy: FileSources.local, + fileStrategies: { avatar: FileSources.cloudfront }, + cloudfront: cloudfrontConfig, + } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeCloudFront).toHaveBeenCalledTimes(1); + expect(initializeCloudFront).toHaveBeenCalledWith(cloudfrontConfig); + expect(initializeS3).not.toHaveBeenCalled(); + }); + + it('does not call any initializer when fileStrategy is local with no fileStrategies', () => { + const appConfig = { ...baseAppConfig, fileStrategy: FileSources.local } as AppConfig; + initializeFileStorage(appConfig); + expect(initializeS3).not.toHaveBeenCalled(); + expect(initializeFirebase).not.toHaveBeenCalled(); + expect(initializeAzureBlobService).not.toHaveBeenCalled(); + expect(initializeCloudFront).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/app/cdn.ts b/packages/api/src/app/cdn.ts index 451eeb8897..9feb606cfb 100644 --- a/packages/api/src/app/cdn.ts +++ b/packages/api/src/app/cdn.ts @@ -4,23 +4,56 @@ import type { AppConfig } from '@librechat/data-schemas'; import { initializeAzureBlobService } from '~/cdn/azure'; import { initializeFirebase } from '~/cdn/firebase'; import { initializeS3 } from '~/cdn/s3'; +import { initializeCloudFront } from '~/cdn/cloudfront'; -/** - * Initializes file storage clients based on the configured file strategy. - * This should be called after loading the app configuration. - * @param {Object} options - * @param {AppConfig} options.appConfig - The application configuration - */ -export function initializeFileStorage(appConfig: AppConfig) { - const { fileStrategy } = appConfig; - - if (fileStrategy === FileSources.firebase) { +function initializeStrategy(strategy: FileSources, appConfig: AppConfig): void { + if (strategy === FileSources.firebase) { initializeFirebase(); - } else if (fileStrategy === FileSources.azure_blob) { + } else if (strategy === FileSources.azure_blob) { initializeAzureBlobService().catch((error) => { logger.error('Error initializing Azure Blob Service:', error); }); - } else if (fileStrategy === FileSources.s3) { + } else if (strategy === FileSources.s3) { initializeS3(); + } else if (strategy === FileSources.cloudfront) { + const cloudfrontConfig = appConfig.cloudfront; + if (!cloudfrontConfig) { + logger.error( + '[initializeFileStorage] CloudFront strategy requires cloudfront config in librechat.yaml', + ); + return; + } + const initialized = initializeCloudFront(cloudfrontConfig); + if (!initialized) { + logger.error( + '[initializeFileStorage] CloudFront initialization failed. CloudFront operations will not work.', + ); + } + } +} + +/** + * Initializes file storage clients based on the configured file strategies. + * Handles both the main fileStrategy and granular fileStrategies config. + */ +export function initializeFileStorage(appConfig: AppConfig): void { + const { fileStrategy, fileStrategies } = appConfig; + + const strategiesToInit = new Set(); + + if (fileStrategy) { + strategiesToInit.add(fileStrategy); + } + + if (fileStrategies) { + for (const value of Object.values(fileStrategies)) { + if (value) { + strategiesToInit.add(value); + } + } + } + + for (const strategy of strategiesToInit) { + initializeStrategy(strategy, appConfig); } } diff --git a/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts new file mode 100644 index 0000000000..73f0e25872 --- /dev/null +++ b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts @@ -0,0 +1,407 @@ +import type { CloudfrontSignInput } from '@aws-sdk/cloudfront-signer'; + +const mockGetCloudFrontConfig = jest.fn(); +const mockGetSignedCookies = jest.fn(); + +jest.mock('~/cdn/cloudfront', () => ({ + getCloudFrontConfig: () => mockGetCloudFrontConfig(), +})); + +jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedCookies: (params: CloudfrontSignInput) => mockGetSignedCookies(params), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +import type { Response } from 'express'; +import { setCloudFrontCookies, clearCloudFrontCookies } from '../cloudfront-cookies'; + +const { logger: mockLogger } = jest.requireMock('@librechat/data-schemas') as { + logger: { warn: jest.Mock; error: jest.Mock; info: jest.Mock; debug: jest.Mock }; +}; + +describe('setCloudFrontCookies', () => { + let mockRes: Partial; + let cookieArgs: Array<[string, string, object]>; + + beforeEach(() => { + jest.clearAllMocks(); + cookieArgs = []; + mockRes = { + cookie: jest.fn((name: string, value: string, options: object) => { + cookieArgs.push([name, value, options]); + return mockRes as Response; + }) as unknown as Response['cookie'], + }; + }); + + it('returns false when CloudFront config is null', () => { + mockGetCloudFrontConfig.mockReturnValue(null); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when imageSigning is not "cookies"', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'none', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when signing keys are missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: null, + keyPairId: null, + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when cookieDomain is missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('uses default expiry of 1800s when cookieExpiry is missing from config (Zod default not applied)', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + // cookieExpiry intentionally absent — simulates raw YAML without Zod defaults + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalled(); + const [, , options] = cookieArgs[0]; + expect((options as { expires: Date }).expires).toBeInstanceOf(Date); + expect(isNaN((options as { expires: Date }).expires.getTime())).toBe(false); + }); + + it('sets three CloudFront cookies when enabled', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(true); + expect(mockRes.cookie).toHaveBeenCalledTimes(3); + + const cookieNames = cookieArgs.map(([name]) => name); + expect(cookieNames).toContain('CloudFront-Policy'); + expect(cookieNames).toContain('CloudFront-Signature'); + expect(cookieNames).toContain('CloudFront-Key-Pair-Id'); + }); + + it('uses cookieDomain from config with path', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + const [, , options] = cookieArgs[0]; + expect(options).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: 'none', + domain: '.example.com', + path: '/images', + }); + }); + + it('builds correct custom policy for images resource', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + expect(mockGetSignedCookies).toHaveBeenCalledWith( + expect.objectContaining({ + keyPairId: 'K123ABC', + privateKey: expect.stringContaining('BEGIN RSA PRIVATE KEY'), + policy: expect.stringContaining('https://cdn.example.com/images/*'), + }), + ); + }); + + it('handles multiple trailing slashes in domain', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com///', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + expect(mockGetSignedCookies).toHaveBeenCalledWith( + expect.objectContaining({ + policy: expect.stringContaining('https://cdn.example.com/images/*'), + }), + ); + }); + + it('returns false when getSignedCookies returns empty object', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({}); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Missing expected cookie from AWS SDK'), + ); + }); + + it('returns false when getSignedCookies returns partial result', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ 'CloudFront-Policy': 'policy-value' }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false and logs error on signing error', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: 'invalid-key', + keyPairId: 'K123ABC', + }); + + const signingError = new Error('Invalid private key'); + mockGetSignedCookies.mockImplementation(() => { + throw signingError; + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + '[setCloudFrontCookies] Failed to generate signed cookies:', + signingError, + ); + }); +}); + +describe('clearCloudFrontCookies', () => { + let mockRes: Partial; + let clearedCookies: Array<[string, object]>; + + beforeEach(() => { + jest.clearAllMocks(); + clearedCookies = []; + mockRes = { + clearCookie: jest.fn((name: string, options: object) => { + clearedCookies.push([name, options]); + return mockRes as Response; + }) as unknown as Response['clearCookie'], + }; + }); + + it('does nothing when config is null', () => { + mockGetCloudFrontConfig.mockReturnValue(null); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('does nothing when imageSigning is not "cookies"', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'none', + cookieDomain: '.example.com', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('does nothing when cookieDomain is missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('clears all three CloudFront cookies with correct domain', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).toHaveBeenCalledTimes(3); + + const expectedOptions = { + domain: '.example.com', + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none', + }; + expect(clearedCookies).toContainEqual(['CloudFront-Policy', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Signature', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Key-Pair-Id', expectedOptions]); + }); + + it('clears cookies with full security attributes matching set path', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).toHaveBeenCalledTimes(3); + + const expectedOptions = { + domain: '.example.com', + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none', + }; + expect(clearedCookies).toContainEqual(['CloudFront-Policy', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Signature', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Key-Pair-Id', expectedOptions]); + }); + + it('logs warning and does not throw when clearing fails', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + const clearError = new Error('Cookie clear failed'); + mockRes.clearCookie = jest.fn(() => { + throw clearError; + }) as unknown as Response['clearCookie']; + + expect(() => clearCloudFrontCookies(mockRes as Response)).not.toThrow(); + expect(mockLogger.warn).toHaveBeenCalledWith( + '[clearCloudFrontCookies] Failed to clear cookies:', + clearError, + ); + }); +}); diff --git a/packages/api/src/cdn/__tests__/cloudfront.test.ts b/packages/api/src/cdn/__tests__/cloudfront.test.ts new file mode 100644 index 0000000000..bcdf202fe6 --- /dev/null +++ b/packages/api/src/cdn/__tests__/cloudfront.test.ts @@ -0,0 +1,182 @@ +import type { CloudFrontConfig } from 'librechat-data-provider'; + +const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }; +const mockInitializeS3 = jest.fn(); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('../s3', () => ({ + initializeS3: mockInitializeS3, +})); + +type RequiredCloudFrontConfig = NonNullable; + +/** Build a fully-typed config object, filling in all Zod-defaulted fields. */ +function makeConfig(overrides: Partial = {}): RequiredCloudFrontConfig { + return { + domain: 'https://d123.cloudfront.net', + invalidateOnDelete: false, + imageSigning: 'none', + urlExpiry: 3600, + cookieExpiry: 1800, + ...overrides, + }; +} + +describe('CloudFront CDN module', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + mockInitializeS3.mockReturnValue({}); + delete process.env.CLOUDFRONT_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_PRIVATE_KEY; + }); + + async function load() { + jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); + jest.mock('../s3', () => ({ initializeS3: mockInitializeS3 })); + return import('../cloudfront'); + } + + describe('initializeCloudFront', () => { + it('returns false when domain is not provided', async () => { + const { initializeCloudFront } = await load(); + expect(initializeCloudFront({} as never)).toBe(false); // intentionally invalid input to test runtime guard + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('[initializeCloudFront] CloudFront domain is required'), + ); + }); + + it('returns false when S3 is not initialized', async () => { + mockInitializeS3.mockReturnValue(null); + const { initializeCloudFront } = await load(); + expect(initializeCloudFront(makeConfig())).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('[initializeCloudFront] S3 must be initialized'), + ); + }); + + it('returns true and logs without signing keys when keys are absent', async () => { + const { initializeCloudFront } = await load(); + expect(initializeCloudFront(makeConfig())).toBe(true); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('without signing keys')); + }); + + it('returns true without signing-key warnings when keys are set and imageSigning is "none"', async () => { + process.env.CLOUDFRONT_KEY_PAIR_ID = 'K123'; + process.env.CLOUDFRONT_PRIVATE_KEY = + '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----'; + const { initializeCloudFront } = await load(); + expect(initializeCloudFront(makeConfig())).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Signing keys are configured'), + ); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('without signing keys'), + ); + }); + + it('returns true immediately when already initialized (no re-init)', async () => { + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig()); + jest.clearAllMocks(); + expect(initializeCloudFront(makeConfig({ domain: 'https://different.cloudfront.net' }))).toBe( + true, + ); + expect(mockInitializeS3).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('logs cache invalidation message when invalidateOnDelete is enabled', async () => { + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig({ distributionId: 'E123ABC', invalidateOnDelete: true })); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Cache invalidation on delete enabled'), + ); + }); + + it('does not log cache invalidation message when invalidateOnDelete is false', async () => { + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig({ invalidateOnDelete: false })); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Cache invalidation'), + ); + }); + + it('returns false and errors when imageSigning is "cookies" but signing keys are missing', async () => { + const { initializeCloudFront } = await load(); + const result = initializeCloudFront(makeConfig({ imageSigning: 'cookies' })); + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + '[initializeCloudFront] imageSigning="cookies" requires CLOUDFRONT_KEY_PAIR_ID', + ), + ); + }); + + it('logs info when imageSigning is "cookies" and signing keys are present', async () => { + process.env.CLOUDFRONT_KEY_PAIR_ID = 'K123'; + process.env.CLOUDFRONT_PRIVATE_KEY = 'my-private-key'; + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig({ imageSigning: 'cookies' })); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('CloudFront cookie signing enabled'), + ); + }); + + it('warns when imageSigning is "url"', async () => { + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig({ imageSigning: 'url' })); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + '[initializeCloudFront] imageSigning="url" is configured but not yet implemented', + ), + ); + }); + }); + + describe('getCloudFrontConfig', () => { + it('returns null before initialization', async () => { + const { getCloudFrontConfig } = await load(); + expect(getCloudFrontConfig()).toBeNull(); + }); + + it('returns config with domain after initialization', async () => { + const { initializeCloudFront, getCloudFrontConfig } = await load(); + initializeCloudFront(makeConfig()); + expect(getCloudFrontConfig()?.domain).toBe('https://d123.cloudfront.net'); + }); + + it('returns config with null signing keys when env vars absent', async () => { + const { initializeCloudFront, getCloudFrontConfig } = await load(); + initializeCloudFront(makeConfig()); + const config = getCloudFrontConfig(); + expect(config?.privateKey).toBeNull(); + expect(config?.keyPairId).toBeNull(); + }); + + it('returns config with signing keys embedded when env vars are set', async () => { + process.env.CLOUDFRONT_KEY_PAIR_ID = 'K456'; + process.env.CLOUDFRONT_PRIVATE_KEY = 'my-private-key'; + const { initializeCloudFront, getCloudFrontConfig } = await load(); + initializeCloudFront(makeConfig()); + const config = getCloudFrontConfig(); + expect(config?.keyPairId).toBe('K456'); + expect(config?.privateKey).toBe('my-private-key'); + }); + + it('returns config with urlExpiry when provided', async () => { + const { initializeCloudFront, getCloudFrontConfig } = await load(); + initializeCloudFront(makeConfig({ urlExpiry: 7200 })); + expect(getCloudFrontConfig()?.urlExpiry).toBe(7200); + }); + + it('persists same config on second getCloudFrontConfig call', async () => { + const { initializeCloudFront, getCloudFrontConfig } = await load(); + initializeCloudFront(makeConfig()); + expect(getCloudFrontConfig()).toBe(getCloudFrontConfig()); + }); + }); +}); diff --git a/packages/api/src/cdn/cloudfront-cookies.ts b/packages/api/src/cdn/cloudfront-cookies.ts new file mode 100644 index 0000000000..8ed23dc1d1 --- /dev/null +++ b/packages/api/src/cdn/cloudfront-cookies.ts @@ -0,0 +1,109 @@ +import { logger } from '@librechat/data-schemas'; +import { getSignedCookies } from '@aws-sdk/cloudfront-signer'; + +import type { Response } from 'express'; + +import { getCloudFrontConfig } from './cloudfront'; + +const DEFAULT_COOKIE_EXPIRY = 1800; + +const REQUIRED_CF_COOKIES = [ + 'CloudFront-Policy', + 'CloudFront-Signature', + 'CloudFront-Key-Pair-Id', +] as const; + +/** + * Clears CloudFront signed cookies from the response. + * Should be called during logout to revoke CDN access. + */ +export function clearCloudFrontCookies(res: Response): void { + try { + const config = getCloudFrontConfig(); + if (!config?.cookieDomain || config.imageSigning !== 'cookies') { + return; + } + const options = { + domain: config.cookieDomain, + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none' as const, + }; + res.clearCookie('CloudFront-Policy', options); + res.clearCookie('CloudFront-Signature', options); + res.clearCookie('CloudFront-Key-Pair-Id', options); + } catch (error) { + logger.warn('[clearCloudFrontCookies] Failed to clear cookies:', error); + } +} + +/** + * Sets CloudFront signed cookies on the response for CDN access. + * Returns true if cookies were set, false if CloudFront cookies are not enabled. + */ +export function setCloudFrontCookies(res: Response): boolean { + const config = getCloudFrontConfig(); + if ( + !config || + config.imageSigning !== 'cookies' || + !config.privateKey || + !config.keyPairId || + !config.cookieDomain + ) { + return false; + } + + try { + const cookieExpiry = config.cookieExpiry ?? DEFAULT_COOKIE_EXPIRY; + const expiresAtMs = Date.now() + cookieExpiry * 1000; + const expiresAt = new Date(expiresAtMs); + const expiresAtEpoch = Math.floor(expiresAtMs / 1000); + + const resourceUrl = `${config.domain.replace(/\/+$/, '')}/images/*`; + + const policy = JSON.stringify({ + Statement: [ + { + Resource: resourceUrl, + Condition: { + DateLessThan: { + 'AWS:EpochTime': expiresAtEpoch, + }, + }, + }, + ], + }); + + const signedCookies = getSignedCookies({ + keyPairId: config.keyPairId, + privateKey: config.privateKey, + policy, + }); + + const cookieOptions = { + expires: expiresAt, + httpOnly: true, + secure: true, + sameSite: 'none' as const, + domain: config.cookieDomain, + path: '/images', + }; + + for (const key of REQUIRED_CF_COOKIES) { + if (!signedCookies[key]) { + logger.error(`[setCloudFrontCookies] Missing expected cookie from AWS SDK: ${key}`); + return false; + } + } + + for (const key of REQUIRED_CF_COOKIES) { + res.cookie(key, signedCookies[key], cookieOptions); + } + + return true; + } catch (error) { + logger.error('[setCloudFrontCookies] Failed to generate signed cookies:', error); + return false; + } +} diff --git a/packages/api/src/cdn/cloudfront.ts b/packages/api/src/cdn/cloudfront.ts new file mode 100644 index 0000000000..4bf56de43c --- /dev/null +++ b/packages/api/src/cdn/cloudfront.ts @@ -0,0 +1,66 @@ +import { logger } from '@librechat/data-schemas'; +import type { CloudFrontConfig } from 'librechat-data-provider'; +import { initializeS3 } from './s3'; + +export interface CloudFrontFullConfig extends NonNullable { + privateKey: string | null; + keyPairId: string | null; +} + +let cloudFrontConfig: CloudFrontFullConfig | null = null; + +export function initializeCloudFront(config: CloudFrontConfig): boolean { + if (cloudFrontConfig) { + logger.debug('[initializeCloudFront] Already initialized; skipping re-initialization.'); + return true; + } + + if (!config?.domain) { + logger.error('[initializeCloudFront] CloudFront domain is required in config.'); + return false; + } + + const s3 = initializeS3(); + if (!s3) { + logger.error('[initializeCloudFront] S3 must be initialized for CloudFront to work.'); + return false; + } + + const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID ?? null; + const privateKey = process.env.CLOUDFRONT_PRIVATE_KEY ?? null; + + if (config.imageSigning === 'cookies' && (!keyPairId || !privateKey)) { + logger.error( + '[initializeCloudFront] imageSigning="cookies" requires CLOUDFRONT_KEY_PAIR_ID and CLOUDFRONT_PRIVATE_KEY env vars.', + ); + return false; + } + + cloudFrontConfig = { ...config, privateKey, keyPairId }; + + if (config.imageSigning === 'cookies') { + logger.info( + '[initializeCloudFront] CloudFront cookie signing enabled. Cookies will be set during auth.', + ); + } else if (config.imageSigning === 'url') { + logger.warn( + '[initializeCloudFront] imageSigning="url" is configured but not yet implemented for images.', + ); + } + + if (!keyPairId || !privateKey) { + logger.info( + '[initializeCloudFront] CloudFront initialized without signing keys (public OAC only).', + ); + } + + if (config.invalidateOnDelete) { + logger.info('[initializeCloudFront] Cache invalidation on delete enabled.'); + } + + return true; +} + +export function getCloudFrontConfig(): Readonly | null { + return cloudFrontConfig; +} diff --git a/packages/api/src/cdn/index.ts b/packages/api/src/cdn/index.ts index 04be850216..69febb17d9 100644 --- a/packages/api/src/cdn/index.ts +++ b/packages/api/src/cdn/index.ts @@ -1,3 +1,5 @@ export * from './azure'; +export * from './cloudfront'; +export * from './cloudfront-cookies'; export * from './firebase'; export * from './s3'; diff --git a/packages/api/src/storage/__tests__/images.test.ts b/packages/api/src/storage/__tests__/images.test.ts new file mode 100644 index 0000000000..9d151a1044 --- /dev/null +++ b/packages/api/src/storage/__tests__/images.test.ts @@ -0,0 +1,309 @@ +import fs from 'fs'; +import sharp from 'sharp'; +import type { ServerRequest } from '~/types'; +import type { SaveBufferFn } from '~/storage/types'; +import type { ImageServiceDeps } from '~/storage/images'; +import { ImageService } from '~/storage/images'; + +jest.mock('fs', () => { + const actualFs = jest.requireActual('fs'); + return { + ...actualFs, + promises: { + ...actualFs.promises, + readFile: jest.fn(), + unlink: jest.fn(), + }, + }; +}); + +jest.mock('sharp', () => { + const mockSharp = jest.fn(() => ({ + metadata: jest.fn().mockResolvedValue({ format: 'png', width: 100, height: 100 }), + toFormat: jest.fn().mockReturnThis(), + toBuffer: jest.fn().mockResolvedValue(Buffer.from('processed')), + })); + return mockSharp; +}); + +describe('ImageService', () => { + let mockSaveBuffer: jest.MockedFunction; + let mockDeps: ImageServiceDeps; + let service: ImageService; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSaveBuffer = jest + .fn() + .mockResolvedValue('https://storage.example.com/images/user123/file.webp'); + + mockDeps = { + resizeImageBuffer: jest.fn().mockResolvedValue({ + buffer: Buffer.from('resized'), + width: 800, + height: 600, + }), + updateUser: jest.fn().mockResolvedValue(undefined), + updateFile: jest.fn().mockResolvedValue(null), + }; + + service = new ImageService(mockSaveBuffer, mockDeps); + }); + + describe('uploadImage', () => { + const mockReq = { + user: { id: 'user123' }, + config: { imageOutputType: 'webp' }, + }; + + const mockFile = { + path: '/tmp/upload-123.jpg', + originalname: 'photo.jpg', + } as Express.Multer.File; + + beforeEach(() => { + (fs.promises.readFile as jest.Mock).mockResolvedValue(Buffer.from('original')); + (fs.promises.unlink as jest.Mock).mockResolvedValue(undefined); + }); + + it('uploads and processes an image successfully', async () => { + const result = await service.uploadImage({ + req: mockReq as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(result).toEqual({ + filepath: 'https://storage.example.com/images/user123/file.webp', + bytes: expect.any(Number), + width: 800, + height: 600, + }); + + expect(mockDeps.resizeImageBuffer).toHaveBeenCalledWith(expect.any(Buffer), 'high', 'openAI'); + + expect(mockSaveBuffer).toHaveBeenCalledWith({ + userId: 'user123', + buffer: expect.any(Buffer), + fileName: expect.stringContaining('file-456__'), + basePath: 'images', + }); + + expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/upload-123.jpg'); + }); + + it('throws error when user not authenticated', async () => { + const reqNoUser = { config: {} }; + + await expect( + service.uploadImage({ + req: reqNoUser as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + }), + ).rejects.toThrow('User not authenticated'); + }); + + it('skips format conversion when extension matches target', async () => { + const webpFile = { + path: '/tmp/upload-123.webp', + originalname: 'photo.webp', + } as Express.Multer.File; + + await service.uploadImage({ + req: mockReq as ServerRequest, + file: webpFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(sharp).not.toHaveBeenCalled(); + }); + + it('uses custom resolution when provided', async () => { + await service.uploadImage({ + req: mockReq as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + resolution: 'low', + }); + + expect(mockDeps.resizeImageBuffer).toHaveBeenCalledWith(expect.any(Buffer), 'low', 'openAI'); + }); + + it('uses custom basePath when provided', async () => { + await service.uploadImage({ + req: mockReq as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + basePath: 'avatars', + }); + + expect(mockSaveBuffer).toHaveBeenCalledWith(expect.objectContaining({ basePath: 'avatars' })); + }); + + it('defaults to webp when imageOutputType not configured', async () => { + const reqNoConfig = { user: { id: 'user123' }, config: {} }; + + await service.uploadImage({ + req: reqNoConfig as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(mockSaveBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expect.stringContaining('.webp'), + }), + ); + }); + + it('deletes temp file when readFile throws', async () => { + (fs.promises.readFile as jest.Mock).mockRejectedValueOnce(new Error('ENOENT')); + + await expect( + service.uploadImage({ + req: mockReq as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + }), + ).rejects.toThrow('ENOENT'); + + expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/upload-123.jpg'); + }); + + it('deletes temp file when resize throws', async () => { + (fs.promises.readFile as jest.Mock).mockResolvedValueOnce(Buffer.from('raw')); + (mockDeps.resizeImageBuffer as jest.Mock).mockRejectedValueOnce(new Error('Resize failed')); + + await expect( + service.uploadImage({ + req: mockReq as ServerRequest, + file: mockFile, + file_id: 'file-456', + endpoint: 'openAI', + }), + ).rejects.toThrow('Resize failed'); + + expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/upload-123.jpg'); + }); + }); + + describe('prepareImageURL', () => { + it('updates file and returns the updated document alongside its filepath', async () => { + const file = { file_id: 'file-123', filepath: 'https://example.com/image.webp' }; + + const result = await service.prepareImageURL(file); + + expect(result).toEqual([null, 'https://example.com/image.webp']); + expect(mockDeps.updateFile).toHaveBeenCalledWith({ file_id: 'file-123' }); + }); + + it('returns the updated MongoFile as the first element when found', async () => { + const mongoFile = { file_id: 'file-123', filepath: 'https://example.com/image.webp' }; + (mockDeps.updateFile as jest.Mock).mockResolvedValue(mongoFile); + + const file = { file_id: 'file-123', filepath: 'https://example.com/image.webp' }; + const result = await service.prepareImageURL(file); + + expect(result[0]).toEqual(mongoFile); + expect(result[1]).toBe('https://example.com/image.webp'); + }); + }); + + describe('processAvatar', () => { + it('processes and uploads avatar for user', async () => { + const buffer = Buffer.from('avatar-data'); + + const result = await service.processAvatar({ + buffer, + userId: 'user123', + manual: 'true', + }); + + expect(result).toBe('https://storage.example.com/images/user123/file.webp'); + expect(mockSaveBuffer).toHaveBeenCalledWith({ + userId: 'user123', + buffer, + fileName: expect.stringMatching(/^avatar-\d+\.png$/), + basePath: 'images', + }); + expect(mockDeps.updateUser).toHaveBeenCalledWith('user123', { + avatar: 'https://storage.example.com/images/user123/file.webp', + }); + }); + + it('does not update user when manual is false', async () => { + const buffer = Buffer.from('avatar-data'); + + await service.processAvatar({ + buffer, + userId: 'user123', + manual: 'false', + }); + + expect(mockDeps.updateUser).not.toHaveBeenCalled(); + }); + + it('creates agent avatar with correct filename and skips user update', async () => { + const buffer = Buffer.from('avatar-data'); + + await service.processAvatar({ + buffer, + userId: 'user123', + manual: 'true', + agentId: 'agent-456', + }); + + expect(mockSaveBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expect.stringMatching(/^agent-agent-456-avatar-\d+\.png$/), + }), + ); + expect(mockDeps.updateUser).not.toHaveBeenCalled(); + }); + + it('appends manual param when config.appendManualParam is true', async () => { + const serviceWithManualParam = new ImageService(mockSaveBuffer, mockDeps, { + appendManualParam: true, + }); + + const buffer = Buffer.from('avatar-data'); + + const result = await serviceWithManualParam.processAvatar({ + buffer, + userId: 'user123', + manual: 'true', + }); + + expect(result).toBe('https://storage.example.com/images/user123/file.webp?manual=true'); + }); + + it('uses gif extension for animated images', async () => { + (sharp as unknown as jest.Mock).mockImplementationOnce(() => ({ + metadata: jest.fn().mockResolvedValue({ format: 'gif' }), + })); + + const buffer = Buffer.from('gif-data'); + + await service.processAvatar({ + buffer, + userId: 'user123', + manual: 'false', + }); + + expect(mockSaveBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: expect.stringMatching(/\.gif$/), + }), + ); + }); + }); +}); diff --git a/packages/api/src/storage/cloudfront/__tests__/crud.test.ts b/packages/api/src/storage/cloudfront/__tests__/crud.test.ts new file mode 100644 index 0000000000..e5da63e836 --- /dev/null +++ b/packages/api/src/storage/cloudfront/__tests__/crud.test.ts @@ -0,0 +1,440 @@ +import { Readable } from 'stream'; +import type { TFile } from 'librechat-data-provider'; +import type { CloudFrontFullConfig } from '~/cdn/cloudfront'; +import type { ServerRequest } from '~/types'; + +const mockGetCloudFrontConfig = jest.fn(); +const mockGetS3Key = jest.fn(); +const mockSaveBufferToS3 = jest.fn(); +const mockSaveURLToS3 = jest.fn(); +const mockUploadFileToS3 = jest.fn(); +const mockDeleteFileFromS3 = jest.fn(); +const mockGetS3FileStream = jest.fn(); +const mockExtractKeyFromS3Url = jest.fn(); +const mockCloudFrontSend = jest.fn(); +const mockGetSignedUrl = jest.fn(); +const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +jest.mock('~/cdn/cloudfront', () => ({ + getCloudFrontConfig: mockGetCloudFrontConfig, +})); + +jest.mock('~/storage/s3/crud', () => ({ + getS3Key: mockGetS3Key, + saveBufferToS3: mockSaveBufferToS3, + saveURLToS3: mockSaveURLToS3, + uploadFileToS3: mockUploadFileToS3, + deleteFileFromS3: mockDeleteFileFromS3, + getS3FileStream: mockGetS3FileStream, + extractKeyFromS3Url: mockExtractKeyFromS3Url, +})); + +jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedUrl: mockGetSignedUrl, +})); + +jest.mock('@aws-sdk/client-cloudfront', () => ({ + CloudFrontClient: jest.fn().mockImplementation(() => ({ send: mockCloudFrontSend })), + CreateInvalidationCommand: jest.fn().mockImplementation((input) => ({ input })), +})); + +jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); + +jest.mock('~/storage/s3/s3Config', () => ({ + s3Config: { S3_URL_EXPIRY_SECONDS: 900, AWS_REGION: 'us-east-1' }, +})); + +function makeConfig(overrides: Partial = {}): CloudFrontFullConfig { + return { + domain: 'https://d123.cloudfront.net', + invalidateOnDelete: false, + imageSigning: 'none', + urlExpiry: 3600, + cookieExpiry: 1800, + privateKey: null, + keyPairId: null, + ...overrides, + }; +} + +describe('CloudFront CRUD', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetS3Key.mockImplementation( + (basePath, userId, fileName) => `${basePath}/${userId}/${fileName}`, + ); + mockGetCloudFrontConfig.mockReturnValue(makeConfig()); + }); + + describe('getCloudFrontURL', () => { + it('returns plain CloudFront URL when sign is false (default)', async () => { + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ userId: 'user1', fileName: 'photo.webp' }); + expect(url).toBe('https://d123.cloudfront.net/images/user1/photo.webp'); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('uses custom basePath when provided', async () => { + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ + userId: 'user1', + fileName: 'doc.pdf', + basePath: 'documents', + }); + expect(url).toBe('https://d123.cloudfront.net/documents/user1/doc.pdf'); + }); + + it('strips trailing slash from domain', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ domain: 'https://d123.cloudfront.net/' }), + ); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ userId: 'user1', fileName: 'photo.webp' }); + expect(url).toBe('https://d123.cloudfront.net/images/user1/photo.webp'); + }); + + it('strips multiple trailing slashes from domain', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ domain: 'https://d123.cloudfront.net///' }), + ); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ userId: 'user1', fileName: 'photo.webp' }); + expect(url).toBe('https://d123.cloudfront.net/images/user1/photo.webp'); + }); + + it('strips leading slash from S3 key', async () => { + mockGetS3Key.mockReturnValue('/images/user1/photo.webp'); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ userId: 'user1', fileName: 'photo.webp' }); + expect(url).toBe('https://d123.cloudfront.net/images/user1/photo.webp'); + }); + + it('strips multiple leading slashes from S3 key', async () => { + mockGetS3Key.mockReturnValue('///images/user1/photo.webp'); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ userId: 'user1', fileName: 'photo.webp' }); + expect(url).toBe('https://d123.cloudfront.net/images/user1/photo.webp'); + }); + + it('returns signed URL when sign is true', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ privateKey: 'pk-secret', keyPairId: 'K123' }), + ); + mockGetSignedUrl.mockReturnValue( + 'https://d123.cloudfront.net/doc.pdf?Policy=abc&Signature=xyz', + ); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + const url = await getCloudFrontURL({ + userId: 'user1', + fileName: 'doc.pdf', + basePath: 'documents', + sign: true, + }); + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://d123.cloudfront.net/documents/user1/doc.pdf', + keyPairId: 'K123', + privateKey: 'pk-secret', + dateLessThan: expect.any(String), + }), + ); + expect(url).toContain('Policy=abc'); + }); + + it('uses urlExpiry from config for signed URL', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ privateKey: 'pk', keyPairId: 'K1', urlExpiry: 7200 }), + ); + mockGetSignedUrl.mockReturnValue('https://d123.cloudfront.net/doc.pdf?signed'); + const before = Date.now(); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + await getCloudFrontURL({ userId: 'u', fileName: 'f.pdf', sign: true }); + const after = Date.now(); + + const { dateLessThan } = mockGetSignedUrl.mock.calls[0][0] as { dateLessThan: string }; + const expiry = new Date(dateLessThan).getTime(); + expect(expiry).toBeGreaterThanOrEqual(before + 7200 * 1000); + expect(expiry).toBeLessThanOrEqual(after + 7200 * 1000 + 100); + }); + + it('falls back to s3Config expiry when urlExpiry not set', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ privateKey: 'pk', keyPairId: 'K1', urlExpiry: undefined as never }), // intentionally invalid input to test runtime guard + ); + mockGetSignedUrl.mockReturnValue('https://d123.cloudfront.net/doc.pdf?signed'); + const before = Date.now(); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + await getCloudFrontURL({ userId: 'u', fileName: 'f.pdf', sign: true }); + const after = Date.now(); + + const { dateLessThan } = mockGetSignedUrl.mock.calls[0][0] as { dateLessThan: string }; + const expiry = new Date(dateLessThan).getTime(); + // 900s fallback from mocked s3Config + expect(expiry).toBeGreaterThanOrEqual(before + 900 * 1000); + expect(expiry).toBeLessThanOrEqual(after + 900 * 1000 + 100); + }); + + it('throws when CloudFront is not initialized', async () => { + mockGetCloudFrontConfig.mockReturnValue(null); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + await expect(getCloudFrontURL({ userId: 'u', fileName: 'f.png' })).rejects.toThrow( + 'CloudFront not initialized', + ); + }); + + it('throws when signing requested but keys not configured', async () => { + // config with no keys + mockGetCloudFrontConfig.mockReturnValue(makeConfig({ privateKey: null, keyPairId: null })); + const { getCloudFrontURL } = await import('~/storage/cloudfront/crud'); + await expect( + getCloudFrontURL({ userId: 'u', fileName: 'doc.pdf', sign: true }), + ).rejects.toThrow('Signing keys not configured'); + }); + }); + + describe('saveBufferToCloudFront', () => { + it('calls saveBufferToS3 with correct params and urlBuilder', async () => { + mockSaveBufferToS3.mockResolvedValue('https://d123.cloudfront.net/images/u/f.webp'); + const { saveBufferToCloudFront } = await import('~/storage/cloudfront/crud'); + const result = await saveBufferToCloudFront({ + userId: 'u', + buffer: Buffer.from('data'), + fileName: 'f.webp', + basePath: 'images', + }); + + expect(mockSaveBufferToS3).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'u', + buffer: Buffer.from('data'), + fileName: 'f.webp', + basePath: 'images', + urlBuilder: expect.any(Function), + }), + ); + expect(result).toBe('https://d123.cloudfront.net/images/u/f.webp'); + }); + + it('passes sign=false to urlBuilder by default', async () => { + let capturedUrlBuilder: ((p: object) => Promise) | null = null; + mockSaveBufferToS3.mockImplementation( + ({ urlBuilder }: { urlBuilder: (p: object) => Promise }) => { + capturedUrlBuilder = urlBuilder; + return Promise.resolve('https://d123.cloudfront.net/images/u/f.webp'); + }, + ); + + const { saveBufferToCloudFront } = await import('~/storage/cloudfront/crud'); + await saveBufferToCloudFront({ userId: 'u', buffer: Buffer.from('x'), fileName: 'f.webp' }); + + // urlBuilder invoked without sign should not call getSignedUrl + await capturedUrlBuilder!({ userId: 'u', fileName: 'f.webp', basePath: 'images' }); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('passes sign=true to urlBuilder when requested', async () => { + mockGetCloudFrontConfig.mockReturnValue(makeConfig({ privateKey: 'pk', keyPairId: 'K1' })); + mockGetSignedUrl.mockReturnValue('https://d123.cloudfront.net/images/u/f.webp?signed'); + + let capturedUrlBuilder: ((p: object) => Promise) | null = null; + mockSaveBufferToS3.mockImplementation( + ({ urlBuilder }: { urlBuilder: (p: object) => Promise }) => { + capturedUrlBuilder = urlBuilder; + return Promise.resolve('https://d123.cloudfront.net/images/u/f.webp?signed'); + }, + ); + + const { saveBufferToCloudFront } = await import('~/storage/cloudfront/crud'); + await saveBufferToCloudFront({ + userId: 'u', + buffer: Buffer.from('x'), + fileName: 'f.webp', + sign: true, + }); + + await capturedUrlBuilder!({ userId: 'u', fileName: 'f.webp', basePath: 'images' }); + expect(mockGetSignedUrl).toHaveBeenCalled(); + }); + }); + + describe('saveURLToCloudFront', () => { + it('delegates to saveURLToS3 with a urlBuilder', async () => { + mockSaveURLToS3.mockResolvedValue('https://d123.cloudfront.net/images/u/f.webp'); + const { saveURLToCloudFront } = await import('~/storage/cloudfront/crud'); + const result = await saveURLToCloudFront({ + userId: 'u', + URL: 'https://external.com/image.jpg', + fileName: 'f.webp', + }); + + expect(mockSaveURLToS3).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'u', + URL: 'https://external.com/image.jpg', + fileName: 'f.webp', + urlBuilder: expect.any(Function), + }), + ); + expect(result).toBe('https://d123.cloudfront.net/images/u/f.webp'); + }); + }); + + describe('uploadFileToCloudFront', () => { + it('delegates to uploadFileToS3 with a urlBuilder', async () => { + const uploadResult = { filepath: 'https://d123.cloudfront.net/images/u/f.pdf', bytes: 1024 }; + mockUploadFileToS3.mockResolvedValue(uploadResult); + + const mockReq = { user: { id: 'u' } } as ServerRequest; + const mockFile = { path: '/tmp/f.pdf' } as Express.Multer.File; + + const { uploadFileToCloudFront } = await import('~/storage/cloudfront/crud'); + const result = await uploadFileToCloudFront({ + req: mockReq, + file: mockFile, + file_id: 'fid-1', + }); + + expect(mockUploadFileToS3).toHaveBeenCalledWith( + expect.objectContaining({ + req: mockReq, + file: mockFile, + file_id: 'fid-1', + urlBuilder: expect.any(Function), + }), + ); + expect(result).toEqual(uploadResult); + }); + }); + + describe('deleteFileFromCloudFront', () => { + const mockReq = { user: { id: 'u' } } as ServerRequest; + const mockFile = { + file_id: 'fid-1', + filepath: 'https://d123.cloudfront.net/images/u/file.webp', + source: 'cloudfront', + } as unknown as TFile; + + beforeEach(() => { + mockDeleteFileFromS3.mockResolvedValue(undefined); + mockExtractKeyFromS3Url.mockReturnValue('images/u/file.webp'); + }); + + it('calls deleteFileFromS3 to remove the file', async () => { + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + expect(mockDeleteFileFromS3).toHaveBeenCalledWith(mockReq, mockFile); + }); + + it('does not create invalidation when invalidateOnDelete is false', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: false, distributionId: 'E123' }), + ); + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + expect(mockCloudFrontSend).not.toHaveBeenCalled(); + }); + + it('does not create invalidation when distributionId is missing', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: true, distributionId: undefined }), + ); + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + expect(mockCloudFrontSend).not.toHaveBeenCalled(); + }); + + it('creates CloudFront invalidation when invalidateOnDelete is true', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: true, distributionId: 'E123ABC' }), + ); + mockCloudFrontSend.mockResolvedValue({}); + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + + expect(mockCloudFrontSend).toHaveBeenCalledTimes(1); + const { CreateInvalidationCommand } = await import('@aws-sdk/client-cloudfront'); + expect(CreateInvalidationCommand).toHaveBeenCalledWith( + expect.objectContaining({ + DistributionId: 'E123ABC', + InvalidationBatch: expect.objectContaining({ + Paths: { Quantity: 1, Items: ['/images/u/file.webp'] }, + }), + }), + ); + }); + + it('prefixes key with / for invalidation path', async () => { + mockExtractKeyFromS3Url.mockReturnValue('images/u/file.webp'); // no leading slash + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: true, distributionId: 'E123' }), + ); + mockCloudFrontSend.mockResolvedValue({}); + + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + + const { CreateInvalidationCommand } = await import('@aws-sdk/client-cloudfront'); + expect(CreateInvalidationCommand).toHaveBeenCalledWith( + expect.objectContaining({ + InvalidationBatch: expect.objectContaining({ + Paths: expect.objectContaining({ Items: ['/images/u/file.webp'] }), + }), + }), + ); + }); + + it('does not re-prefix path that already has leading slash', async () => { + mockExtractKeyFromS3Url.mockReturnValue('/images/u/file.webp'); // already has slash + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: true, distributionId: 'E123' }), + ); + mockCloudFrontSend.mockResolvedValue({}); + + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await deleteFileFromCloudFront(mockReq, mockFile); + + const { CreateInvalidationCommand } = await import('@aws-sdk/client-cloudfront'); + expect(CreateInvalidationCommand).toHaveBeenCalledWith( + expect.objectContaining({ + InvalidationBatch: expect.objectContaining({ + Paths: expect.objectContaining({ Items: ['/images/u/file.webp'] }), + }), + }), + ); + }); + + it('logs error and continues when invalidation fails', async () => { + mockGetCloudFrontConfig.mockReturnValue( + makeConfig({ invalidateOnDelete: true, distributionId: 'E123' }), + ); + mockCloudFrontSend.mockRejectedValue(new Error('Access denied')); + + const { deleteFileFromCloudFront } = await import('~/storage/cloudfront/crud'); + await expect(deleteFileFromCloudFront(mockReq, mockFile)).resolves.toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('[deleteFileFromCloudFront] Cache invalidation failed:'), + 'Access denied', + ); + }); + }); + + describe('getCloudFrontFileStream', () => { + it('delegates to getS3FileStream', async () => { + const readable = new Readable(); + mockGetS3FileStream.mockResolvedValue(readable); + + const mockReq = { user: { id: 'u' } } as ServerRequest; + const { getCloudFrontFileStream } = await import('~/storage/cloudfront/crud'); + const result = await getCloudFrontFileStream( + mockReq, + 'https://d123.cloudfront.net/images/u/f.webp', + ); + + expect(mockGetS3FileStream).toHaveBeenCalledWith( + mockReq, + 'https://d123.cloudfront.net/images/u/f.webp', + ); + expect(result).toBe(readable); + }); + }); +}); diff --git a/packages/api/src/storage/cloudfront/crud.ts b/packages/api/src/storage/cloudfront/crud.ts new file mode 100644 index 0000000000..37e41f5fa4 --- /dev/null +++ b/packages/api/src/storage/cloudfront/crud.ts @@ -0,0 +1,149 @@ +import crypto from 'crypto'; +import { logger } from '@librechat/data-schemas'; +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; +import type { TFile } from 'librechat-data-provider'; +import type { Readable } from 'stream'; +import type { ServerRequest } from '~/types'; +import type { + SaveBufferParams, + GetURLParams, + SaveURLParams, + UploadFileParams, + UploadResult, +} from '~/storage/types'; +import { getCloudFrontConfig } from '~/cdn/cloudfront'; +import { s3Config } from '~/storage/s3/s3Config'; +import { DEFAULT_BASE_PATH as defaultBasePath } from '~/storage/constants'; +import { + getS3Key, + saveBufferToS3, + saveURLToS3, + uploadFileToS3, + deleteFileFromS3, + getS3FileStream, + extractKeyFromS3Url, +} from '~/storage/s3/crud'; + +let _cloudFrontClient: CloudFrontClient | null = null; + +function getOrCreateCloudFrontClient(): CloudFrontClient { + if (!_cloudFrontClient) { + const region = s3Config.AWS_REGION || process.env.AWS_REGION; + if (!region) { + throw new Error('[CloudFront] AWS_REGION is required for cache invalidation'); + } + _cloudFrontClient = new CloudFrontClient({ region }); + } + return _cloudFrontClient; +} + +export interface CloudFrontURLParams extends GetURLParams { + sign?: boolean; +} + +function buildCloudFrontUrl(s3Key: string): string { + const config = getCloudFrontConfig(); + if (!config?.domain) { + throw new Error('[buildCloudFrontUrl] CloudFront not initialized.'); + } + const cleanDomain = config.domain.replace(/\/+$/, ''); + const cleanKey = s3Key.replace(/^\/+/, ''); + return `${cleanDomain}/${cleanKey}`; +} + +function signUrl(url: string): string { + const config = getCloudFrontConfig(); + if (!config?.privateKey || !config?.keyPairId) { + throw new Error('[signUrl] Signing keys not configured.'); + } + + const expiry = config.urlExpiry ?? s3Config.S3_URL_EXPIRY_SECONDS; + const dateLessThan = new Date(Date.now() + expiry * 1000).toISOString(); + + return getSignedUrl({ + url, + keyPairId: config.keyPairId, + privateKey: config.privateKey, + dateLessThan, + }); +} + +/** + * Get CloudFront URL for a file. + * @param sign - If true, returns a signed URL. Caller (strategy) decides based on config. + */ +export async function getCloudFrontURL({ + userId, + fileName, + basePath = defaultBasePath, + sign = false, +}: CloudFrontURLParams): Promise { + const key = getS3Key(basePath, userId, fileName); + const url = buildCloudFrontUrl(key); + return sign ? signUrl(url) : url; +} + +/** Save buffer to S3 and return CloudFront URL. */ +export async function saveBufferToCloudFront( + params: SaveBufferParams & { sign?: boolean }, +): Promise { + const { sign = false, ...rest } = params; + return saveBufferToS3({ ...rest, urlBuilder: (p) => getCloudFrontURL({ ...p, sign }) }); +} + +/** Save file from URL to S3 and return CloudFront URL. */ +export async function saveURLToCloudFront( + params: SaveURLParams & { sign?: boolean }, +): Promise { + const { sign = false, ...rest } = params; + return saveURLToS3({ ...rest, urlBuilder: (p) => getCloudFrontURL({ ...p, sign }) }); +} + +/** Upload file to S3 and return CloudFront URL. */ +export async function uploadFileToCloudFront( + params: UploadFileParams & { sign?: boolean }, +): Promise { + const { sign = false, ...rest } = params; + return uploadFileToS3({ ...rest, urlBuilder: (p) => getCloudFrontURL({ ...p, sign }) }); +} + +/** Delete file from S3 and optionally invalidate CloudFront cache. */ +export async function deleteFileFromCloudFront(req: ServerRequest, file: TFile): Promise { + const config = getCloudFrontConfig(); + + await deleteFileFromS3(req, file); + + if (config?.invalidateOnDelete && config.distributionId) { + try { + const client = getOrCreateCloudFrontClient(); + // CloudFront URL pathname matches S3 key when no origin path prefix is configured + const key = extractKeyFromS3Url(file.filepath); + const path = key.startsWith('/') ? key : `/${key}`; + + await client.send( + new CreateInvalidationCommand({ + DistributionId: config.distributionId, + InvalidationBatch: { + CallerReference: crypto.randomUUID(), + Paths: { Quantity: 1, Items: [path] }, + }, + }), + ); + logger.debug(`[deleteFileFromCloudFront] Invalidation created for: ${path}`); + } catch (error) { + logger.error( + '[deleteFileFromCloudFront] Cache invalidation failed:', + (error as Error).message, + ); + } + } +} + +/** Get file stream from S3 storage. */ +export async function getCloudFrontFileStream( + req: ServerRequest, + filePath: string, +): Promise { + return getS3FileStream(req, filePath); +} diff --git a/packages/api/src/storage/cloudfront/index.ts b/packages/api/src/storage/cloudfront/index.ts new file mode 100644 index 0000000000..f9b6bca108 --- /dev/null +++ b/packages/api/src/storage/cloudfront/index.ts @@ -0,0 +1 @@ +export * from './crud'; diff --git a/packages/api/src/storage/constants.ts b/packages/api/src/storage/constants.ts new file mode 100644 index 0000000000..e6f5330aaf --- /dev/null +++ b/packages/api/src/storage/constants.ts @@ -0,0 +1,2 @@ +/** Default base path for cloud-stored files (used by all storage strategies). */ +export const DEFAULT_BASE_PATH = 'images'; diff --git a/packages/api/src/storage/s3/images.ts b/packages/api/src/storage/images.ts similarity index 56% rename from packages/api/src/storage/s3/images.ts rename to packages/api/src/storage/images.ts index b9d7322359..479763102a 100644 --- a/packages/api/src/storage/s3/images.ts +++ b/packages/api/src/storage/images.ts @@ -5,13 +5,15 @@ import { logger } from '@librechat/data-schemas'; import type { IUser } from '@librechat/data-schemas'; import type { TFile } from 'librechat-data-provider'; import type { FormatEnum } from 'sharp'; -import type { UploadImageParams, ImageUploadResult, ProcessAvatarParams } from '~/storage/types'; -import { saveBufferToS3 } from './crud'; -import { s3Config } from './s3Config'; +import type { + SaveBufferFn, + UploadImageParams, + ImageUploadResult, + ProcessAvatarParams, +} from '~/storage/types'; +import { DEFAULT_BASE_PATH as defaultBasePath } from '~/storage/constants'; -const { DEFAULT_BASE_PATH: defaultBasePath } = s3Config; - -export interface S3ImageServiceDeps { +export interface ImageServiceDeps { resizeImageBuffer: ( buffer: Buffer, resolution: string, @@ -21,14 +23,34 @@ export interface S3ImageServiceDeps { updateFile: (params: { file_id: string }) => Promise; } -export class S3ImageService { - private deps: S3ImageServiceDeps; +export interface ImageServiceConfig { + /** If true, appends ?manual=... to avatar URLs (Firebase/Azure behavior) */ + appendManualParam?: boolean; +} - constructor(deps: S3ImageServiceDeps) { - this.deps = deps; - } +/** + * Unified image service for cloud storage strategies. + * Handles image uploads, URL preparation, and avatar processing + * via an injected `saveBuffer` function, enabling any storage backend + * (S3, CloudFront, Azure, Firebase, etc.) without subclassing. + */ +export class ImageService { + /** + * @param saveBuffer - Strategy-specific function that persists a buffer and returns a download URL. + * @param deps - External dependencies (resize, user/file update callbacks). + * @param config - Optional per-strategy configuration. + */ + constructor( + private saveBuffer: SaveBufferFn, + private deps: ImageServiceDeps, + private config: ImageServiceConfig = {}, + ) {} - async uploadImageToS3({ + /** + * Resizes, converts, and uploads an image file to cloud storage. + * Deletes the local temp file after a successful upload. + */ + async uploadImage({ req, file, file_id, @@ -39,7 +61,7 @@ export class S3ImageService { const inputFilePath = file.path; try { if (!req.user) { - throw new Error('[S3ImageService.uploadImageToS3] User not authenticated'); + throw new Error('[ImageService.uploadImage] User not authenticated'); } const appConfig = req.config; @@ -53,15 +75,16 @@ export class S3ImageService { const extension = path.extname(inputFilePath); const userId = req.user.id; + const outputType = appConfig?.imageOutputType ?? 'webp'; + const targetExtension = `.${outputType}`; let processedBuffer: Buffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - const targetExtension = `.${appConfig?.imageOutputType ?? 'webp'}`; if (extension.toLowerCase() === targetExtension) { processedBuffer = resizedBuffer; } else { - const outputFormat = (appConfig?.imageOutputType ?? 'webp') as keyof FormatEnum; + const outputFormat = outputType as keyof FormatEnum; processedBuffer = await sharp(resizedBuffer).toFormat(outputFormat).toBuffer(); fileName = fileName.replace(new RegExp(path.extname(fileName) + '$'), targetExtension); if (!path.extname(fileName)) { @@ -69,7 +92,7 @@ export class S3ImageService { } } - const downloadURL = await saveBufferToS3({ + const downloadURL = await this.saveBuffer({ userId, buffer: processedBuffer, fileName, @@ -78,17 +101,14 @@ export class S3ImageService { const bytes = processedBuffer.length; return { filepath: downloadURL, bytes, width, height }; } catch (error) { - logger.error( - '[S3ImageService.uploadImageToS3] Error uploading image to S3:', - (error as Error).message, - ); + logger.error('[ImageService.uploadImage] Error uploading image:', (error as Error).message); throw error; } finally { await fs.promises .unlink(inputFilePath) .catch((e: unknown) => logger.error( - '[S3ImageService.uploadImageToS3] Failed to delete temp file:', + '[ImageService.uploadImage] Failed to delete temp file:', (e as Error).message, ), ); @@ -100,13 +120,18 @@ export class S3ImageService { return await Promise.all([this.deps.updateFile({ file_id: file.file_id }), file.filepath]); } catch (error) { logger.error( - '[S3ImageService.prepareImageURL] Error preparing image URL:', + '[ImageService.prepareImageURL] Error preparing image URL:', (error as Error).message, ); throw error; } } + /** + * Processes and uploads an avatar image. + * Detects GIF vs PNG, generates a timestamped filename, and optionally + * persists the URL to the user record when `manual` is `'true'`. + */ async processAvatar({ buffer, userId, @@ -123,16 +148,20 @@ export class S3ImageService { ? `agent-${agentId}-avatar-${timestamp}.${extension}` : `avatar-${timestamp}.${extension}`; - const downloadURL = await saveBufferToS3({ userId, buffer, fileName, basePath }); + const downloadURL = await this.saveBuffer({ userId, buffer, fileName, basePath }); + + const finalURL = this.config.appendManualParam + ? `${downloadURL}?manual=${manual === 'true'}` + : downloadURL; if (manual === 'true' && !agentId) { - await this.deps.updateUser(userId, { avatar: downloadURL }); + await this.deps.updateUser(userId, { avatar: finalURL }); } - return downloadURL; + return finalURL; } catch (error) { logger.error( - '[S3ImageService.processAvatar] Error processing S3 avatar:', + '[ImageService.processAvatar] Error processing avatar:', (error as Error).message, ); throw error; diff --git a/packages/api/src/storage/index.ts b/packages/api/src/storage/index.ts index ebd7bd63a9..0d2be2208a 100644 --- a/packages/api/src/storage/index.ts +++ b/packages/api/src/storage/index.ts @@ -1,2 +1,4 @@ +export * from './cloudfront'; export * from './s3'; export * from './types'; +export * from './images'; diff --git a/packages/api/src/storage/s3/__tests__/images.test.ts b/packages/api/src/storage/s3/__tests__/images.test.ts deleted file mode 100644 index 065c73cebd..0000000000 --- a/packages/api/src/storage/s3/__tests__/images.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import fs from 'fs'; -import type { S3ImageServiceDeps } from '~/storage/s3/images'; -import type { ServerRequest } from '~/types'; -import { S3ImageService } from '~/storage/s3/images'; -import { saveBufferToS3 } from '~/storage/s3/crud'; - -jest.mock('fs', () => ({ - ...jest.requireActual('fs'), - promises: { - readFile: jest.fn(), - unlink: jest.fn().mockResolvedValue(undefined), - }, -})); - -jest.mock('../crud', () => ({ - saveBufferToS3: jest - .fn() - .mockResolvedValue('https://bucket.s3.amazonaws.com/avatar.png?signed=true'), -})); - -const mockSaveBufferToS3 = jest.mocked(saveBufferToS3); - -jest.mock('sharp', () => { - return jest.fn(() => ({ - metadata: jest.fn().mockResolvedValue({ format: 'png', width: 100, height: 100 }), - toFormat: jest.fn().mockReturnThis(), - toBuffer: jest.fn().mockResolvedValue(Buffer.from('processed')), - })); -}); - -describe('S3ImageService', () => { - let service: S3ImageService; - let mockDeps: S3ImageServiceDeps; - - beforeEach(() => { - jest.clearAllMocks(); - - mockDeps = { - resizeImageBuffer: jest.fn().mockResolvedValue({ - buffer: Buffer.from('resized'), - width: 100, - height: 100, - }), - updateUser: jest.fn().mockResolvedValue(undefined), - updateFile: jest.fn().mockResolvedValue(undefined), - }; - - service = new S3ImageService(mockDeps); - }); - - describe('processAvatar', () => { - it('uploads avatar and returns URL', async () => { - const result = await service.processAvatar({ - buffer: Buffer.from('test'), - userId: 'user123', - manual: 'false', - }); - - expect(result).toContain('signed=true'); - }); - - it('updates user avatar when manual is true', async () => { - await service.processAvatar({ - buffer: Buffer.from('test'), - userId: 'user123', - manual: 'true', - }); - - expect(mockDeps.updateUser).toHaveBeenCalledWith( - 'user123', - expect.objectContaining({ avatar: expect.any(String) }), - ); - }); - - it('does not update user when agentId is provided', async () => { - await service.processAvatar({ - buffer: Buffer.from('test'), - userId: 'user123', - manual: 'true', - agentId: 'agent456', - }); - - expect(mockDeps.updateUser).not.toHaveBeenCalled(); - }); - - it('generates agent avatar filename when agentId provided', async () => { - await service.processAvatar({ - buffer: Buffer.from('test'), - userId: 'user123', - manual: 'false', - agentId: 'agent456', - }); - - expect(mockSaveBufferToS3).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: expect.stringContaining('agent-agent456-avatar-'), - }), - ); - }); - }); - - describe('prepareImageURL', () => { - it('returns tuple with resolved promise and filepath', async () => { - const file = { file_id: 'file123', filepath: 'https://example.com/file.png' }; - const result = await service.prepareImageURL(file); - - expect(Array.isArray(result)).toBe(true); - expect(result[1]).toBe('https://example.com/file.png'); - }); - - it('calls updateFile with file_id', async () => { - const file = { file_id: 'file123', filepath: 'https://example.com/file.png' }; - await service.prepareImageURL(file); - - expect(mockDeps.updateFile).toHaveBeenCalledWith({ file_id: 'file123' }); - }); - }); - - describe('constructor', () => { - it('requires dependencies to be passed', () => { - const newService = new S3ImageService(mockDeps); - expect(newService).toBeInstanceOf(S3ImageService); - }); - }); - - describe('uploadImageToS3', () => { - const mockReq = { - user: { id: 'user123' }, - config: { imageOutputType: 'webp' }, - } as unknown as ServerRequest; - - it('deletes temp file on early failure (readFile throws)', async () => { - (fs.promises.readFile as jest.Mock).mockRejectedValueOnce( - new Error('ENOENT: no such file or directory'), - ); - (fs.promises.unlink as jest.Mock).mockResolvedValueOnce(undefined); - - await expect( - service.uploadImageToS3({ - req: mockReq, - file: { path: '/tmp/input.jpg' } as Express.Multer.File, - file_id: 'file123', - endpoint: 'openai', - }), - ).rejects.toThrow('ENOENT: no such file or directory'); - - expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/input.jpg'); - }); - - it('deletes temp file on resize failure (resizeImageBuffer throws)', async () => { - (fs.promises.readFile as jest.Mock).mockResolvedValueOnce(Buffer.from('raw')); - (mockDeps.resizeImageBuffer as jest.Mock).mockRejectedValueOnce(new Error('Resize failed')); - (fs.promises.unlink as jest.Mock).mockResolvedValueOnce(undefined); - - await expect( - service.uploadImageToS3({ - req: mockReq, - file: { path: '/tmp/input.jpg' } as Express.Multer.File, - file_id: 'file123', - endpoint: 'openai', - }), - ).rejects.toThrow('Resize failed'); - - expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/input.jpg'); - }); - - it('deletes temp file on success', async () => { - (fs.promises.readFile as jest.Mock).mockResolvedValueOnce(Buffer.from('raw')); - (fs.promises.unlink as jest.Mock).mockResolvedValueOnce(undefined); - - const result = await service.uploadImageToS3({ - req: mockReq, - file: { path: '/tmp/input.webp' } as Express.Multer.File, - file_id: 'file123', - endpoint: 'openai', - }); - - expect(result.filepath).toContain('signed=true'); - expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/input.webp'); - }); - }); -}); diff --git a/packages/api/src/storage/s3/__tests__/s3.integration.spec.ts b/packages/api/src/storage/s3/__tests__/s3.integration.spec.ts index de80e7409b..5dc1e3b754 100644 --- a/packages/api/src/storage/s3/__tests__/s3.integration.spec.ts +++ b/packages/api/src/storage/s3/__tests__/s3.integration.spec.ts @@ -108,13 +108,13 @@ describe('S3 Integration Tests', () => { describe('getS3Key', () => { it('constructs key from basePath, userId, and fileName', async () => { - const { getS3Key } = await import('../crud'); + const { getS3Key } = await import('~/storage/s3/crud'); const key = getS3Key(TEST_BASE_PATH, TEST_USER_ID, 'test-file.txt'); expect(key).toBe(`${TEST_BASE_PATH}/${TEST_USER_ID}/test-file.txt`); }); it('handles nested file names', async () => { - const { getS3Key } = await import('../crud'); + const { getS3Key } = await import('~/storage/s3/crud'); const key = getS3Key(TEST_BASE_PATH, TEST_USER_ID, 'folder/nested/file.pdf'); expect(key).toBe(`${TEST_BASE_PATH}/${TEST_USER_ID}/folder/nested/file.pdf`); }); @@ -122,7 +122,7 @@ describe('S3 Integration Tests', () => { describe('saveBufferToS3 and getS3URL', () => { it('uploads buffer and returns signed URL', async () => { - const { saveBufferToS3 } = await import('../crud'); + const { saveBufferToS3 } = await import('~/storage/s3/crud'); const testContent = 'Hello, S3!'; const buffer = Buffer.from(testContent); const fileName = `test-${Date.now()}.txt`; @@ -140,7 +140,7 @@ describe('S3 Integration Tests', () => { }); it('can get signed URL for existing file', async () => { - const { saveBufferToS3, getS3URL } = await import('../crud'); + const { saveBufferToS3, getS3URL } = await import('~/storage/s3/crud'); const buffer = Buffer.from('test content for URL'); const fileName = `url-test-${Date.now()}.txt`; @@ -162,7 +162,7 @@ describe('S3 Integration Tests', () => { }); it('can get signed URL with custom filename and content type', async () => { - const { saveBufferToS3, getS3URL } = await import('../crud'); + const { saveBufferToS3, getS3URL } = await import('~/storage/s3/crud'); const buffer = Buffer.from('custom headers test'); const fileName = `headers-test-${Date.now()}.txt`; @@ -188,7 +188,7 @@ describe('S3 Integration Tests', () => { describe('saveURLToS3', () => { it('fetches URL content and uploads to S3', async () => { - const { saveURLToS3 } = await import('../crud'); + const { saveURLToS3 } = await import('~/storage/s3/crud'); const fileName = `url-upload-${Date.now()}.json`; const downloadURL = await saveURLToS3({ @@ -205,7 +205,7 @@ describe('S3 Integration Tests', () => { describe('extractKeyFromS3Url', () => { it('extracts key from signed URL', async () => { - const { saveBufferToS3, extractKeyFromS3Url } = await import('../crud'); + const { saveBufferToS3, extractKeyFromS3Url } = await import('~/storage/s3/crud'); const buffer = Buffer.from('extract key test'); const fileName = `extract-key-${Date.now()}.txt`; @@ -221,7 +221,7 @@ describe('S3 Integration Tests', () => { }); it('returns key as-is when not a URL', async () => { - const { extractKeyFromS3Url } = await import('../crud'); + const { extractKeyFromS3Url } = await import('~/storage/s3/crud'); const key = `${TEST_BASE_PATH}/${TEST_USER_ID}/file.txt`; expect(extractKeyFromS3Url(key)).toBe(key); }); @@ -229,7 +229,7 @@ describe('S3 Integration Tests', () => { describe('uploadFileToS3', () => { it('uploads file and returns filepath with bytes', async () => { - const { uploadFileToS3 } = await import('../crud'); + const { uploadFileToS3 } = await import('~/storage/s3/crud'); const testContent = 'File upload test content'; const testFilePath = path.join(tempDir, 'upload-test.txt'); fs.writeFileSync(testFilePath, testContent); @@ -266,7 +266,7 @@ describe('S3 Integration Tests', () => { }); it('throws error when user is not authenticated', async () => { - const { uploadFileToS3 } = await import('../crud'); + const { uploadFileToS3 } = await import('~/storage/s3/crud'); const mockReq = {} as ServerRequest; const mockFile = { path: '/fake/path.txt', @@ -286,7 +286,7 @@ describe('S3 Integration Tests', () => { describe('getS3FileStream', () => { it('returns readable stream for existing file', async () => { - const { saveBufferToS3, getS3FileStream } = await import('../crud'); + const { saveBufferToS3, getS3FileStream } = await import('~/storage/s3/crud'); const testContent = 'Stream test content'; const buffer = Buffer.from(testContent); const fileName = `stream-test-${Date.now()}.txt`; @@ -317,12 +317,12 @@ describe('S3 Integration Tests', () => { describe('needsRefresh', () => { it('returns false for non-signed URLs', async () => { - const { needsRefresh } = await import('../crud'); + const { needsRefresh } = await import('~/storage/s3/crud'); expect(needsRefresh('https://example.com/file.png', 3600)).toBe(false); }); it('returns true for expired signed URLs', async () => { - const { saveBufferToS3, needsRefresh } = await import('../crud'); + const { saveBufferToS3, needsRefresh } = await import('~/storage/s3/crud'); const buffer = Buffer.from('refresh test'); const fileName = `refresh-test-${Date.now()}.txt`; @@ -338,7 +338,7 @@ describe('S3 Integration Tests', () => { }); it('returns false for fresh signed URLs', async () => { - const { saveBufferToS3, needsRefresh } = await import('../crud'); + const { saveBufferToS3, needsRefresh } = await import('~/storage/s3/crud'); const buffer = Buffer.from('fresh test'); const fileName = `fresh-test-${Date.now()}.txt`; @@ -356,7 +356,7 @@ describe('S3 Integration Tests', () => { describe('getNewS3URL', () => { it('generates signed URL from existing URL', async () => { - const { saveBufferToS3, getNewS3URL } = await import('../crud'); + const { saveBufferToS3, getNewS3URL } = await import('~/storage/s3/crud'); const buffer = Buffer.from('new url test'); const fileName = `new-url-${Date.now()}.txt`; @@ -377,7 +377,7 @@ describe('S3 Integration Tests', () => { describe('refreshS3Url', () => { it('returns original URL for non-S3 source', async () => { - const { refreshS3Url } = await import('../crud'); + const { refreshS3Url } = await import('~/storage/s3/crud'); const fileObj = { filepath: 'https://example.com/file.png', source: 'local', @@ -388,7 +388,7 @@ describe('S3 Integration Tests', () => { }); it('refreshes URL for S3 source when needed', async () => { - const { saveBufferToS3, refreshS3Url } = await import('../crud'); + const { saveBufferToS3, refreshS3Url } = await import('~/storage/s3/crud'); const buffer = Buffer.from('s3 refresh test'); const fileName = `s3-refresh-${Date.now()}.txt`; @@ -411,9 +411,10 @@ describe('S3 Integration Tests', () => { }); }); - describe('S3ImageService', () => { + describe('ImageService (S3 strategy)', () => { it('uploads avatar and returns URL', async () => { - const { S3ImageService } = await import('../images'); + const { ImageService } = await import('~/storage/images'); + const { saveBufferToS3 } = await import('~/storage/s3/crud'); const mockDeps = { resizeImageBuffer: jest.fn().mockImplementation(async (buffer: Buffer) => ({ @@ -422,10 +423,10 @@ describe('S3 Integration Tests', () => { height: 100, })), updateUser: jest.fn().mockResolvedValue(undefined), - updateFile: jest.fn().mockResolvedValue(undefined), + updateFile: jest.fn().mockResolvedValue(null), }; - const imageService = new S3ImageService(mockDeps); + const imageService = new ImageService(saveBufferToS3, mockDeps); const pngBuffer = MINIMAL_PNG; @@ -442,7 +443,8 @@ describe('S3 Integration Tests', () => { }); it('updates user when manual is true', async () => { - const { S3ImageService } = await import('../images'); + const { ImageService } = await import('~/storage/images'); + const { saveBufferToS3 } = await import('~/storage/s3/crud'); const mockDeps = { resizeImageBuffer: jest.fn().mockImplementation(async (buffer: Buffer) => ({ @@ -451,10 +453,10 @@ describe('S3 Integration Tests', () => { height: 100, })), updateUser: jest.fn().mockResolvedValue(undefined), - updateFile: jest.fn().mockResolvedValue(undefined), + updateFile: jest.fn().mockResolvedValue(null), }; - const imageService = new S3ImageService(mockDeps); + const imageService = new ImageService(saveBufferToS3, mockDeps); const pngBuffer = MINIMAL_PNG; @@ -472,7 +474,8 @@ describe('S3 Integration Tests', () => { }); it('does not update user when agentId is provided', async () => { - const { S3ImageService } = await import('../images'); + const { ImageService } = await import('~/storage/images'); + const { saveBufferToS3 } = await import('~/storage/s3/crud'); const mockDeps = { resizeImageBuffer: jest.fn().mockImplementation(async (buffer: Buffer) => ({ @@ -481,10 +484,10 @@ describe('S3 Integration Tests', () => { height: 100, })), updateUser: jest.fn().mockResolvedValue(undefined), - updateFile: jest.fn().mockResolvedValue(undefined), + updateFile: jest.fn().mockResolvedValue(null), }; - const imageService = new S3ImageService(mockDeps); + const imageService = new ImageService(saveBufferToS3, mockDeps); const pngBuffer = MINIMAL_PNG; @@ -500,7 +503,8 @@ describe('S3 Integration Tests', () => { }); it('returns tuple with resolved promise and filepath in prepareImageURL', async () => { - const { S3ImageService } = await import('../images'); + const { ImageService } = await import('~/storage/images'); + const { saveBufferToS3 } = await import('~/storage/s3/crud'); const mockDeps = { resizeImageBuffer: jest.fn().mockImplementation(async (buffer: Buffer) => ({ @@ -509,10 +513,10 @@ describe('S3 Integration Tests', () => { height: 100, })), updateUser: jest.fn().mockResolvedValue(undefined), - updateFile: jest.fn().mockResolvedValue(undefined), + updateFile: jest.fn().mockResolvedValue(null), }; - const imageService = new S3ImageService(mockDeps); + const imageService = new ImageService(saveBufferToS3, mockDeps); const testFile = { file_id: 'file-123', diff --git a/packages/api/src/storage/s3/crud.ts b/packages/api/src/storage/s3/crud.ts index 1143a7ed7f..1ea6d32dd0 100644 --- a/packages/api/src/storage/s3/crud.ts +++ b/packages/api/src/storage/s3/crud.ts @@ -1,16 +1,16 @@ import fs from 'fs'; -import { Readable } from 'stream'; -import { logger } from '@librechat/data-schemas'; -import { FileSources } from 'librechat-data-provider'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3'; +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { GetObjectCommandInput } from '@aws-sdk/client-s3'; import type { TFile } from 'librechat-data-provider'; +import type { Readable } from 'stream'; import type { ServerRequest } from '~/types'; import type { UploadFileParams, @@ -19,6 +19,7 @@ import type { SaveURLParams, GetURLParams, UploadResult, + UrlBuilder, S3FileRef, } from '~/storage/types'; import { initializeS3 } from '~/cdn/s3'; @@ -77,7 +78,8 @@ export async function saveBufferToS3({ buffer, fileName, basePath = defaultBasePath, -}: SaveBufferParams): Promise { + urlBuilder, +}: SaveBufferParams & { urlBuilder?: UrlBuilder }): Promise { const key = getS3Key(basePath, userId, fileName); const params = { Bucket: bucketName, Key: key, Body: buffer }; @@ -88,7 +90,8 @@ export async function saveBufferToS3({ } await s3.send(new PutObjectCommand(params)); - return await getS3URL({ userId, fileName, basePath }); + const getUrl = urlBuilder ?? getS3URL; + return await getUrl({ userId, fileName, basePath }); } catch (error) { logger.error('[saveBufferToS3] Error uploading buffer to S3:', (error as Error).message); throw error; @@ -100,7 +103,8 @@ export async function saveURLToS3({ URL, fileName, basePath = defaultBasePath, -}: SaveURLParams): Promise { + urlBuilder, +}: SaveURLParams & { urlBuilder?: UrlBuilder }): Promise { try { const response = await fetch(URL); if (!response.ok) { @@ -108,7 +112,7 @@ export async function saveURLToS3({ } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - return await saveBufferToS3({ userId, buffer, fileName, basePath }); + return await saveBufferToS3({ userId, buffer, fileName, basePath, urlBuilder }); } catch (error) { logger.error('[saveURLToS3] Error uploading file from URL to S3:', (error as Error).message); throw error; @@ -247,7 +251,8 @@ export async function uploadFileToS3({ file, file_id, basePath = defaultBasePath, -}: UploadFileParams): Promise { + urlBuilder, +}: UploadFileParams & { urlBuilder?: UrlBuilder }): Promise { if (!req.user) { throw new Error('[uploadFileToS3] User not authenticated'); } @@ -274,7 +279,8 @@ export async function uploadFileToS3({ }; await s3.send(new PutObjectCommand(uploadParams)); - const fileURL = await getS3URL({ userId, fileName, basePath }); + const getUrl = urlBuilder ?? getS3URL; + const fileURL = await getUrl({ userId, fileName, basePath }); // NOTE: temp file is intentionally NOT deleted on the success path. // The caller (processAgentFileUpload) reads file.path after this returns // to stream the file to the RAG vector embedding service (POST /embed). diff --git a/packages/api/src/storage/s3/index.ts b/packages/api/src/storage/s3/index.ts index e700610bba..f9b6bca108 100644 --- a/packages/api/src/storage/s3/index.ts +++ b/packages/api/src/storage/s3/index.ts @@ -1,2 +1 @@ export * from './crud'; -export * from './images'; diff --git a/packages/api/src/storage/s3/s3Config.ts b/packages/api/src/storage/s3/s3Config.ts index 766c0cf66e..9f7a0ce5eb 100644 --- a/packages/api/src/storage/s3/s3Config.ts +++ b/packages/api/src/storage/s3/s3Config.ts @@ -1,9 +1,9 @@ import { logger } from '@librechat/data-schemas'; import { isEnabled } from '~/utils/common'; +import { DEFAULT_BASE_PATH } from '~/storage/constants'; const MAX_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 days const DEFAULT_EXPIRY_SECONDS = 2 * 60; // 2 minutes -const DEFAULT_BASE_PATH = 'images'; const parseUrlExpiry = (): number => { if (process.env.S3_URL_EXPIRY_SECONDS === undefined) { diff --git a/packages/api/src/storage/types.ts b/packages/api/src/storage/types.ts index 314719f38a..2a5380dec2 100644 --- a/packages/api/src/storage/types.ts +++ b/packages/api/src/storage/types.ts @@ -57,4 +57,8 @@ export interface S3FileRef { source: string; } +export type SaveBufferFn = (params: SaveBufferParams) => Promise; + export type BatchUpdateFn = (files: Array<{ file_id: string; filepath: string }>) => Promise; + +export type UrlBuilder = (params: GetURLParams) => Promise; diff --git a/packages/data-provider/src/cloudfront-config.spec.ts b/packages/data-provider/src/cloudfront-config.spec.ts new file mode 100644 index 0000000000..4d7766c368 --- /dev/null +++ b/packages/data-provider/src/cloudfront-config.spec.ts @@ -0,0 +1,77 @@ +import { cloudfrontConfigSchema } from './config'; + +describe('cloudfrontConfigSchema cookieDomain validation', () => { + it('accepts cookieDomain starting with dot', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + cookieDomain: '.example.com', + }); + expect(result.success).toBe(true); + }); + + it('rejects cookieDomain without leading dot', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + cookieDomain: 'example.com', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('must start with a dot'); + } + }); + + it('allows omitting cookieDomain', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + }); + expect(result.success).toBe(true); + }); +}); + +describe('cloudfrontConfigSchema cross-field refinements', () => { + it('rejects invalidateOnDelete=true without distributionId', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + invalidateOnDelete: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'distributionId is required when invalidateOnDelete is true', + ); + expect(result.error.issues[0].path).toEqual(['distributionId']); + } + }); + + it('accepts invalidateOnDelete=true with distributionId', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + invalidateOnDelete: true, + distributionId: 'E1ABCDEFGHIJK', + }); + expect(result.success).toBe(true); + }); + + it('rejects imageSigning="cookies" without cookieDomain', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + 'cookieDomain is required when imageSigning is "cookies"', + ); + expect(result.error.issues[0].path).toEqual(['cookieDomain']); + } + }); + + it('accepts imageSigning="cookies" with cookieDomain', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ca6ec7cdae..d8fc071c6f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -168,6 +168,7 @@ const FILE_STORAGE_BACKENDS = [ FileSources.firebase, FileSources.s3, FileSources.azure_blob, + FileSources.cloudfront, ] as const satisfies ReadonlyArray; export const fileStorageSchema = z.enum(FILE_STORAGE_BACKENDS); @@ -184,6 +185,37 @@ export const fileStrategiesSchema = z }) .optional(); +const cloudfrontSigningSchema = z.enum(['none', 'cookies', 'url']); + +export const cloudfrontConfigSchema = z + .object({ + domain: z.string().url(), + distributionId: z.string().optional(), + invalidateOnDelete: z.boolean().default(false), + imageSigning: cloudfrontSigningSchema.default('none'), + urlExpiry: z.number().positive().default(3600), + cookieExpiry: z.number().positive().max(604800).default(1800), + cookieDomain: z + .string() + .min(1) + .refine((d) => d.startsWith('.'), { + message: 'cookieDomain must start with a dot (e.g., ".example.com") to apply to subdomains', + }) + .optional(), + }) + .refine((data) => !data.invalidateOnDelete || !!data.distributionId, { + message: 'distributionId is required when invalidateOnDelete is true', + path: ['distributionId'], + }) + .refine((data) => data.imageSigning !== 'cookies' || !!data.cookieDomain, { + message: + 'cookieDomain is required when imageSigning is "cookies" (e.g., ".example.com" for API at api.example.com and CDN at cdn.example.com)', + path: ['cookieDomain'], + }) + .optional(); + +export type CloudFrontConfig = z.infer; + // Helper type to extract the shape of the Zod object schema type SchemaShape = T extends z.ZodObject ? U : never; @@ -1296,6 +1328,7 @@ export const configSchema = z.object({ turnstile: turnstileSchema.optional(), fileStrategy: fileStorageSchema.default(FileSources.local), fileStrategies: fileStrategiesSchema, + cloudfront: cloudfrontConfigSchema, actions: z .object({ allowedDomains: z.array(z.string()).optional(), diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index 2215e7c5b6..8168136685 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -7,6 +7,7 @@ export enum FileSources { azure_blob = 'azure_blob', openai = 'openai', s3 = 's3', + cloudfront = 'cloudfront', vectordb = 'vectordb', execute_code = 'execute_code', mistral_ocr = 'mistral_ocr', diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index 57a5e603ac..19fec9f5a1 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -89,7 +89,8 @@ export const AppService = async (params?: { | FileSources.local | FileSources.s3 | FileSources.firebase - | FileSources.azure_blob; + | FileSources.azure_blob + | FileSources.cloudfront; const startBalance = process.env.START_BALANCE; const balance = config.balance ?? { enabled: process.env.CHECK_BALANCE?.toLowerCase().trim() === 'true', @@ -132,6 +133,7 @@ export const AppService = async (params?: { turnstileConfig, mcpConfig: mcpServersConfig, fileStrategies: config.fileStrategies, + cloudfront: config.cloudfront as AppConfig['cloudfront'], }; const agentsDefaults = agentsConfigSetup(config); diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index 3b626694b4..53e4496f07 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -8,6 +8,7 @@ import type { EModelEndpoint, TVertexAIConfig, TAgentsEndpoint, + CloudFrontConfig, TCustomEndpoints, TAssistantEndpoint, TAnthropicEndpoint, @@ -61,10 +62,12 @@ export interface AppConfig { summarization?: SummarizationConfig; /** Web search configuration */ webSearch?: TCustomConfig['webSearch']; - /** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */ + /** File storage strategy ('local', 's3', 'firebase', 'azure_blob', 'cloudfront') */ fileStrategy: FileStorage; /** File strategies configuration */ fileStrategies?: TCustomConfig['fileStrategies']; + /** CloudFront CDN configuration */ + cloudfront?: CloudFrontConfig; /** Registration configurations */ registration?: TCustomConfig['registration']; /** Actions configurations */