Skip to content

🚀 feat: Add Cloudflare Turnstile support #5987

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
May 15, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a7aea80
🚀 feat: Add @marsidev/react-turnstile dependency to package.json and …
rubentalstra Feb 23, 2025
fe3819f
🚀 feat: Integrate Cloudflare Turnstile configuration support in AppSe…
rubentalstra Feb 23, 2025
3c89105
🚀 feat: Implemented Cloudflare Turnstile integration in Login and Reg…
rubentalstra Feb 24, 2025
8925042
🚀 feat: Enhance AppService tests with additional mocks and configurat…
rubentalstra Feb 24, 2025
971a2fc
🚀 feat: Comment out outdated config version warning tests in AppServi…
rubentalstra Feb 24, 2025
02947c5
🚀 feat: Remove outdated warning tests and add new checks for environm…
rubentalstra Feb 24, 2025
564e85b
Merge branch 'main' into feat/CAPTCHA
rubentalstra Feb 24, 2025
788094f
Merge branch 'main' into feat/CAPTCHA
rubentalstra Mar 2, 2025
db171b2
Merge branch 'main' into feat/CAPTCHA
rubentalstra Mar 8, 2025
0f2d06d
Merge branch 'main' into feat/CAPTCHA
rubentalstra Mar 19, 2025
504ac61
🔧 test: Update AppService.spec.js to use expect.anything() for paths …
rubentalstra Mar 19, 2025
f0104ac
🔧 test: Refactor AppService.spec.js to streamline mocks and enhance c…
rubentalstra Mar 19, 2025
540da99
Merge branch 'main' into feat/CAPTCHA
rubentalstra Mar 21, 2025
95e5a31
Merge branch 'main' into feat/CAPTCHA
rubentalstra Mar 26, 2025
21ab4e3
Merge branch 'main' into feat/CAPTCHA
rubentalstra Apr 10, 2025
637025b
Merge branch 'main' into feat/CAPTCHA
rubentalstra Apr 11, 2025
d517454
🔧 chore: removed not needed test
rubentalstra Apr 11, 2025
40782dc
Merge branch 'main' into feat/CAPTCHA
rubentalstra May 14, 2025
547ade3
Potential fix for code scanning alert no. 5638: Ensure code is proper…
rubentalstra May 14, 2025
ef0047e
Potential fix for code scanning alert no. 5629: Ensure code is proper…
rubentalstra May 14, 2025
9131873
Potential fix for code scanning alert no. 5642: Ensure code is proper…
rubentalstra May 14, 2025
f4daeb2
Update turnstile.js
rubentalstra May 14, 2025
ee1b816
Potential fix for code scanning alert no. 5634: Ensure code is proper…
rubentalstra May 14, 2025
a1cd9b1
Potential fix for code scanning alert no. 5646: Ensure code is proper…
rubentalstra May 14, 2025
64b7e2f
Potential fix for code scanning alert no. 5647: Ensure code is proper…
rubentalstra May 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion api/server/services/AppService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -85,6 +86,7 @@ const AppService = async (app) => {
availableTools,
imageOutputType,
interfaceConfig,
turnstileConfig,
balance,
};

Expand Down
11 changes: 11 additions & 0 deletions api/server/services/AppService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({
},
}),
}));
jest.mock('./start/turnstile', () => ({
loadTurnstileConfig: jest.fn(() => ({
siteKey: 'default-site-key',
options: {},
})),
}));

const azureGroups = [
{
Expand Down Expand Up @@ -86,6 +92,10 @@ const azureGroups = [

describe('AppService', () => {
let app;
const mockedTurnstileConfig = {
siteKey: 'default-site-key',
options: {},
};

beforeEach(() => {
app = { locals: {} };
Expand All @@ -107,6 +117,7 @@ describe('AppService', () => {
sidePanel: true,
presets: true,
}),
turnstileConfig: mockedTurnstileConfig,
modelSpecs: undefined,
availableTools: {
ExampleTool: {
Expand Down
35 changes: 35 additions & 0 deletions api/server/services/start/turnstile.js
Original file line number Diff line number Diff line change
@@ -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
};
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 24 additions & 2 deletions client/src/components/Auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,16 +15,19 @@ type TLoginFormProps = {

const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const {
register,
getValues,
handleSubmit,
formState: { errors },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

const { data: config } = useGetStartupConfig();
const useUsernameLogin = config?.ldap?.username;
const validTheme = theme === 'dark' ? 'dark' : 'light';

useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
Expand Down Expand Up @@ -159,11 +163,29 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
{localize('com_auth_password_forgot')}
</a>
)}

{/* Render Turnstile only if enabled in startupConfig */}
{startupConfig.turnstile && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}

<div className="mt-6">
<button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
disabled={startupConfig.turnstile ? !turnstileToken : false}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
Expand Down
41 changes: 33 additions & 8 deletions client/src/components/Auth/Registration.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useForm } from 'react-hook-form';
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from './ErrorMessage';
import { Spinner } from '~/components/svg';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';

const Registration: React.FC = () => {
const navigate = useNavigate();
const localize = useLocalize();
const { theme } = useContext(ThemeContext);
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();

const {
Expand All @@ -24,10 +26,12 @@ const Registration: React.FC = () => {
const [errorMessage, setErrorMessage] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [countdown, setCountdown] = useState<number>(3);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);

const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const token = queryParams.get('token');
const validTheme = theme === 'dark' ? 'dark' : 'light';

const registerUser = useRegisterUserMutation({
onMutate: () => {
Expand Down Expand Up @@ -178,17 +182,38 @@ const Registration: React.FC = () => {
validate: (value: string) =>
value === password || localize('com_auth_password_not_match'),
})}

{/* Render Turnstile only if enabled in startupConfig */}
{startupConfig?.turnstile && (
<div className="my-4 flex justify-center">
<Turnstile
siteKey={startupConfig.turnstile.siteKey}
options={{
...startupConfig.turnstile.options,
theme: validTheme,
}}
onSuccess={(token) => setTurnstileToken(token)}
onError={() => setTurnstileToken(null)}
onExpire={() => setTurnstileToken(null)}
/>
</div>
)}

<div className="mt-6">
<button
disabled={Object.keys(errors).length > 0}
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
(startupConfig?.turnstile ? !turnstileToken : false)
}
type="submit"
aria-label="Submit registration"
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>
Expand Down
7 changes: 7 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,10 +505,28 @@ export const intefaceSchema = z
export type TInterfaceConfig = z.infer<typeof intefaceSchema>;
export type TBalanceConfig = z.infer<typeof balanceSchema>;

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<typeof turnstileSchema>;

export type TStartupConfig = {
appTitle: string;
socialLogins?: string[];
interface?: TInterfaceConfig;
turnstile?: TTurnstileConfig;
balance?: TBalanceConfig;
discordLoginEnabled: boolean;
facebookLoginEnabled: boolean;
Expand Down Expand Up @@ -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({
Expand Down