fix: cloudflare turnstile implementation EXACTLY to the documentation

This commit is contained in:
Elizabeth
2025-08-08 12:56:11 -05:00
parent 02b16b8db2
commit 6ebcfff2b3
14 changed files with 988 additions and 396 deletions

View File

@@ -59,6 +59,11 @@ class CaptchaController extends Controller
'turnstile' => [
'site_key' => $this->settings->get('settings::captcha:turnstile:site_key', ''),
'secret_key' => $this->settings->get('settings::captcha:turnstile:secret_key', ''),
'theme' => $this->settings->get('settings::captcha:turnstile:theme', 'auto'),
'size' => $this->settings->get('settings::captcha:turnstile:size', 'normal'),
'appearance' => $this->settings->get('settings::captcha:turnstile:appearance', 'always'),
'action' => $this->settings->get('settings::captcha:turnstile:action', ''),
'cdata' => $this->settings->get('settings::captcha:turnstile:cdata', ''),
],
'friendly' => [
'site_key' => $this->settings->get('settings::captcha:friendly:site_key', ''),
@@ -94,6 +99,13 @@ class CaptchaController extends Controller
if ($provider === 'mcaptcha') {
$this->settings->set("settings::captcha:{$provider}:endpoint", '');
}
if ($provider === 'turnstile') {
$this->settings->set("settings::captcha:{$provider}:theme", '');
$this->settings->set("settings::captcha:{$provider}:size", '');
$this->settings->set("settings::captcha:{$provider}:appearance", '');
$this->settings->set("settings::captcha:{$provider}:action", '');
$this->settings->set("settings::captcha:{$provider}:cdata", '');
}
}
// Save the selected provider's config if enabled

View File

@@ -2,206 +2,165 @@
namespace Pterodactyl\Http\Middleware;
use GuzzleHttp\Client;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Events\Auth\FailedCaptcha;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Events\Dispatcher;
use Psr\Http\Client\ClientExceptionInterface;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Events\Auth\FailedCaptcha;
use Pterodactyl\Services\Captcha\TurnstileService;
use Pterodactyl\Services\Captcha\TurnstileException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class VerifyCaptcha
{
private const PROVIDER_ENDPOINTS = [
'turnstile' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
'hcaptcha' => 'https://hcaptcha.com/siteverify',
'friendly' => 'https://api.friendlycaptcha.com/api/v1/siteverify',
'mcaptcha' => 'https://mcaptcha.org/api/siteverify',
];
private const PROVIDER_FIELDS = [
'turnstile' => 'cf-turnstile-response',
'hcaptcha' => 'h-captcha-response',
'friendly' => 'frc-captcha-response',
'mcaptcha' => 'mcaptcha-response',
];
public function __construct(
private Dispatcher $dispatcher,
private Repository $config,
private Client $client
) {
}
public function handle(Request $request, \Closure $next): mixed
{
$driver = $this->config->get('captcha.driver');
if (!$this->shouldVerifyCaptcha($driver)) {
return $next($request);
public function __construct(
private Dispatcher $dispatcher,
private Repository $config,
private TurnstileService $turnstileService
) {
}
$fieldName = self::PROVIDER_FIELDS[$driver];
$captchaResponse = $this->getCaptchaResponseFromRequest($request, $fieldName);
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): mixed
{
$driver = $this->config->get('captcha.driver');
if (empty($captchaResponse)) {
\Log::warning('CAPTCHA Middleware - Missing response token', [
'expected_field' => $fieldName,
'available_fields' => array_keys($request->all()),
]);
$this->logAndTriggerFailure($request, $driver, 'missing_response');
throw new HttpException(400, 'Please complete the CAPTCHA challenge.');
}
try {
$result = $this->verifyWithProvider($driver, $captchaResponse, $request->ip());
if ($this->isResponseValid($result, $request, $driver)) {
return $next($request);
}
$this->logAndTriggerFailure($request, $driver, 'verification_failed', $result);
throw new HttpException(400, 'CAPTCHA verification failed. Please try again.');
} catch (ClientExceptionInterface $e) {
$this->logAndTriggerFailure($request, $driver, 'service_error');
\Log::error('CAPTCHA service error', ['error' => $e->getMessage()]);
throw new HttpException(503, 'CAPTCHA service unavailable. Please try again later.');
} catch (\Exception $e) {
$this->logAndTriggerFailure($request, $driver, 'unexpected_error');
\Log::error('CAPTCHA unexpected error', ['error' => $e->getMessage()]);
throw new HttpException(500, 'An unexpected error occurred during CAPTCHA verification.');
}
}
private function shouldVerifyCaptcha(?string $driver): bool
{
return $driver && array_key_exists($driver, self::PROVIDER_FIELDS);
}
private function getCaptchaResponseFromRequest(Request $request, string $fieldName): ?string
{
if ($request->isJson()) {
$data = $request->json()->all();
return $data['captchaData'] ?? $data[$fieldName] ?? null;
}
$response = $request->input($fieldName) ?? $request->input('captchaData');
if (empty($response) && in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
$rawInput = file_get_contents('php://input');
if (!empty($rawInput)) {
parse_str($rawInput, $parsed);
$response = $parsed[$fieldName] ?? $parsed['captchaData'] ?? null;
}
}
return $response;
}
private function verifyWithProvider(string $driver, string $response, string $remoteIp): \stdClass
{
$secretKey = $this->config->get("captcha.{$driver}.secret_key");
if (empty($secretKey)) {
throw new \RuntimeException("No secret key configured for CAPTCHA driver: {$driver}");
}
$params = ['secret' => $secretKey];
if ($driver === 'turnstile') {
$params['response'] = $response;
$params['remoteip'] = $remoteIp;
} elseif ($driver === 'hcaptcha') {
$params['response'] = $response;
$params['remoteip'] = $remoteIp;
} elseif ($driver === 'friendly') {
$params['solution'] = $response;
$params['sitekey'] = $this->config->get("captcha.{$driver}.site_key");
}
try {
$res = $this->client->post(self::PROVIDER_ENDPOINTS[$driver], [
'timeout' => $this->config->get('captcha.timeout', 5),
'json' => $params,
]);
$body = $res->getBody()->getContents();
$result = json_decode($body);
if (json_last_error() !== JSON_ERROR_NONE) {
\Log::error('Invalid JSON response from CAPTCHA provider', [
'provider' => $driver,
'response_body' => $body,
'json_error' => json_last_error_msg()
]);
throw new \RuntimeException("Invalid JSON response from {$driver} CAPTCHA provider");
}
return $result;
} catch (\Exception $e) {
\Log::error('CAPTCHA verification error', [
'provider' => $driver,
'error' => $e->getMessage(),
'response' => $e->getResponse() ? $e->getResponse()->getBody()->getContents() : null
]);
throw $e;
}
}
private function isResponseValid(\stdClass $result, Request $request, string $driver): bool
{
if (!($result->success ?? false)) {
return false;
}
switch ($driver) {
case 'turnstile':
if ($this->config->get('captcha.verify_domain', false)) {
$expectedHost = parse_url($request->url(), PHP_URL_HOST);
if (($result->hostname ?? null) !== $expectedHost) {
\Log::warning('Domain verification failed', [
'expected' => $expectedHost,
'actual' => $result->hostname ?? 'null'
]);
return false;
}
// Skip verification if captcha is disabled or not Turnstile
if ($driver !== 'turnstile' || !$this->turnstileService->isEnabled()) {
return $next($request);
}
try {
$this->verifyTurnstileToken($request);
return $next($request);
} catch (TurnstileException $e) {
$this->logAndTriggerFailure($request, 'turnstile', $e->getMessage());
throw new HttpException(400, 'CAPTCHA verification failed: ' . $e->getMessage());
} catch (\Exception $e) {
$this->logAndTriggerFailure($request, 'turnstile', 'unexpected_error');
Log::error('CAPTCHA unexpected error', ['error' => $e->getMessage()]);
throw new HttpException(500, 'An unexpected error occurred during CAPTCHA verification.');
}
break;
}
return true;
}
/**
* Verify Turnstile token according to official documentation
*/
private function verifyTurnstileToken(Request $request): void
{
// Extract token from request
$token = $this->turnstileService->getTokenFromRequest($request);
private function logAndTriggerFailure(
Request $request,
string $driver,
string $reason,
?\stdClass $result = null
): void {
$errorCodes = $result->{'error-codes'} ?? [];
if (empty($token)) {
throw new TurnstileException('Please complete the CAPTCHA challenge.');
}
\Log::warning("CAPTCHA verification failed", [
'driver' => $driver,
'reason' => $reason,
'ip' => $request->ip(),
'path' => $request->path(),
'method' => $request->method(),
'error_codes' => $errorCodes,
]);
// Get visitor's IP address
$remoteIp = $this->getVisitorIpAddress($request);
$this->dispatcher->dispatch(new FailedCaptcha(
$request->ip(),
$driver,
$reason,
$errorCodes
));
}
// Generate idempotency key for potential retries
$idempotencyKey = $this->turnstileService->generateIdempotencyKey();
// Verify token with Turnstile Siteverify API
$result = $this->turnstileService->verify($token, $remoteIp, $idempotencyKey);
if ($result->isFailed()) {
// Handle specific error cases
if ($result->isTokenInvalid()) {
throw new TurnstileException('Invalid or expired CAPTCHA token. Please try again.');
}
if ($result->isTokenConsumed()) {
throw new TurnstileException('CAPTCHA token has already been used or has timed out. Please refresh and try again.');
}
if ($result->hasInternalError()) {
throw new TurnstileException('CAPTCHA service temporarily unavailable. Please try again.');
}
if ($result->hasSecretKeyError()) {
Log::error('Turnstile secret key configuration error', [
'error_codes' => $result->getErrorCodes()
]);
throw new TurnstileException('CAPTCHA configuration error. Please contact support.');
}
// Generic error message for other failures
throw new TurnstileException($result->getErrorMessage() ?: 'CAPTCHA verification failed. Please try again.');
}
// Additional validation: verify hostname if domain verification is enabled
if ($this->config->get('captcha.verify_domain', false)) {
$expectedHostname = parse_url($request->url(), PHP_URL_HOST);
if (!$result->validateHostname($expectedHostname)) {
Log::warning('Turnstile hostname verification failed', [
'expected' => $expectedHostname,
'actual' => $result->getHostname()
]);
throw new TurnstileException('CAPTCHA domain verification failed.');
}
}
// Log successful verification for monitoring
Log::info('Turnstile verification successful', [
'hostname' => $result->getHostname(),
'challenge_ts' => $result->getChallengeTimestamp()?->toISOString(),
'action' => $result->getAction(),
'ip' => $remoteIp,
]);
}
/**
* Get the visitor's IP address with proper header handling
*/
private function getVisitorIpAddress(Request $request): string
{
// Check for Cloudflare's CF-Connecting-IP header first
if ($request->hasHeader('CF-Connecting-IP')) {
return $request->header('CF-Connecting-IP');
}
// Check for other common proxy headers
$headers = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
];
foreach ($headers as $header) {
if ($request->server($header)) {
$ips = explode(',', $request->server($header));
return trim($ips[0]);
}
}
// Fallback to standard IP
return $request->ip();
}
/**
* Log failure and trigger event
*/
private function logAndTriggerFailure(Request $request, string $driver, string $reason): void
{
Log::warning('CAPTCHA verification failed', [
'driver' => $driver,
'reason' => $reason,
'ip' => $request->ip(),
'path' => $request->path(),
'method' => $request->method(),
'user_agent' => $request->userAgent(),
]);
$this->dispatcher->dispatch(new FailedCaptcha(
$request->ip(),
$driver,
$reason,
[]
));
}
}

View File

@@ -26,6 +26,11 @@ class CaptchaSettingsFormRequest extends AdminFormRequest
} elseif ($driver === 'turnstile') {
$rules['turnstile.site_key'] = 'required|string';
$rules['turnstile.secret_key'] = 'required|string';
$rules['turnstile.theme'] = 'nullable|in:auto,light,dark';
$rules['turnstile.size'] = 'nullable|in:normal,compact,flexible';
$rules['turnstile.appearance'] = 'nullable|in:always,execute,interaction-only';
$rules['turnstile.action'] = 'nullable|string|max:32|regex:/^[a-zA-Z0-9_-]*$/';
$rules['turnstile.cdata'] = 'nullable|string|max:255|regex:/^[a-zA-Z0-9_-]*$/';
} elseif ($driver === 'friendly') {
$rules['friendly.site_key'] = 'required|string';
$rules['friendly.secret_key'] = 'required|string';
@@ -48,6 +53,11 @@ class CaptchaSettingsFormRequest extends AdminFormRequest
'mcaptcha.endpoint' => 'mCaptcha Endpoint',
'turnstile.site_key' => 'Turnstile Site Key',
'turnstile.secret_key' => 'Turnstile Secret Key',
'turnstile.theme' => 'Turnstile Theme',
'turnstile.size' => 'Turnstile Size',
'turnstile.appearance' => 'Turnstile Appearance',
'turnstile.action' => 'Turnstile Action',
'turnstile.cdata' => 'Turnstile Custom Data',
'proton.site_key' => 'Proton Site Key',
'proton.secret_key' => 'Proton Secret Key',
'friendly.site_key' => 'Friendly Site Key',

View File

@@ -19,7 +19,12 @@ class AssetComposer
'captcha' => [
'driver' => config('captcha.driver', 'none'),
'turnstile' => [
'siteKey' => config('captcha.turnstile.site_key', '')
'siteKey' => config('captcha.turnstile.site_key', ''),
'theme' => config('captcha.turnstile.theme', 'auto'),
'size' => config('captcha.turnstile.size', 'normal'),
'appearance' => config('captcha.turnstile.appearance', 'always'),
'action' => config('captcha.turnstile.action', ''),
'cdata' => config('captcha.turnstile.cdata', ''),
],
'hcaptcha' => [
'siteKey' => config('captcha.hcaptcha.site_key', '')

View File

@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Pterodactyl\Extensions\Themes\Theme;
use Pterodactyl\Services\Captcha\TurnstileService;
use Illuminate\Database\Eloquent\Relations\Relation;
class AppServiceProvider extends ServiceProvider
@@ -65,6 +66,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton('extensions.themes', function () {
return new Theme();
});
// Register Turnstile service
$this->app->singleton(TurnstileService::class);
}
/**

View File

@@ -0,0 +1,14 @@
<?php
namespace Pterodactyl\Services\Captcha;
use Exception;
use Throwable;
class TurnstileException extends Exception
{
public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Pterodactyl\Services\Captcha;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Contracts\Config\Repository;
use Psr\Http\Client\ClientExceptionInterface;
class TurnstileService
{
private const SITEVERIFY_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
private const TOKEN_FIELD = 'cf-turnstile-response';
private const MAX_TOKEN_LENGTH = 2048;
private const TOKEN_VALIDITY_SECONDS = 300;
public function __construct(
private Client $client,
private Repository $config
) {
}
/**
* Verify a Turnstile token using the Siteverify API
*
* @param string $token The Turnstile response token
* @param string|null $remoteIp The visitor's IP address
* @param string|null $idempotencyKey Optional UUID for retry functionality
* @return TurnstileVerificationResult
* @throws TurnstileException
*/
public function verify(string $token, ?string $remoteIp = null, ?string $idempotencyKey = null): TurnstileVerificationResult
{
$this->validateToken($token);
$secretKey = $this->config->get('captcha.turnstile.secret_key');
if (empty($secretKey)) {
throw new TurnstileException('Turnstile secret key is not configured');
}
$params = [
'secret' => $secretKey,
'response' => $token,
];
// Add optional parameters
if ($remoteIp) {
$params['remoteip'] = $remoteIp;
}
if ($idempotencyKey) {
$params['idempotency_key'] = $idempotencyKey;
}
try {
$response = $this->client->post(self::SITEVERIFY_ENDPOINT, [
'form_params' => $params, // Use form_params for application/x-www-form-urlencoded
'timeout' => $this->config->get('captcha.timeout', 5),
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
]);
$body = $response->getBody()->getContents();
$result = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new TurnstileException('Invalid JSON response from Turnstile API: ' . json_last_error_msg());
}
return new TurnstileVerificationResult($result);
} catch (ClientExceptionInterface $e) {
throw new TurnstileException('Turnstile API request failed: ' . $e->getMessage(), 0, $e);
}
}
/**
* Extract Turnstile token from request
*/
public function getTokenFromRequest(Request $request): ?string
{
// Try different methods to get the token
$token = $request->input(self::TOKEN_FIELD)
?? $request->input('captchaData')
?? $request->json(self::TOKEN_FIELD)
?? $request->json('captchaData');
// Fallback: parse raw input for form data
if (empty($token) && in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
$rawInput = file_get_contents('php://input');
if (!empty($rawInput)) {
parse_str($rawInput, $parsed);
$token = $parsed[self::TOKEN_FIELD] ?? $parsed['captchaData'] ?? null;
}
}
return $token;
}
/**
* Validate token format and length
*/
private function validateToken(string $token): void
{
if (empty($token)) {
throw new TurnstileException('Token cannot be empty');
}
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
throw new TurnstileException('Token exceeds maximum length of ' . self::MAX_TOKEN_LENGTH . ' characters');
}
}
/**
* Generate a UUID for idempotency
*/
public function generateIdempotencyKey(): string
{
return Str::uuid()->toString();
}
/**
* Check if Turnstile is enabled
*/
public function isEnabled(): bool
{
return $this->config->get('captcha.driver') === 'turnstile'
&& !empty($this->config->get('captcha.turnstile.secret_key'))
&& !empty($this->config->get('captcha.turnstile.site_key'));
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace Pterodactyl\Services\Captcha;
use Carbon\Carbon;
class TurnstileVerificationResult
{
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Check if the verification was successful
*/
public function isSuccess(): bool
{
return $this->data['success'] ?? false;
}
/**
* Get the challenge timestamp
*/
public function getChallengeTimestamp(): ?Carbon
{
$timestamp = $this->data['challenge_ts'] ?? null;
return $timestamp ? Carbon::parse($timestamp) : null;
}
/**
* Get the hostname for which the challenge was served
*/
public function getHostname(): ?string
{
return $this->data['hostname'] ?? null;
}
/**
* Get the action identifier
*/
public function getAction(): ?string
{
return $this->data['action'] ?? null;
}
/**
* Get the customer data
*/
public function getCdata(): ?string
{
return $this->data['cdata'] ?? null;
}
/**
* Get error codes
*/
public function getErrorCodes(): array
{
return $this->data['error-codes'] ?? [];
}
/**
* Get the ephemeral ID (Enterprise only)
*/
public function getEphemeralId(): ?string
{
return $this->data['metadata']['ephemeral_id'] ?? null;
}
/**
* Check if the verification failed
*/
public function isFailed(): bool
{
return !$this->isSuccess();
}
/**
* Check if the token was invalid or expired
*/
public function isTokenInvalid(): bool
{
return in_array('invalid-input-response', $this->getErrorCodes());
}
/**
* Check if the token was already consumed or timed out
*/
public function isTokenConsumed(): bool
{
return in_array('timeout-or-duplicate', $this->getErrorCodes());
}
/**
* Check if there was an internal error
*/
public function hasInternalError(): bool
{
return in_array('internal-error', $this->getErrorCodes());
}
/**
* Check if the secret key was missing or invalid
*/
public function hasSecretKeyError(): bool
{
return in_array('missing-input-secret', $this->getErrorCodes()) ||
in_array('invalid-input-secret', $this->getErrorCodes());
}
/**
* Check if the response parameter was missing
*/
public function hasResponseMissing(): bool
{
return in_array('missing-input-response', $this->getErrorCodes());
}
/**
* Check if the request was malformed
*/
public function isBadRequest(): bool
{
return in_array('bad-request', $this->getErrorCodes());
}
/**
* Get a human-readable error message
*/
public function getErrorMessage(): string
{
if ($this->isSuccess()) {
return '';
}
$errorCodes = $this->getErrorCodes();
if (empty($errorCodes)) {
return 'Verification failed for unknown reason';
}
$messages = [
'missing-input-secret' => 'Secret key was not provided',
'invalid-input-secret' => 'Secret key is invalid or does not exist',
'missing-input-response' => 'Response token was not provided',
'invalid-input-response' => 'Response token is invalid or has expired',
'bad-request' => 'Request was malformed',
'timeout-or-duplicate' => 'Response token has already been validated or has timed out',
'internal-error' => 'Internal error occurred during validation',
];
$errorMessages = [];
foreach ($errorCodes as $code) {
$errorMessages[] = $messages[$code] ?? "Unknown error: {$code}";
}
return implode('; ', $errorMessages);
}
/**
* Get the raw response data
*/
public function getRawData(): array
{
return $this->data;
}
/**
* Validate the hostname matches the expected domain
*/
public function validateHostname(string $expectedHostname): bool
{
$actualHostname = $this->getHostname();
return $actualHostname === $expectedHostname;
}
}

View File

@@ -23,6 +23,16 @@ return [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
'endpoint' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
'theme' => env('TURNSTILE_THEME', 'auto'), // auto, light, dark
'size' => env('TURNSTILE_SIZE', 'normal'), // normal, compact, flexible
'action' => env('TURNSTILE_ACTION', null), // Optional action identifier
'cdata' => env('TURNSTILE_CDATA', null), // Optional customer data
'retry' => env('TURNSTILE_RETRY', 'auto'), // auto, never
'retry_interval' => env('TURNSTILE_RETRY_INTERVAL', 8000), // milliseconds
'refresh_expired' => env('TURNSTILE_REFRESH_EXPIRED', 'auto'), // auto, manual, never
'refresh_timeout' => env('TURNSTILE_REFRESH_TIMEOUT', 'auto'), // auto, manual, never
'appearance' => env('TURNSTILE_APPEARANCE', 'always'), // always, execute, interaction-only
'execution' => env('TURNSTILE_EXECUTION', 'render'), // render, execute
],
'proton' => [

View File

@@ -1,7 +1,7 @@
// components/Captcha.tsx
import React from 'react';
import HCaptcha from '@hcaptcha/react-hcaptcha';
import { Turnstile } from '@marsidev/react-turnstile';
import { useEffect, useState } from 'react';
import TurnstileWidget from './TurnstileWidget';
import FriendlyCaptcha from './FriendlyCaptcha';
interface CaptchaProps {
sitekey?: string;
@@ -10,45 +10,83 @@ interface CaptchaProps {
onVerify: (token: string) => void;
onError: () => void;
onExpire: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'flexible';
action?: string;
cData?: string;
className?: string;
}
const Captcha = ({ driver, sitekey, endpoint, onVerify, onError, onExpire }: CaptchaProps) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (driver !== 'none' && !loaded) {
// Load any required external scripts here if needed
setLoaded(true);
const Captcha = React.forwardRef<any, CaptchaProps>(
({ driver, sitekey, theme = 'auto', size = 'normal', action, cData, onVerify, onError, onExpire, className }, ref) => {
if (driver === 'hcaptcha' && sitekey) {
return (
<HCaptcha
ref={ref}
sitekey={sitekey}
onVerify={onVerify}
onError={onError}
onExpire={onExpire}
theme={theme === 'auto' ? 'dark' : theme}
size={size === 'flexible' ? 'normal' : size}
/>
);
}
}, [driver]);
if (driver === 'hcaptcha') {
return <HCaptcha sitekey={sitekey || ''} onVerify={onVerify} onError={onError} onExpire={onExpire} />;
if (driver === 'turnstile' && sitekey) {
return (
<TurnstileWidget
ref={ref}
siteKey={sitekey}
theme={theme}
size={size}
action={action}
cData={cData}
onSuccess={onVerify}
onError={onError}
onExpire={onExpire}
className={className}
appearance="always"
execution="render"
retry="auto"
refreshExpired="auto"
refreshTimeout="auto"
/>
);
}
if (driver === 'friendly' && sitekey) {
return (
<FriendlyCaptcha
ref={ref}
sitekey={sitekey}
onComplete={onVerify}
onError={onError}
onExpire={onExpire}
/>
);
}
if (driver === 'mcaptcha') {
return (
<div className="text-red-500 text-sm">
mCaptcha implementation needed
</div>
);
}
if (driver === 'recaptcha') {
return (
<div className="text-red-500 text-sm">
reCAPTCHA implementation needed
</div>
);
}
return null;
}
);
if (driver === 'turnstile') {
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
}
if (driver === 'mcaptcha') {
// TODO: Maybe make this work one day
// @mcaptcha/vanilla-glue
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
}
if (driver === 'recaptcha') {
// TODO: Maybe make this work one day
// react-google-recaptcha-v3
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
}
if (driver === 'friendly') {
// TODO: Maybe make this work one day
// @friendlycaptcha/sdk
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
}
return null;
};
Captcha.displayName = 'Captcha';
export default Captcha;

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
interface TurnstileWidgetProps {
siteKey: string;
action?: string;
cData?: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'flexible';
tabIndex?: number;
responseField?: boolean;
responseFieldName?: string;
retry?: 'auto' | 'never';
retryInterval?: number;
refreshExpired?: 'auto' | 'manual' | 'never';
refreshTimeout?: 'auto' | 'manual' | 'never';
appearance?: 'always' | 'execute' | 'interaction-only';
execution?: 'render' | 'execute';
feedbackEnabled?: boolean;
onSuccess?: (token: string) => void;
onError?: (error?: string) => void;
onExpire?: () => void;
onTimeout?: () => void;
onBeforeInteractive?: () => void;
onAfterInteractive?: () => void;
onUnsupported?: () => void;
className?: string;
}
export interface TurnstileWidgetRef {
reset: () => void;
remove: () => void;
getResponse: () => string | undefined;
isExpired: () => boolean;
execute: () => void;
}
const TurnstileWidget = forwardRef<TurnstileWidgetRef, TurnstileWidgetProps>(
(
{
siteKey,
action,
cData,
theme = 'auto',
size = 'normal',
tabIndex = 0,
responseField = true,
responseFieldName = 'cf-turnstile-response',
retry = 'auto',
retryInterval = 8000,
refreshExpired = 'auto',
refreshTimeout = 'auto',
appearance = 'always',
execution = 'render',
feedbackEnabled = true,
onSuccess,
onError,
onExpire,
onTimeout,
onBeforeInteractive,
onAfterInteractive,
onUnsupported,
className = '',
},
ref
) => {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | undefined>(undefined);
const scriptLoadedRef = useRef<boolean>(false);
useImperativeHandle(ref, () => ({
reset: () => {
if ((window as any).turnstile && widgetIdRef.current) {
(window as any).turnstile.reset(widgetIdRef.current);
}
},
remove: () => {
if ((window as any).turnstile && widgetIdRef.current) {
(window as any).turnstile.remove(widgetIdRef.current);
widgetIdRef.current = undefined;
}
},
getResponse: () => {
if ((window as any).turnstile && widgetIdRef.current) {
return (window as any).turnstile.getResponse(widgetIdRef.current);
}
return undefined;
},
isExpired: () => {
if ((window as any).turnstile && widgetIdRef.current) {
return (window as any).turnstile.isExpired(widgetIdRef.current);
}
return false;
},
execute: () => {
if ((window as any).turnstile && containerRef.current) {
(window as any).turnstile.execute(containerRef.current);
}
},
}));
const loadTurnstileScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
if ((window as any).turnstile) {
resolve();
return;
}
if (scriptLoadedRef.current) {
// Script is already loading, wait for it
const checkInterval = setInterval(() => {
if ((window as any).turnstile) {
clearInterval(checkInterval);
resolve();
}
}, 100);
return;
}
scriptLoadedRef.current = true;
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
// Note: Do NOT use async/defer when using turnstile.ready()
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
document.head.appendChild(script);
});
};
const renderWidget = () => {
if (!(window as any).turnstile || !containerRef.current || widgetIdRef.current) {
return;
}
const params: any = {
sitekey: siteKey,
callback: onSuccess,
'error-callback': onError,
'expired-callback': onExpire,
'timeout-callback': onTimeout,
'before-interactive-callback': onBeforeInteractive,
'after-interactive-callback': onAfterInteractive,
'unsupported-callback': onUnsupported,
theme,
size,
tabindex: tabIndex,
'response-field': responseField,
'response-field-name': responseFieldName,
retry,
'retry-interval': retryInterval,
'refresh-expired': refreshExpired,
'refresh-timeout': refreshTimeout,
appearance,
execution,
'feedback-enabled': feedbackEnabled,
};
// Add optional parameters only if they are provided
if (action) params.action = action;
if (cData) params.cdata = cData;
try {
const widgetId = (window as any).turnstile.render(containerRef.current, params);
widgetIdRef.current = widgetId || undefined;
} catch (error) {
console.error('Failed to render Turnstile widget:', error);
onError?.('Failed to render widget');
}
};
useEffect(() => {
let mounted = true;
const initializeTurnstile = async () => {
try {
await loadTurnstileScript();
if (!mounted) return;
if ((window as any).turnstile) {
// Script is loaded, render widget directly
renderWidget();
}
} catch (error) {
console.error('Failed to initialize Turnstile:', error);
onError?.('Failed to load Turnstile');
}
};
initializeTurnstile();
return () => {
mounted = false;
if ((window as any).turnstile && widgetIdRef.current) {
try {
(window as any).turnstile.remove(widgetIdRef.current);
} catch (error) {
console.warn('Failed to remove Turnstile widget:', error);
}
}
widgetIdRef.current = undefined;
};
}, [siteKey]);
// Re-render widget if critical props change
useEffect(() => {
if (widgetIdRef.current && (window as any).turnstile) {
// Remove existing widget and create new one
(window as any).turnstile.remove(widgetIdRef.current);
widgetIdRef.current = undefined;
renderWidget();
}
}, [siteKey, theme, size, action, cData]);
return (
<div
ref={containerRef}
className={`turnstile-widget ${className}`}
data-testid="turnstile-widget"
/>
);
}
);
TurnstileWidget.displayName = 'TurnstileWidget';
export default TurnstileWidget;

View File

@@ -1,19 +1,16 @@
import HCaptcha from '@hcaptcha/react-hcaptcha';
import { Turnstile } from '@marsidev/react-turnstile';
import { useStoreState } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Formik } from 'formik';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { object, string } from 'yup';
import FriendlyCaptcha from '@/components/FriendlyCaptcha';
import Captcha from '@/components/Captcha';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import Button from '@/components/elements/Button';
import ContentBox from '@/components/elements/ContentBox';
import Field from '@/components/elements/Field';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http';
import http from '@/api/http';
@@ -26,42 +23,39 @@ interface Values {
}
const ForgotPasswordContainer = () => {
const turnstileRef = useRef(null);
const friendlyCaptchaRef = useRef<{ reset: () => void }>(null);
const hCaptchaRef = useRef<HCaptcha>(null);
const captchaRef = useRef<any>(null);
const [token, setToken] = useState('');
const [friendlyLoaded, setFriendlyLoaded] = useState(false);
const { clearFlashes, addFlash } = useFlash();
const { captcha } = useStoreState((state) => state.settings.data!);
const isTurnstileEnabled = captcha.driver === 'turnstile' && captcha.turnstile?.siteKey;
const isFriendlyEnabled = captcha.driver === 'friendly' && captcha.friendly?.siteKey;
const isHCaptchaEnabled = captcha.driver === 'hcaptcha' && captcha.hcaptcha?.siteKey;
const isMCaptchaEnabled = captcha.driver === 'mcaptcha' && captcha.mcaptcha?.siteKey;
useEffect(() => {
clearFlashes();
const isCaptchaEnabled = captcha.driver !== 'none' && captcha.driver !== undefined;
let siteKey = '';
if (captcha.driver === 'turnstile') {
siteKey = captcha.turnstile?.siteKey || '';
} else if (captcha.driver === 'hcaptcha') {
siteKey = captcha.hcaptcha?.siteKey || '';
} else if (captcha.driver === 'friendly') {
siteKey = captcha.friendly?.siteKey || '';
} else if (captcha.driver === 'mcaptcha') {
siteKey = captcha.mcaptcha?.siteKey || '';
}
if (isFriendlyEnabled && !window.friendlyChallenge) {
const script = document.createElement('script');
script.src = 'https://unpkg.com/friendly-challenge@0.9.12/widget.module.min.js';
script.async = true;
script.defer = true;
script.onload = () => setFriendlyLoaded(true);
document.body.appendChild(script);
} else if (isFriendlyEnabled) {
setFriendlyLoaded(true);
const resetCaptcha = () => {
setToken('');
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
captchaRef.current.reset();
}
}, []);
};
const handleCaptchaComplete = (response: string) => {
const handleCaptchaSuccess = (response: string) => {
setToken(response);
};
const handleCaptchaError = (provider: string) => {
const handleCaptchaError = () => {
setToken('');
addFlash({ type: 'error', title: 'CAPTCHA Error', message: `${provider} challenge failed.` });
addFlash({ type: 'error', title: 'CAPTCHA Error', message: 'CAPTCHA challenge failed. Please try again.' });
};
const handleCaptchaExpire = () => {
@@ -71,22 +65,31 @@ const ForgotPasswordContainer = () => {
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes();
if ((isTurnstileEnabled || isFriendlyEnabled || isHCaptchaEnabled) && !token) {
// Validate CAPTCHA if enabled
if (isCaptchaEnabled && !token) {
addFlash({ type: 'error', title: 'Error', message: 'Please complete the CAPTCHA challenge.' });
setSubmitting(false);
return;
}
const requestData: Record<string, string> = { email };
const requestData: any = { email };
if (isTurnstileEnabled) {
requestData['cf-turnstile-response'] = token;
} else if (isHCaptchaEnabled) {
requestData['h-captcha-response'] = token;
} else if (isFriendlyEnabled) {
requestData['frc-captcha-response'] = token;
} else if (isMCaptchaEnabled) {
requestData['g-recaptcha-response'] = token; // Fallback or mCaptcha field
// Add CAPTCHA token based on provider
if (isCaptchaEnabled && token) {
switch (captcha.driver) {
case 'turnstile':
requestData['cf-turnstile-response'] = token;
break;
case 'hcaptcha':
requestData['h-captcha-response'] = token;
break;
case 'friendly':
requestData['frc-captcha-response'] = token;
break;
case 'mcaptcha':
requestData['mcaptcha-response'] = token;
break;
}
}
http.post('/auth/password', requestData)
@@ -99,8 +102,7 @@ const ForgotPasswordContainer = () => {
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
})
.finally(() => {
setToken('');
// Reset CAPTCHAs...
resetCaptcha();
setSubmitting(false);
});
};
@@ -128,50 +130,24 @@ const ForgotPasswordContainer = () => {
</div>
<Field id='email' label={'Email'} name={'email'} type={'email'} />
{/* CAPTCHA Components */}
{isTurnstileEnabled && (
<div className='mt-6'>
<Turnstile
ref={turnstileRef}
siteKey={captcha.turnstile.siteKey}
onSuccess={handleCaptchaComplete}
onError={() => handleCaptchaError('Turnstile')}
{/* CAPTCHA Component */}
{isCaptchaEnabled && siteKey && (
<div className='mt-6 flex justify-center'>
<Captcha
ref={captchaRef}
driver={captcha.driver}
sitekey={siteKey}
theme={(captcha.turnstile as any)?.theme || 'dark'}
size={(captcha.turnstile as any)?.size || 'flexible'}
action={(captcha.turnstile as any)?.action}
cData={(captcha.turnstile as any)?.cdata}
onVerify={handleCaptchaSuccess}
onError={handleCaptchaError}
onExpire={handleCaptchaExpire}
options={{
theme: 'dark',
size: 'flexible',
}}
className=""
/>
</div>
)}
{isFriendlyEnabled && friendlyLoaded && (
<div className='mt-6 w-full'>
<FriendlyCaptcha
ref={friendlyCaptchaRef}
sitekey={captcha.friendly.siteKey}
onComplete={handleCaptchaComplete}
onError={() => handleCaptchaError('FriendlyCaptcha')}
onExpire={handleCaptchaExpire}
/>
</div>
)}
{isHCaptchaEnabled && (
<div className='mt-6'>
<HCaptcha
ref={hCaptchaRef}
sitekey={captcha.hcaptcha.siteKey}
onVerify={handleCaptchaComplete}
onError={() => handleCaptchaError('hCaptcha')}
onExpire={handleCaptchaExpire}
theme='dark'
/>
</div>
)}
{isMCaptchaEnabled && (
<div className='mt-6'>
<p className='text-red-500'>mCaptcha implementation needed</p>
</div>
)}
<div className='mt-6'>
<Button

View File

@@ -1,5 +1,3 @@
import HCaptcha from '@hcaptcha/react-hcaptcha';
import { Turnstile } from '@marsidev/react-turnstile';
import { useStoreState } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Formik } from 'formik';
@@ -7,7 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { object, string } from 'yup';
import FriendlyCaptcha from '@/components/FriendlyCaptcha';
import Captcha from '@/components/Captcha';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
@@ -24,87 +22,81 @@ interface Values {
function LoginContainer() {
const [token, setToken] = useState('');
const [friendlyLoaded, setFriendlyLoaded] = useState(false);
const turnstileRef = useRef(null);
const friendlyCaptchaRef = useRef<{ reset: () => void }>(null);
const hCaptchaRef = useRef<HCaptcha>(null);
const mCaptchaRef = useRef<{ reset: () => void }>(null);
const captchaRef = useRef<any>(null);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { captcha } = useStoreState((state) => state.settings.data!);
const isTurnstileEnabled = captcha.driver === 'turnstile' && captcha.turnstile?.siteKey;
const isFriendlyEnabled = captcha.driver === 'friendly' && captcha.friendly?.siteKey;
const isHCaptchaEnabled = captcha.driver === 'hcaptcha' && captcha.hcaptcha?.siteKey;
const isMCaptchaEnabled = captcha.driver === 'mcaptcha' && captcha.mcaptcha?.siteKey;
const navigate = useNavigate();
useEffect(() => {
clearFlashes();
// Load FriendlyCaptcha script if needed
if (isFriendlyEnabled && !window.friendlyChallenge) {
const script = document.createElement('script');
script.src = 'https://unpkg.com/friendly-challenge@0.9.12/widget.module.min.js';
script.async = true;
script.defer = true;
script.onload = () => setFriendlyLoaded(true);
document.body.appendChild(script);
} else if (isFriendlyEnabled) {
setFriendlyLoaded(true);
}
}, []);
const resetCaptcha = () => {
setToken('');
if (isTurnstileEnabled && turnstileRef.current) {
// @ts-expect-error - The type doesn't expose the reset method directly
turnstileRef.current.reset();
}
if (isFriendlyEnabled && friendlyCaptchaRef.current) {
friendlyCaptchaRef.current.reset();
}
if (isHCaptchaEnabled && hCaptchaRef.current) {
hCaptchaRef.current.resetCaptcha();
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
captchaRef.current.reset();
}
};
const handleCaptchaComplete = (response: string) => {
const handleCaptchaSuccess = (response: string) => {
setToken(response);
};
const handleCaptchaError = (provider: string) => {
const handleCaptchaError = () => {
setToken('');
clearAndAddHttpError({ error: new Error(`${provider} challenge failed.`) });
clearAndAddHttpError({ error: new Error('CAPTCHA challenge failed. Please try again.') });
};
const handleCaptchaExpire = () => {
setToken('');
};
const isCaptchaEnabled = captcha.driver !== 'none' && captcha.driver !== undefined;
let siteKey = '';
if (captcha.driver === 'turnstile') {
siteKey = captcha.turnstile?.siteKey || '';
} else if (captcha.driver === 'hcaptcha') {
siteKey = captcha.hcaptcha?.siteKey || '';
} else if (captcha.driver === 'friendly') {
siteKey = captcha.friendly?.siteKey || '';
} else if (captcha.driver === 'mcaptcha') {
siteKey = captcha.mcaptcha?.siteKey || '';
}
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
if ((isTurnstileEnabled || isFriendlyEnabled || isHCaptchaEnabled) && !token) {
// Validate CAPTCHA if enabled
if (isCaptchaEnabled && !token) {
setSubmitting(false);
clearAndAddHttpError({ error: new Error('Please complete the CAPTCHA challenge.') });
return;
}
const requestData: Record<string, string> = {
const requestData: any = {
user: values.user,
password: values.password,
};
if (isTurnstileEnabled) {
requestData['cf-turnstile-response'] = token;
if (process.env.NODE_ENV === 'development') {
requestData['cf-turnstile-remoteip'] = 'localhost';
// Add CAPTCHA token based on provider
if (isCaptchaEnabled && token) {
switch (captcha.driver) {
case 'turnstile':
requestData['cf-turnstile-response'] = token;
break;
case 'hcaptcha':
requestData['h-captcha-response'] = token;
break;
case 'friendly':
requestData['frc-captcha-response'] = token;
break;
case 'mcaptcha':
requestData['mcaptcha-response'] = token;
break;
}
} else if (isHCaptchaEnabled) {
requestData['h-captcha-response'] = token;
} else if (isFriendlyEnabled) {
requestData['frc-captcha-response'] = token;
}
login(requestData)
@@ -152,7 +144,15 @@ function LoginContainer() {
</div>
<div aria-hidden className='my-8 bg-[#ffffff33] min-h-[1px]'></div>
<h2 className='text-xl font-extrabold mb-2'>Login</h2>
<Field id='user' type={'text'} label={'Username or Email'} name={'user'} disabled={isSubmitting} />
<Field
id='user'
type={'text'}
label={'Username or Email'}
name={'user'}
disabled={isSubmitting}
/>
<div className={`relative mt-6`}>
<Field
id='password'
@@ -169,55 +169,25 @@ function LoginContainer() {
</Link>
</div>
{/* CAPTCHA Providers */}
{isTurnstileEnabled && (
<div className='mt-6'>
<Turnstile
ref={turnstileRef}
siteKey={captcha.turnstile.siteKey}
onSuccess={handleCaptchaComplete}
onError={() => handleCaptchaError('Turnstile')}
{/* CAPTCHA Component */}
{isCaptchaEnabled && siteKey && (
<div className='mt-6 flex justify-center'>
<Captcha
ref={captchaRef}
driver={captcha.driver}
sitekey={siteKey}
theme={(captcha.turnstile as any)?.theme || 'dark'}
size={(captcha.turnstile as any)?.size || 'flexible'}
action={(captcha.turnstile as any)?.action}
cData={(captcha.turnstile as any)?.cdata}
onVerify={handleCaptchaSuccess}
onError={handleCaptchaError}
onExpire={handleCaptchaExpire}
options={{
theme: 'dark',
size: 'flexible',
}}
className=""
/>
</div>
)}
{isFriendlyEnabled && friendlyLoaded && (
<div className='mt-6 w-full'>
<FriendlyCaptcha
ref={friendlyCaptchaRef}
sitekey={captcha.friendly.siteKey}
onComplete={handleCaptchaComplete}
onError={() => handleCaptchaError('FriendlyCaptcha')}
onExpire={handleCaptchaExpire}
/>
</div>
)}
{isHCaptchaEnabled && (
<div className='mt-6'>
<HCaptcha
ref={hCaptchaRef}
sitekey={captcha.hcaptcha.siteKey}
onVerify={handleCaptchaComplete}
onError={() => handleCaptchaError('hCaptcha')}
onExpire={handleCaptchaExpire}
theme='dark'
size='normal'
/>
</div>
)}
{isMCaptchaEnabled && (
<div className='mt-6'>
<p className='text-red-500'>mCaptcha implementation needed</p>
</div>
)}
<div className={`mt-6`}>
<Button
className={`relative mt-4 w-full rounded-full bg-brand border-0 ring-0 outline-hidden capitalize font-bold text-sm py-2 hover:cursor-pointer`}

View File

@@ -143,6 +143,59 @@
</div>
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
<label class="control-label">Theme</label>
<div>
<select class="form-control" name="turnstile[theme]">
<option value="auto" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'auto') selected @endif>Auto</option>
<option value="light" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'light') selected @endif>Light</option>
<option value="dark" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'dark') selected @endif>Dark</option>
</select>
<p class="text-muted small">Widget color theme.</p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Size</label>
<div>
<select class="form-control" name="turnstile[size]">
<option value="normal" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'normal') selected @endif>Normal</option>
<option value="compact" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'compact') selected @endif>Compact</option>
<option value="flexible" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'flexible') selected @endif>Flexible</option>
</select>
<p class="text-muted small">Widget size.</p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Appearance</label>
<div>
<select class="form-control" name="turnstile[appearance]">
<option value="always" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'always') selected @endif>Always</option>
<option value="execute" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'execute') selected @endif>Execute</option>
<option value="interaction-only" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'interaction-only') selected @endif>Interaction Only</option>
</select>
<p class="text-muted small">When the widget becomes visible.</p>
</div>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label class="control-label">Action (Optional)</label>
<div>
<input type="text" class="form-control" name="turnstile[action]"
value="{{ old('turnstile.action', isset($current) ? $current['turnstile']['action'] ?? '' : '') }}">
<p class="text-muted small">Custom action identifier for analytics (max 32 chars, alphanumeric + _ -).</p>
</div>
</div>
<div class="form-group col-md-6">
<label class="control-label">Custom Data (Optional)</label>
<div>
<input type="text" class="form-control" name="turnstile[cdata]"
value="{{ old('turnstile.cdata', isset($current) ? $current['turnstile']['cdata'] ?? '' : '') }}">
<p class="text-muted small">Custom data passed to widget (max 255 chars, alphanumeric + _ -).</p>
</div>
</div>
</div>
</div>
</div>