From c925f9f39c8e97fcb4a2ce9f8eec2d6fe7d58d84 Mon Sep 17 00:00:00 2001 From: Ruben Talstra Date: Thu, 15 May 2025 15:38:58 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20feat:=20Add=20`Cloudflare=20Turn?= =?UTF-8?q?stile`=20support=20(#5987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚀 feat: Add @marsidev/react-turnstile dependency to package.json and package-lock.json * 🚀 feat: Integrate Cloudflare Turnstile configuration support in AppService and add schema validation * 🚀 feat: Implemented Cloudflare Turnstile integration in Login and Registration forms * 🚀 feat: Enhance AppService tests with additional mocks and configuration setups * 🚀 feat: Comment out outdated config version warning tests in AppService.spec.js * 🚀 feat: Remove outdated warning tests and add new checks for environment variables and API health * 🔧 test: Update AppService.spec.js to use expect.anything() for paths validation * 🔧 test: Refactor AppService.spec.js to streamline mocks and enhance clarity * 🔧 chore: removed not needed test * Potential fix for code scanning alert no. 5638: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 5629: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 5642: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update turnstile.js * Potential fix for code scanning alert no. 5634: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 5646: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 5647: Ensure code is properly formatted, use insertion, deletion, or replacement to obtain desired formatting. Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- api/server/routes/config.js | 1 + api/server/services/AppService.js | 4 +- api/server/services/AppService.spec.js | 11 ++++++ api/server/services/start/turnstile.js | 35 ++++++++++++++++++ client/package.json | 1 + client/src/components/Auth/LoginForm.tsx | 26 ++++++++++++- client/src/components/Auth/Registration.tsx | 41 +++++++++++++++++---- librechat.example.yaml | 7 ++++ package-lock.json | 11 ++++++ packages/data-provider/src/config.ts | 19 ++++++++++ 10 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 api/server/services/start/turnstile.js diff --git a/api/server/routes/config.js b/api/server/routes/config.js index ebafb05c30..583453fe4a 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, balance: req.app.locals.balance, sharedLinksEnabled, diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 1ad3aaace6..5f119e67aa 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -12,6 +12,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 { initializeS3 } = require('./Files/S3/initialize'); @@ -23,7 +24,6 @@ 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. @@ -74,6 +74,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 = { ocr, @@ -85,6 +86,7 @@ const AppService = async (app) => { availableTools, imageOutputType, interfaceConfig, + turnstileConfig, balance, }; diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 465ec9fdd6..81a017e41e 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({ }, }), })); +jest.mock('./start/turnstile', () => ({ + loadTurnstileConfig: jest.fn(() => ({ + siteKey: 'default-site-key', + options: {}, + })), +})); const azureGroups = [ { @@ -86,6 +92,10 @@ const azureGroups = [ describe('AppService', () => { let app; + const mockedTurnstileConfig = { + siteKey: 'default-site-key', + options: {}, + }; beforeEach(() => { app = { locals: {} }; @@ -107,6 +117,7 @@ describe('AppService', () => { sidePanel: true, presets: true, }), + turnstileConfig: mockedTurnstileConfig, modelSpecs: undefined, availableTools: { ExampleTool: { diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js new file mode 100644 index 0000000000..ffd4545dae --- /dev/null +++ b/api/server/services/start/turnstile.js @@ -0,0 +1,35 @@ +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, +}; diff --git a/client/package.json b/client/package.json index 5fd9729a74..1b33c37919 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@dicebear/collection": "^9.2.2", "@dicebear/core": "^9.2.2", "@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", diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 2cd62d08b9..030b6323f7 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 0b4963cb2a..ae14b0faae 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -71,6 +71,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 5aa383170a..f4690d43c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1073,6 +1073,7 @@ "@dicebear/collection": "^9.2.2", "@dicebear/core": "^9.2.2", "@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", @@ -19729,6 +19730,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 978aea2520..bdac70a0c6 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -505,10 +505,28 @@ export const intefaceSchema = z export type TInterfaceConfig = z.infer; export type TBalanceConfig = 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; balance?: TBalanceConfig; discordLoginEnabled: boolean; facebookLoginEnabled: boolean; @@ -578,6 +596,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({