diff --git a/api/server/routes/config.js b/api/server/routes/config.js index c36f4a9b8a9..1066cd378a6 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -75,6 +75,7 @@ router.get('/', async function (req, res) { process.env.SHOW_BIRTHDAY_ICON === '', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', interface: req.app.locals.interfaceConfig, + turnstile: req.app.locals.turnstileConfig, modelSpecs: req.app.locals.modelSpecs, sharedLinksEnabled, publicSharedLinksEnabled, diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index d194d31a6bc..4f544342f25 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -5,6 +5,7 @@ const { initializeFirebase } = require('./Files/Firebase/initialize'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); const { loadDefaultInterface } = require('./start/interface'); +const { loadTurnstileConfig } = require('./start/turnstile'); const { azureConfigSetup } = require('./start/azureOpenAI'); const { processModelSpecs } = require('./start/modelSpecs'); const { loadAndFormatTools } = require('./ToolService'); @@ -14,14 +15,13 @@ const { getMCPManager } = require('~/config'); const paths = require('~/config/paths'); /** - * * Loads custom config and initializes app-wide variables. * @function AppService * @param {Express.Application} app - The Express application object. */ const AppService = async (app) => { await initializeRoles(); - /** @type {TCustomConfig}*/ + /** @type {TCustomConfig} */ const config = (await loadCustomConfig()) ?? {}; const configDefaults = getConfigDefaults(); @@ -39,7 +39,7 @@ const AppService = async (app) => { initializeFirebase(); } - /** @type {Record} */ const availableTools = loadAndFormatTools({ adminFilter: filteredTools, adminIncluded: includedTools, @@ -55,6 +55,7 @@ const AppService = async (app) => { const socialLogins = config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; const interfaceConfig = await loadDefaultInterface(config, configDefaults); + const turnstileConfig = loadTurnstileConfig(config, configDefaults); const defaultLocals = { paths, @@ -65,6 +66,7 @@ const AppService = async (app) => { availableTools, imageOutputType, interfaceConfig, + turnstileConfig, }; if (!Object.keys(config).length) { diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 61ac80fc6c8..dca7bf347f2 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -6,8 +6,9 @@ const { validateAzureGroups, deprecatedAzureVariables, conflictingAzureVariables, + getConfigDefaults, + removeNullishValues, } = require('librechat-data-provider'); - const AppService = require('./AppService'); jest.mock('./Config/loadCustomConfig', () => { @@ -18,12 +19,27 @@ jest.mock('./Config/loadCustomConfig', () => { }), ); }); +jest.mock('./start/checks', () => ({ + checkVariables: jest.fn(), + checkHealth: jest.fn().mockResolvedValue(), + checkConfig: jest.fn(), + checkAzureVariables: jest.fn(), +})); +jest.mock('./start/assistants', () => ({ + azureAssistantsDefaults: jest.fn(() => ({ assistantsDefault: true })), + assistantsConfigSetup: jest.fn((config, endpoint, prev) => ({ + disableBuilder: config.endpoints[endpoint]?.disableBuilder || false, + pollIntervalMs: config.endpoints[endpoint]?.pollIntervalMs, + timeoutMs: config.endpoints[endpoint]?.timeoutMs, + supportedIds: config.endpoints[endpoint]?.supportedIds, + privateAssistants: config.endpoints[endpoint]?.privateAssistants, + })), +})); jest.mock('./Files/Firebase/initialize', () => ({ initializeFirebase: jest.fn(), })); jest.mock('~/models/Role', () => ({ initializeRoles: jest.fn(), - updateAccessPermissions: jest.fn(), })); jest.mock('./ToolService', () => ({ loadAndFormatTools: jest.fn().mockReturnValue({ @@ -43,6 +59,43 @@ jest.mock('./ToolService', () => ({ }, }), })); +jest.mock('./start/interface', () => ({ + loadDefaultInterface: jest.fn(() => ({ + endpointsMenu: true, + modelSelect: true, + parameters: true, + sidePanel: true, + presets: true, + })), +})); +jest.mock('./start/turnstile', () => ({ + loadTurnstileConfig: jest.fn(() => ({ + siteKey: 'default-site-key', + options: {}, + })), +})); +jest.mock('./start/azureOpenAI', () => ({ + azureConfigSetup: jest.fn(() => ({ azure: true })), +})); +jest.mock('./start/modelSpecs', () => ({ + processModelSpecs: jest.fn(() => undefined), +})); +jest.mock('./start/agents', () => ({ + agentsConfigSetup: jest.fn(() => ({ agent: true })), +})); +jest.mock('~/config', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + }, + getMCPManager: jest.fn(() => ({ + initializeMCP: jest.fn(), + mapAvailableTools: jest.fn(), + })), +})); +jest.mock('~/config/paths', () => ({ + structuredTools: '/some/path', +})); const azureGroups = [ { @@ -83,10 +136,21 @@ const azureGroups = [ describe('AppService', () => { let app; + const mockedInterfaceConfig = { + endpointsMenu: true, + modelSelect: true, + parameters: true, + sidePanel: true, + presets: true, + }; + const mockedTurnstileConfig = { + siteKey: 'default-site-key', + options: {}, + }; beforeEach(() => { app = { locals: {} }; - process.env.CDN_PROVIDER = undefined; + process.env = {}; // reset env }); it('should correctly assign process.env and app.locals based on custom config', async () => { @@ -95,16 +159,11 @@ describe('AppService', () => { expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); expect(app.locals).toEqual({ - socialLogins: ['testLogin'], + paths: expect.any(Object), fileStrategy: 'testStrategy', - interfaceConfig: expect.objectContaining({ - endpointsMenu: true, - modelSelect: true, - parameters: true, - sidePanel: true, - presets: true, - }), - modelSpecs: undefined, + socialLogins: ['testLogin'], + filteredTools: undefined, + includedTools: undefined, availableTools: { ExampleTool: { type: 'function', @@ -114,35 +173,23 @@ describe('AppService', () => { parameters: expect.objectContaining({ type: 'object', properties: expect.any(Object), - required: expect.arrayContaining(['param1']), + required: ['param1'], }), }), }, }, - paths: expect.anything(), - imageOutputType: expect.any(String), + imageOutputType: 'png', + interfaceConfig: mockedInterfaceConfig, + turnstileConfig: mockedTurnstileConfig, fileConfig: undefined, secureImageLinks: undefined, + modelSpecs: undefined, }); }); - it('should log a warning if the config version is outdated', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve({ - version: '0.9.0', // An outdated version for this test - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - }), - ); - - await AppService(app); - - const { logger } = require('~/config'); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); - }); - it('should change the `imageOutputType` based on config value', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ version: '0.10.0', imageOutputType: EImageOutputType.WEBP, @@ -153,26 +200,29 @@ describe('AppService', () => { expect(app.locals.imageOutputType).toEqual(EImageOutputType.WEBP); }); - it('should default to `PNG` `imageOutputType` with no provided type', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + it('should default to `png` `imageOutputType` with no provided type', async () => { + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ version: '0.10.0', }), ); await AppService(app); - expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + expect(app.locals.imageOutputType).toEqual('png'); }); - it('should default to `PNG` `imageOutputType` with no provided config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(undefined)); + it('should default to `png` `imageOutputType` with no provided config', async () => { + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); await AppService(app); - expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + expect(app.locals.imageOutputType).toEqual('png'); }); it('should initialize Firebase when fileStrategy is firebase', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ fileStrategy: FileSources.firebase, }), @@ -191,7 +241,9 @@ describe('AppService', () => { await AppService(app); expect(loadAndFormatTools).toHaveBeenCalledWith({ - directory: expect.anything(), + adminFilter: undefined, + adminIncluded: undefined, + directory: '/some/path', }); expect(app.locals.availableTools.ExampleTool).toBeDefined(); @@ -212,7 +264,8 @@ describe('AppService', () => { }); it('should correctly configure Assistants endpoint based on custom config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.assistants]: { @@ -229,20 +282,19 @@ describe('AppService', () => { await AppService(app); expect(app.locals).toHaveProperty(EModelEndpoint.assistants); - expect(app.locals[EModelEndpoint.assistants]).toEqual( - expect.objectContaining({ - disableBuilder: true, - pollIntervalMs: 5000, - timeoutMs: 30000, - supportedIds: expect.arrayContaining(['id1', 'id2']), - privateAssistants: false, - }), - ); + expect(app.locals[EModelEndpoint.assistants]).toEqual({ + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: ['id1', 'id2'], + privateAssistants: false, + }); }); it('should correctly configure minimum Azure OpenAI Assistant values', async () => { const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; - require('./Config/loadCustomConfig').mockImplementationOnce(() => + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -258,11 +310,13 @@ describe('AppService', () => { await AppService(app); expect(app.locals).toHaveProperty(EModelEndpoint.azureAssistants); - expect(app.locals[EModelEndpoint.azureAssistants].capabilities.length).toEqual(3); + // Expecting the azureAssistantsDefaults mock to have been used + expect(app.locals[EModelEndpoint.azureAssistants]).toEqual({ assistantsDefault: true }); }); it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({ endpoints: { [EModelEndpoint.azureOpenAI]: { @@ -279,18 +333,10 @@ describe('AppService', () => { expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI); const azureConfig = app.locals[EModelEndpoint.azureOpenAI]; - expect(azureConfig).toHaveProperty('modelNames'); - expect(azureConfig).toHaveProperty('modelGroupMap'); - expect(azureConfig).toHaveProperty('groupMap'); - - const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); - expect(azureConfig.modelNames).toEqual(modelNames); - expect(azureConfig.modelGroupMap).toEqual(modelGroupMap); - expect(azureConfig.groupMap).toEqual(groupMap); + expect(azureConfig).toEqual({ azure: true }); }); it('should not modify FILE_UPLOAD environment variables without rate limits', async () => { - // Setup initial environment variables process.env.FILE_UPLOAD_IP_MAX = '10'; process.env.FILE_UPLOAD_IP_WINDOW = '15'; process.env.FILE_UPLOAD_USER_MAX = '5'; @@ -300,7 +346,6 @@ describe('AppService', () => { await AppService(app); - // Expect environment variables to remain unchanged expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual(initialEnv.FILE_UPLOAD_IP_WINDOW); expect(process.env.FILE_UPLOAD_USER_MAX).toEqual(initialEnv.FILE_UPLOAD_USER_MAX); @@ -308,7 +353,6 @@ describe('AppService', () => { }); it('should correctly set FILE_UPLOAD environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits const rateLimitsConfig = { rateLimits: { fileUploads: { @@ -320,13 +364,11 @@ describe('AppService', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(rateLimitsConfig), - ); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig)); await AppService(app); - // Verify that process.env has been updated according to the rate limits config expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); @@ -334,18 +376,16 @@ describe('AppService', () => { }); it('should fallback to default FILE_UPLOAD environment variables when rate limits are unspecified', async () => { - // Setup initial environment variables to non-default values process.env.FILE_UPLOAD_IP_MAX = 'initialMax'; process.env.FILE_UPLOAD_IP_WINDOW = 'initialWindow'; process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; - // Mock a custom configuration without specific rate limits - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({})); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); await AppService(app); - // Verify that process.env falls back to the initial values expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('initialWindow'); expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('initialUserMax'); @@ -353,7 +393,6 @@ describe('AppService', () => { }); it('should not modify IMPORT environment variables without rate limits', async () => { - // Setup initial environment variables process.env.IMPORT_IP_MAX = '10'; process.env.IMPORT_IP_WINDOW = '15'; process.env.IMPORT_USER_MAX = '5'; @@ -363,7 +402,6 @@ describe('AppService', () => { await AppService(app); - // Expect environment variables to remain unchanged expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW); expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX); @@ -371,7 +409,6 @@ describe('AppService', () => { }); it('should correctly set IMPORT environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits const importLimitsConfig = { rateLimits: { conversationsImport: { @@ -383,13 +420,11 @@ describe('AppService', () => { }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(importLimitsConfig), - ); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig)); await AppService(app); - // Verify that process.env has been updated according to the rate limits config expect(process.env.IMPORT_IP_MAX).toEqual('150'); expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); expect(process.env.IMPORT_USER_MAX).toEqual('50'); @@ -397,18 +432,16 @@ describe('AppService', () => { }); it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => { - // Setup initial environment variables to non-default values process.env.IMPORT_IP_MAX = 'initialMax'; process.env.IMPORT_IP_WINDOW = 'initialWindow'; process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; - // Mock a custom configuration without specific rate limits - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({})); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); await AppService(app); - // Verify that process.env falls back to the initial values expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); expect(process.env.IMPORT_IP_WINDOW).toEqual('initialWindow'); expect(process.env.IMPORT_USER_MAX).toEqual('initialUserMax'); @@ -421,40 +454,35 @@ describe('AppService updating app.locals and issuing warnings', () => { let initialEnv; beforeEach(() => { - // Store initial environment variables to restore them after each test initialEnv = { ...process.env }; - app = { locals: {} }; process.env.CDN_PROVIDER = undefined; }); afterEach(() => { - // Restore initial environment variables process.env = { ...initialEnv }; }); it('should update app.locals with default values if loadCustomConfig returns undefined', async () => { - // Mock loadCustomConfig to return undefined - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(undefined)); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); await AppService(app); expect(app.locals).toBeDefined(); expect(app.locals.paths).toBeDefined(); expect(app.locals.availableTools).toBeDefined(); - expect(app.locals.fileStrategy).toEqual(FileSources.local); + expect(app.locals.fileStrategy).toEqual('local'); expect(app.locals.socialLogins).toEqual(defaultSocialLogins); }); it('should update app.locals with values from loadCustomConfig', async () => { - // Mock loadCustomConfig to return a specific config object const customConfig = { fileStrategy: 'firebase', registration: { socialLogins: ['testLogin'] }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve(customConfig), - ); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig)); await AppService(app); @@ -476,9 +504,9 @@ describe('AppService updating app.locals and issuing warnings', () => { }, }, }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); + const loadCustomConfig = require('./Config/loadCustomConfig'); + loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const app = { locals: {} }; await AppService(app); expect(app.locals).toHaveProperty('assistants'); @@ -489,103 +517,4 @@ describe('AppService updating app.locals and issuing warnings', () => { expect(assistants.supportedIds).toEqual(['id1', 'id2']); expect(assistants.excludedIds).toBeUndefined(); }); - - it('should log a warning when both supportedIds and excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - disableBuilder: false, - pollIntervalMs: 3000, - timeoutMs: 20000, - supportedIds: ['id1', 'id2'], - excludedIds: ['id3'], - }, - }, - }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); - - const app = { locals: {} }; - await require('./AppService')(app); - - const { logger } = require('~/config'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'The \'assistants\' endpoint has both \'supportedIds\' and \'excludedIds\' defined.', - ), - ); - }); - - it('should log a warning when privateAssistants and supportedIds or excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - privateAssistants: true, - supportedIds: ['id1'], - }, - }, - }; - require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig)); - - const app = { locals: {} }; - await require('./AppService')(app); - - const { logger } = require('~/config'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'The \'assistants\' endpoint has both \'privateAssistants\' and \'supportedIds\' or \'excludedIds\' defined.', - ), - ); - }); - - it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - deprecatedAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - const app = { locals: {} }; - await require('./AppService')(app); - - const { logger } = require('~/config'); - deprecatedAzureVariables.forEach(({ key, description }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, - ); - }); - }); - - it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => { - require('./Config/loadCustomConfig').mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - conflictingAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - const app = { locals: {} }; - await require('./AppService')(app); - - const { logger } = require('~/config'); - conflictingAzureVariables.forEach(({ key }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, - ); - }); - }); }); diff --git a/api/server/services/start/checks.test.js b/api/server/services/start/checks.test.js new file mode 100644 index 00000000000..7430ca9d224 --- /dev/null +++ b/api/server/services/start/checks.test.js @@ -0,0 +1,148 @@ +const { + checkVariables, + checkHealth, + checkConfig, + checkAzureVariables, +} = require('./checks'); +const { deprecatedAzureVariables, conflictingAzureVariables, Constants } = require('librechat-data-provider'); +const { logger } = require('~/config'); +const { isEnabled, checkEmailConfig } = require('~/server/utils'); + +jest.mock('~/server/utils', () => ({ + isEnabled: jest.fn(), + checkEmailConfig: jest.fn(), +})); + +describe('Checks', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset process.env for each test + process.env = {}; + }); + + describe('checkVariables', () => { + it('should log warnings for default secret values and deprecated variables', () => { + // Set default secret values + process.env.CREDS_KEY = 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0'; + process.env.CREDS_IV = 'e2341419ec3dd3d19b13a1a87fafcbfb'; + process.env.JWT_SECRET = '16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef'; + process.env.JWT_REFRESH_SECRET = 'eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418'; + + // Set deprecated variables + process.env.GOOGLE_API_KEY = 'some-google-key'; + process.env.OPENROUTER_API_KEY = 'some-openrouter-key'; + + // For password reset check: simulate email not configured and password reset enabled. + process.env.ALLOW_PASSWORD_RESET = 'true'; + checkEmailConfig.mockReturnValue(false); + isEnabled.mockReturnValue(true); + + checkVariables(); + + // Verify warnings for each default secret + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Default value for CREDS_KEY')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Default value for CREDS_IV')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Default value for JWT_SECRET')); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Default value for JWT_REFRESH_SECRET')); + + // Verify info message to replace defaults is logged + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Please replace any default secret values')); + + // Verify warnings for deprecated variables + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('The `GOOGLE_API_KEY` environment variable is deprecated.'), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('The `OPENROUTER_API_KEY` environment variable is deprecated'), + ); + + // Verify password reset warning is logged + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Password reset is enabled')); + }); + + it('should not warn for password reset if email service is configured', () => { + process.env.ALLOW_PASSWORD_RESET = 'true'; + checkEmailConfig.mockReturnValue(true); + isEnabled.mockReturnValue(true); + + checkVariables(); + + // No warning should be logged about password reset + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Password reset is enabled')); + }); + }); + + describe('checkConfig', () => { + it('should log info when config version is outdated', () => { + const outdatedConfig = { version: '0.9.0' }; + checkConfig(outdatedConfig); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); + }); + + it('should not log info when config version is up-to-date', () => { + const upToDateConfig = { version: Constants.CONFIG_VERSION }; + checkConfig(upToDateConfig); + + // When the config is current, no info message should be logged. + expect(logger.info).not.toHaveBeenCalled(); + }); + }); + + describe('checkAzureVariables', () => { + it('should warn for each deprecated Azure variable if set', () => { + deprecatedAzureVariables.forEach(({ key, description }) => { + process.env[key] = 'test'; + }); + + checkAzureVariables(); + + deprecatedAzureVariables.forEach(({ key, description }) => { + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + `The \`${key}\` environment variable (related to ${description}) should not be used`, + ), + ); + }); + }); + + it('should warn for each conflicting Azure variable if set', () => { + conflictingAzureVariables.forEach(({ key }) => { + process.env[key] = 'test'; + }); + + checkAzureVariables(); + + conflictingAzureVariables.forEach(({ key }) => { + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(`The \`${key}\` environment variable should not be used in combination`), + ); + }); + }); + }); + + describe('checkHealth', () => { + it('should log info if RAG API is healthy', async () => { + process.env.RAG_API_URL = 'http://fakeurl.com'; + const fakeResponse = { ok: true, status: 200 }; + global.fetch = jest.fn().mockResolvedValue(fakeResponse); + + await checkHealth(); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining(`RAG API is running and reachable at ${process.env.RAG_API_URL}.`), + ); + }); + + it('should log warning if RAG API is not healthy', async () => { + process.env.RAG_API_URL = 'http://fakeurl.com'; + global.fetch = jest.fn().mockRejectedValue(new Error('Fetch failed')); + + await checkHealth(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(`RAG API is either not running or not reachable at ${process.env.RAG_API_URL}`), + ); + }); + }); +}); \ No newline at end of file diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js new file mode 100644 index 00000000000..2c62c10f852 --- /dev/null +++ b/api/server/services/start/turnstile.js @@ -0,0 +1,33 @@ +const { removeNullishValues } = require('librechat-data-provider'); +const { logger } = require('~/config'); + +/** + * Loads and maps the Cloudflare Turnstile configuration. + * + * Expected config structure: + * + * turnstile: + * siteKey: "your-site-key-here" + * options: + * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) + * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" + * + * @param {TCustomConfig | undefined} config - The loaded custom configuration. + * @param {TConfigDefaults} configDefaults - The custom configuration default values. + * @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration. + */ +function loadTurnstileConfig(config, configDefaults) { + const { turnstile: customTurnstile = {} } = config ?? {}; + const { turnstile: defaults = {} } = configDefaults; + + /** @type {TCustomConfig['turnstile']} */ + const loadedTurnstile = removeNullishValues({ + siteKey: customTurnstile.siteKey ?? defaults.siteKey, + options: customTurnstile.options ?? defaults.options, + }); + + logger.info('Turnstile configuration loaded:\n' + JSON.stringify(loadedTurnstile, null, 2)); + return loadedTurnstile; +} + +module.exports = { loadTurnstileConfig }; \ No newline at end of file diff --git a/client/package.json b/client/package.json index 96b402e7471..3aae8fd8f6b 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", + "@marsidev/react-turnstile": "^1.1.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.3", @@ -141,8 +142,8 @@ "ts-jest": "^29.2.5", "typescript": "^5.3.3", "vite": "^6.1.0", - "vite-plugin-node-polyfills": "^0.17.0", "vite-plugin-compression": "^0.5.1", + "vite-plugin-node-polyfills": "^0.17.0", "vite-plugin-pwa": "^0.21.1" } } diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 2cd62d08b91..17189c39973 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,9 +1,10 @@ import { useForm } from 'react-hook-form'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; +import { Turnstile } from '@marsidev/react-turnstile'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; -import { useLocalize } from '~/hooks'; +import { ThemeContext, useLocalize } from '~/hooks'; type TLoginFormProps = { onSubmit: (data: TLoginUser) => void; @@ -14,6 +15,7 @@ type TLoginFormProps = { const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); + const { theme } = useContext(ThemeContext); const { register, getValues, @@ -21,9 +23,11 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, formState: { errors }, } = useForm(); const [showResendLink, setShowResendLink] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(null); const { data: config } = useGetStartupConfig(); const useUsernameLogin = config?.ldap?.username; + const validTheme = theme === 'dark' ? 'dark' : 'light'; useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -159,11 +163,29 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, {localize('com_auth_password_forgot')} )} + + {/* Render Turnstile only if enabled in startupConfig */} + {startupConfig.turnstile && ( +
+ setTurnstileToken(token)} + onError={() => setTurnstileToken(null)} + onExpire={() => setTurnstileToken(null)} + /> +
+ )} +
+ )} +
diff --git a/librechat.example.yaml b/librechat.example.yaml index 12c6ed9635f..662393a0fb3 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -68,6 +68,13 @@ interface: multiConvo: true agents: true +# Example Cloudflare turnstile (optional) +#turnstile: +# siteKey: "your-site-key-here" +# options: +# language: "auto" # "auto" or an ISO 639-1 language code (e.g. en) +# size: "normal" # Options: "normal", "compact", "flexible", or "invisible" + # Example Registration Object Structure (optional) registration: socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple'] diff --git a/package-lock.json b/package-lock.json index e9d9c1ef16f..6b3214efc2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1143,6 +1143,7 @@ "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", + "@marsidev/react-turnstile": "^1.1.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.3", @@ -15975,6 +15976,16 @@ "resolved": "client", "link": true }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz", + "integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@microsoft/eslint-formatter-sarif": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@microsoft/eslint-formatter-sarif/-/eslint-formatter-sarif-3.1.0.tgz", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d4c400c8277..f2398d6bc37 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -497,10 +497,28 @@ export const intefaceSchema = z export type TInterfaceConfig = z.infer; +export const turnstileOptionsSchema = z + .object({ + language: z.string().default('auto'), + size: z.enum(['normal', 'compact', 'flexible', 'invisible']).default('normal'), + }) + .default({ + language: 'auto', + size: 'normal', + }); + +export const turnstileSchema = z.object({ + siteKey: z.string(), + options: turnstileOptionsSchema.optional(), +}); + +export type TTurnstileConfig = z.infer; + export type TStartupConfig = { appTitle: string; socialLogins?: string[]; interface?: TInterfaceConfig; + turnstile?: TTurnstileConfig; discordLoginEnabled: boolean; facebookLoginEnabled: boolean; githubLoginEnabled: boolean; @@ -543,6 +561,7 @@ export const configSchema = z.object({ filteredTools: z.array(z.string()).optional(), mcpServers: MCPServersSchema.optional(), interface: intefaceSchema, + turnstile: turnstileSchema.optional(), fileStrategy: fileSourceSchema.default(FileSources.local), actions: z .object({