From 4874e3f2c1e9b8e79338ff0903d6e4500ebe2cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 11:53:10 +0000 Subject: [PATCH 1/4] v3.0.0: Multi-provider captcha architecture This major release introduces a modular architecture supporting multiple captcha providers: **New Features:** - Multi-provider architecture with 4 captcha systems: * Google reCAPTCHA v2/v3 (existing, refactored) * Google reCAPTCHA Enterprise (with Google Cloud integration) * hCaptcha (privacy-focused alternative) * Math Captcha (native, no external dependencies) - Provider abstraction layer: * CaptchaProviderInterface: common contract for all providers * AbstractCaptchaProvider: shared functionality * CaptchaFactory: dynamic provider instantiation * Provider-specific templates - Enhanced configuration: * Provider selection dropdown in admin * Dynamic configuration fields per provider * Math Captcha difficulty levels (easy/medium/hard) * Google Enterprise project/key configuration **Technical Changes:** - Refactored eicaptcha.php to use provider pattern - Updated ConfigForm.php for provider management - Enhanced Debugger.php with provider checks - Added upgrade script for migration from 2.x - All templates now use provider includes for flexibility **Backward Compatibility:** - Automatic migration from 2.x to 3.0 - Defaults to google_recaptcha provider - All existing configurations preserved - Upgrade script: upgrade/upgrade-3.0.0.php **Architecture:** - PSR-4 compliant provider structure - Lazy loading of providers via factory - Session-based validation for Math Captcha - CSRF protection for Math Captcha - Extensible: new providers can be registered Breaking changes: Version bumped to 3.0.0 due to architectural changes --- eicaptcha.php | 242 ++++---------- src/ConfigForm.php | 69 +++- src/Debugger.php | 73 +++- src/Factory/CaptchaFactory.php | 165 +++++++++ src/Installer.php | 6 +- src/Provider/AbstractCaptchaProvider.php | 200 +++++++++++ src/Provider/CaptchaProviderInterface.php | 111 +++++++ src/Provider/GoogleEnterpriseProvider.php | 193 +++++++++++ src/Provider/GoogleRecaptchaProvider.php | 313 ++++++++++++++++++ src/Provider/HCaptchaProvider.php | 217 ++++++++++++ src/Provider/MathCaptchaProvider.php | 280 ++++++++++++++++ upgrade/upgrade-3.0.0.php | 62 ++++ .../hook/hookDisplayCustomerAccountForm.tpl | 33 +- .../hookDisplayNewsletterRegistration.tpl | 24 +- .../hook/providers/google_recaptcha.tpl | 53 +++ views/templates/hook/providers/hcaptcha.tpl | 25 ++ views/templates/hook/providers/math.tpl | 42 +++ 17 files changed, 1876 insertions(+), 232 deletions(-) create mode 100644 src/Factory/CaptchaFactory.php create mode 100644 src/Provider/AbstractCaptchaProvider.php create mode 100644 src/Provider/CaptchaProviderInterface.php create mode 100644 src/Provider/GoogleEnterpriseProvider.php create mode 100644 src/Provider/GoogleRecaptchaProvider.php create mode 100644 src/Provider/HCaptchaProvider.php create mode 100644 src/Provider/MathCaptchaProvider.php create mode 100644 upgrade/upgrade-3.0.0.php create mode 100644 views/templates/hook/providers/google_recaptcha.tpl create mode 100644 views/templates/hook/providers/hcaptcha.tpl create mode 100644 views/templates/hook/providers/math.tpl diff --git a/eicaptcha.php b/eicaptcha.php index d227b20..48ecacc 100644 --- a/eicaptcha.php +++ b/eicaptcha.php @@ -23,7 +23,8 @@ use Eicaptcha\Module\ConfigForm; use Eicaptcha\Module\Debugger; use Eicaptcha\Module\Installer; -use ReCaptcha\ReCaptcha; +use Eicaptcha\Module\Factory\CaptchaFactory; +use Eicaptcha\Module\Provider\CaptchaProviderInterface; class EiCaptcha extends Module { @@ -48,12 +49,17 @@ class EiCaptcha extends Module */ protected $captchaLang = 'en'; + /** + * @var CaptchaProviderInterface + */ + protected $captchaProvider; + public function __construct() { $this->author = 'hhennes'; $this->name = 'eicaptcha'; $this->tab = 'front_office_features'; - $this->version = '2.6.0'; + $this->version = '3.0.0'; $this->need_instance = 1; $this->bootstrap = true; @@ -141,6 +147,28 @@ public function getContext() return $this->context; } + /** + * Get the current captcha provider + * + * @return CaptchaProviderInterface + * + * @since 3.0.0 + */ + public function getCaptchaProvider() + { + if (null === $this->captchaProvider) { + try { + $this->captchaProvider = CaptchaFactory::create($this); + } catch (\Exception $e) { + $this->debugger->log('Error loading captcha provider: ' . $e->getMessage()); + // Fallback to google_recaptcha + $this->captchaProvider = CaptchaFactory::create($this, 'google_recaptcha'); + } + } + + return $this->captchaProvider; + } + /** * Module Configuration in Back Office * @@ -165,108 +193,15 @@ public function getContent() */ public function hookHeader(array $params) { - if (!$this->shouldDisplayToCustomer()) { - return; - } - - $captchaVersion = Configuration::get('CAPTCHA_VERSION'); - //Add Content box to contact form page in order to display captcha - if ($this->context->controller instanceof ContactController - && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 - ) { - $this->context->controller->registerJavascript( - 'modules-eicaptcha-contact-form', - 'modules/' . $this->name . '/views/js/eicaptcha-contact-form-v' . $captchaVersion . '.js' - ); - } + $provider = $this->getCaptchaProvider(); - if ($captchaVersion == 2) { - return $this->renderHeaderV2(); - } else { - return $this->renderHeaderV3(); + if (!$provider->shouldDisplayToCustomer()) { + return; } - } - - /** - * Return content for (re)captcha v2 - * - * @return string|void - */ - protected function renderHeaderV2() - { - if (( - ( - $this->context->controller instanceof AuthController - || $this->context->controller instanceof RegistrationController - ) - && Configuration::get('CAPTCHA_ENABLE_ACCOUNT') == 1 - ) - || - ($this->context->controller instanceof ContactController - && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 - ) - || Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1 - ) { - $this->context->controller->registerStylesheet( - 'module-eicaptcha', - 'modules/' . $this->name . '/views/css/eicaptcha.css' - ); - //Dynamic insertion of the content - $js = ''; - - if (($this->context->controller instanceof ContactController && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1)) { - $js .= ''; - } - return $js; - } + return $provider->renderHeader(['controller' => $this->context->controller]); } - /** - * Return content for recaptcha v3 - * - * @return string|void - */ - public function renderHeaderV3() - { - if ( - ($this->context->controller instanceof ContactController - && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 - ) - || Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1 - ) { - $publicKey = Configuration::get('CAPTCHA_PUBLIC_KEY'); - $js = ' - - '; - - return $js; - } - } /** * Add Captcha on the Customer Registration Form @@ -280,12 +215,8 @@ public function hookDisplayCustomerAccountForm(array $params) if ($this->context->controller->php_self != 'identity' && Configuration::get('CAPTCHA_ENABLE_ACCOUNT') == 1 ) { - $this->context->smarty->assign([ - 'captchaVersion' => Configuration::get('CAPTCHA_VERSION'), - 'publicKey' => Configuration::get('CAPTCHA_PUBLIC_KEY'), - 'captchalang' => $this->captchaLang, - 'captchatheme' => $this->themes[Configuration::get('CAPTCHA_THEME')], - ]); + $provider = $this->getCaptchaProvider(); + $this->context->smarty->assign($provider->getTemplateVars()); return $this->display(__FILE__, 'views/templates/hook/hookDisplayCustomerAccountForm.tpl'); } @@ -398,17 +329,14 @@ public function hookActionAdminControllerSetMedia(array $params) */ public function hookDisplayNewsletterRegistration(array $params) { + $provider = $this->getCaptchaProvider(); + if ( Configuration::get('CAPTCHA_ENABLE_NEWSLETTER') == 1 && $this->canUseCaptchaOnNewsletter() - && $this->shouldDisplayToCustomer() + && $provider->shouldDisplayToCustomer() ) { - $this->context->smarty->assign([ - 'captchaVersion' => Configuration::get('CAPTCHA_VERSION'), - 'publicKey' => Configuration::get('CAPTCHA_PUBLIC_KEY'), - 'captchalang' => $this->captchaLang, - 'captchatheme' => $this->themes[Configuration::get('CAPTCHA_THEME')], - ]); + $this->context->smarty->assign($provider->getTemplateVars()); return $this->display(__FILE__, 'views/templates/hook/hookDisplayNewsletterRegistration.tpl'); } @@ -425,9 +353,11 @@ public function hookDisplayNewsletterRegistration(array $params) */ public function hookActionNewsletterRegistrationBefore(array $params) { + $provider = $this->getCaptchaProvider(); + if (Configuration::get('CAPTCHA_ENABLE_NEWSLETTER') == 1 && $this->canUseCaptchaOnNewsletter() - && $this->shouldDisplayToCustomer() + && $provider->shouldDisplayToCustomer() ) { if (!$this->_validateCaptcha()) { $params['hookError'] = $this->l('Please validate the captcha field before submitting your request'); @@ -442,51 +372,22 @@ public function hookActionNewsletterRegistrationBefore(array $params) */ protected function _validateCaptcha() { - if (!$this->shouldDisplayToCustomer()) { + $provider = $this->getCaptchaProvider(); + + if (!$provider->shouldDisplayToCustomer()) { return true; } - $context = Context::getContext(); - $captchaVersion = Configuration::get('CAPTCHA_VERSION'); - $captchaV3MinScore = (float) Configuration::get('CAPTCHA_V3_MINIMAL_SCORE'); - //Fix issue if allow_url_open is set to 0 - if (function_exists('ini_get') && !ini_get('allow_url_fopen')) { - $recaptchaMethod = new \ReCaptcha\RequestMethod\CurlPost(); - } else { - $recaptchaMethod = null; - } - $captcha = new ReCaptcha(Configuration::get('CAPTCHA_PRIVATE_KEY'), $recaptchaMethod); - if ($captchaVersion == 3) { - $captcha->setScoreThreshold($captchaV3MinScore); - } - $result = $captcha->verify( - Tools::getValue('g-recaptcha-response'), - Tools::getRemoteAddr() - ); - - if (!$result->isSuccess()) { - $errorMessage = $this->l('Please validate the captcha field before submitting your request'); - $this->debugger->log($errorMessage); - $this->debugger->log(sprintf($this->l('Recaptcha response %s'), print_r($result->getErrorCodes(), true))); - if ($captchaVersion == 3) { - if ($result->getScore() < $captchaV3MinScore) { - $errorMessageV3 = - sprintf( - 'Your request has been blocked by the captcha system, due to a low score of %s, required score is %s', - $result->getScore(), - $captchaV3MinScore - ); - $this->debugger->log($errorMessageV3); - } - } - $context->controller->errors[] = $errorMessage; + $response = Tools::getValue('g-recaptcha-response'); + $remoteIp = Tools::getRemoteAddr(); - return false; - } + $isValid = $provider->validate($response, $remoteIp); - $this->debugger->log($this->l('Captcha submited with success')); + if (!$isValid) { + $this->context->controller->errors[] = $provider->getLastError(); + } - return true; + return $isValid; } /** @@ -500,13 +401,9 @@ protected function _validateCaptcha() */ public function hookActionGetEicaptchaParams(array $params) { - return [ - 'displayCaptcha' => $this->shouldDisplayToCustomer(), - 'captchaVersion' => Configuration::get('CAPTCHA_VERSION'), - 'publicKey' => Configuration::get('CAPTCHA_PUBLIC_KEY'), - 'captchaforcelang' => Configuration::get('CAPTCHA_FORCE_LANG'), - 'captchatheme' => $this->themes[Configuration::get('CAPTCHA_THEME')], - ]; + $provider = $this->getCaptchaProvider(); + + return $provider->getTemplateVars(); } /** @@ -518,13 +415,8 @@ public function hookActionGetEicaptchaParams(array $params) */ public function hookDisplayEicaptchaVerification(array $params) { - $this->context->smarty->assign([ - 'displayCaptcha' => $this->shouldDisplayToCustomer(), - 'captchaVersion' => Configuration::get('CAPTCHA_VERSION'), - 'publicKey' => Configuration::get('CAPTCHA_PUBLIC_KEY'), - 'captchalang' => $this->captchaLang, - 'captchatheme' => $this->themes[Configuration::get('CAPTCHA_THEME')], - ]); + $provider = $this->getCaptchaProvider(); + $this->context->smarty->assign($provider->getTemplateVars()); return $this->display(__FILE__, 'views/templates/hook/hookDisplayEicaptchaVerification.tpl'); } @@ -558,22 +450,4 @@ public function canUseCaptchaOnNewsletter() return false; } - - /** - * Define if the captcha should be displayed to the customer - * - * @return bool - */ - protected function shouldDisplayToCustomer() - { - if ( - Configuration::get('CAPTCHA_ENABLE_LOGGED_CUSTOMERS') == 0 - && $this->context->customer->id > 0 - && $this->context->customer->email != null - ) { - return false; - } - - return true; - } } diff --git a/src/ConfigForm.php b/src/ConfigForm.php index be3461d..343e072 100644 --- a/src/ConfigForm.php +++ b/src/ConfigForm.php @@ -20,6 +20,7 @@ use Configuration; use Context; use EiCaptcha; +use Eicaptcha\Module\Factory\CaptchaFactory; use HelperForm; use Language; use Tools; @@ -67,9 +68,21 @@ public function renderForm() 'general' => $this->l('General configuration'), 'advanced' => $this->l('Advanded parameters'), ], - 'description' => $this->l('To get your own public and private keys please click on the folowing link') - . '
https://www.google.com/recaptcha/intro/index.html', + 'description' => $this->l('Configure your captcha provider and protect your website from spam and bots'), 'input' => [ + [ + 'type' => 'select', + 'label' => $this->l('Captcha Provider'), + 'name' => 'CAPTCHA_PROVIDER', + 'required' => true, + 'desc' => $this->l('Select the captcha system you want to use'), + 'options' => [ + 'query' => $this->getAvailableProviders(), + 'id' => 'id', + 'name' => 'name', + ], + 'tab' => 'general', + ], [ 'type' => 'radio', 'label' => $this->l('Recaptcha Version'), @@ -384,6 +397,18 @@ public function postProcess() Configuration::updateValue('CAPTCHA_DEBUG', (int) Tools::getValue('CAPTCHA_DEBUG')); Configuration::updateValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', (int) Tools::getValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE')); Configuration::updateValue('CAPTCHA_LOAD_EVERYWHERE', (int) Tools::getValue('CAPTCHA_LOAD_EVERYWHERE')); + Configuration::updateValue('CAPTCHA_PROVIDER', Tools::getValue('CAPTCHA_PROVIDER')); + + // hCaptcha + Configuration::updateValue('CAPTCHA_HCAPTCHA_SITE_KEY', Tools::getValue('CAPTCHA_HCAPTCHA_SITE_KEY')); + Configuration::updateValue('CAPTCHA_HCAPTCHA_SECRET_KEY', Tools::getValue('CAPTCHA_HCAPTCHA_SECRET_KEY')); + + // Math Captcha + Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', Tools::getValue('CAPTCHA_MATH_DIFFICULTY')); + + // Google Enterprise + Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')); + Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY')); return $this->module->displayConfirmation($this->l('Settings updated')); } @@ -397,6 +422,7 @@ public function postProcess() public function getConfigFieldsValues() { return [ + 'CAPTCHA_PROVIDER' => Tools::getValue('CAPTCHA_PROVIDER', Configuration::get('CAPTCHA_PROVIDER') ?: 'google_recaptcha'), 'CAPTCHA_VERSION' => Tools::getValue('CAPTCHA_VERSION', Configuration::get('CAPTCHA_VERSION')), 'CAPTCHA_V3_MINIMAL_SCORE' => Tools::getValue('CAPTCHA_V3_MINIMAL_SCORE', Configuration::get('CAPTCHA_V3_MINIMAL_SCORE')), 'CAPTCHA_PRIVATE_KEY' => Tools::getValue('CAPTCHA_PRIVATE_KEY', Configuration::get('CAPTCHA_PRIVATE_KEY')), @@ -410,9 +436,48 @@ public function getConfigFieldsValues() 'CAPTCHA_DEBUG' => Tools::getValue('CAPTCHA_DEBUG', Configuration::get('CAPTCHA_DEBUG')), 'CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE' => Tools::getValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', Configuration::get('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE')), 'CAPTCHA_LOAD_EVERYWHERE' => Tools::getValue('CAPTCHA_LOAD_EVERYWHERE', Configuration::get('CAPTCHA_LOAD_EVERYWHERE')), + // hCaptcha + 'CAPTCHA_HCAPTCHA_SITE_KEY' => Tools::getValue('CAPTCHA_HCAPTCHA_SITE_KEY', Configuration::get('CAPTCHA_HCAPTCHA_SITE_KEY')), + 'CAPTCHA_HCAPTCHA_SECRET_KEY' => Tools::getValue('CAPTCHA_HCAPTCHA_SECRET_KEY', Configuration::get('CAPTCHA_HCAPTCHA_SECRET_KEY')), + // Math Captcha + 'CAPTCHA_MATH_DIFFICULTY' => Tools::getValue('CAPTCHA_MATH_DIFFICULTY', Configuration::get('CAPTCHA_MATH_DIFFICULTY') ?: 'easy'), + // Google Enterprise + 'CAPTCHA_GOOGLE_ENTERPRISE_PROJECT' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')), + 'CAPTCHA_GOOGLE_ENTERPRISE_KEY' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_KEY')), ]; } + /** + * Get available captcha providers + * + * @return array + * + * @since 3.0.0 + */ + protected function getAvailableProviders() + { + $providers = []; + + try { + $allProviders = CaptchaFactory::getAllProviders($this->module); + + foreach ($allProviders as $provider) { + $providers[] = [ + 'id' => $provider->getName(), + 'name' => $provider->getDisplayName(), + ]; + } + } catch (\Exception $e) { + // Fallback to default provider + $providers[] = [ + 'id' => 'google_recaptcha', + 'name' => 'Google reCAPTCHA', + ]; + } + + return $providers; + } + /** * Alias of l function with specific context * diff --git a/src/Debugger.php b/src/Debugger.php index 90e1e93..cff3424 100644 --- a/src/Debugger.php +++ b/src/Debugger.php @@ -19,6 +19,7 @@ use Configuration; use EiCaptcha; +use Eicaptcha\Module\Factory\CaptchaFactory; use Module; class Debugger @@ -91,6 +92,7 @@ public function debugModuleInstall() $overridesChecks = $this->checkOverrides(); $newsletterChecks = $this->checkNewsletter(); $hookHookDisplayCustomerAccountForm = $this->checkHookDisplayCustomerAccountForm(); + $providerChecks = $this->checkProviders(); $errors = array_merge( $errors, @@ -98,7 +100,8 @@ public function debugModuleInstall() $hookChecks['errors'], $overridesChecks['errors'], $hookHookDisplayCustomerAccountForm['errors'], - $newsletterChecks['errors'] + $newsletterChecks['errors'], + $providerChecks['errors'] ); $success = array_merge( @@ -107,7 +110,8 @@ public function debugModuleInstall() $hookChecks['success'], $overridesChecks['success'], $hookHookDisplayCustomerAccountForm['success'], - $newsletterChecks['success'] + $newsletterChecks['success'], + $providerChecks['success'] ); $this->module->getContext()->smarty->assign([ @@ -342,6 +346,71 @@ public function log($message) } } + /** + * Check the current captcha provider configuration + * + * @return array + * + * @since 3.0.0 + */ + protected function checkProviders() + { + $errors = $success = []; + + $currentProvider = Configuration::get('CAPTCHA_PROVIDER'); + + if (empty($currentProvider)) { + $errors[] = $this->l('No captcha provider selected'); + + return [ + 'errors' => $errors, + 'success' => $success, + ]; + } + + try { + $provider = CaptchaFactory::create($this->module, $currentProvider); + + $success[] = sprintf( + $this->l('Current captcha provider: %s'), + $provider->getDisplayName() + ); + + if ($provider->isConfigured()) { + $success[] = sprintf( + $this->l('Provider %s is properly configured'), + $provider->getDisplayName() + ); + } else { + $errors[] = sprintf( + $this->l('Provider %s is not properly configured. Please check your API keys.'), + $provider->getDisplayName() + ); + } + + // Check provider-specific requirements + switch ($currentProvider) { + case 'google_enterprise': + if (!class_exists('\Google\Cloud\RecaptchaEnterprise\V1\RecaptchaEnterpriseServiceClient')) { + $errors[] = $this->l('Google Cloud reCAPTCHA Enterprise library is not installed. Run: composer require google/cloud-recaptcha-enterprise'); + } else { + $success[] = $this->l('Google Cloud reCAPTCHA Enterprise library is installed'); + } + break; + } + } catch (\Exception $e) { + $errors[] = sprintf( + $this->l('Error loading provider: %s'), + $e->getMessage() + ); + } + + return [ + 'errors' => $errors, + 'success' => $success, + ]; + } + /** * Alias of l function with specific context * diff --git a/src/Factory/CaptchaFactory.php b/src/Factory/CaptchaFactory.php new file mode 100644 index 0000000..d6a3233 --- /dev/null +++ b/src/Factory/CaptchaFactory.php @@ -0,0 +1,165 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Factory; + +use Configuration; +use EiCaptcha; +use Eicaptcha\Module\Provider\CaptchaProviderInterface; +use Eicaptcha\Module\Provider\GoogleRecaptchaProvider; +use Eicaptcha\Module\Provider\GoogleEnterpriseProvider; +use Eicaptcha\Module\Provider\HCaptchaProvider; +use Eicaptcha\Module\Provider\MathCaptchaProvider; + +/** + * Class CaptchaFactory + * + * Factory for creating captcha provider instances + * + * @since 3.0.0 + */ +class CaptchaFactory +{ + /** + * @var array Map of provider names to their class names + */ + private static $providers = [ + 'google_recaptcha' => GoogleRecaptchaProvider::class, + 'google_enterprise' => GoogleEnterpriseProvider::class, + 'hcaptcha' => HCaptchaProvider::class, + 'math' => MathCaptchaProvider::class, + ]; + + /** + * @var array Cached provider instances + */ + private static $instances = []; + + /** + * Create a captcha provider instance based on configuration + * + * @param EiCaptcha $module Module instance + * @param string|null $providerName Provider name (null = auto-detect from config) + * + * @return CaptchaProviderInterface + * + * @throws \Exception If provider is not found + */ + public static function create(EiCaptcha $module, $providerName = null) + { + // Auto-detect provider from configuration + if ($providerName === null) { + $providerName = Configuration::get('CAPTCHA_PROVIDER'); + + // Backward compatibility: if no provider is set, use google_recaptcha + if (empty($providerName)) { + $providerName = 'google_recaptcha'; + } + } + + // Return cached instance if available + if (isset(self::$instances[$providerName])) { + return self::$instances[$providerName]; + } + + // Check if provider exists + if (!isset(self::$providers[$providerName])) { + throw new \Exception(sprintf('Captcha provider "%s" not found', $providerName)); + } + + $providerClass = self::$providers[$providerName]; + + // Check if class exists + if (!class_exists($providerClass)) { + throw new \Exception(sprintf('Captcha provider class "%s" not found', $providerClass)); + } + + // Create and cache instance + $provider = new $providerClass($module); + self::$instances[$providerName] = $provider; + + return $provider; + } + + /** + * Get all available providers + * + * @param EiCaptcha $module Module instance + * + * @return array Array of provider instances indexed by provider name + */ + public static function getAllProviders(EiCaptcha $module) + { + $providers = []; + + foreach (self::$providers as $name => $class) { + try { + $providers[$name] = self::create($module, $name); + } catch (\Exception $e) { + // Skip providers that cannot be instantiated + continue; + } + } + + return $providers; + } + + /** + * Register a new provider + * + * @param string $name Provider name + * @param string $className Provider class name + * + * @return void + */ + public static function registerProvider($name, $className) + { + self::$providers[$name] = $className; + } + + /** + * Check if a provider is registered + * + * @param string $name Provider name + * + * @return bool + */ + public static function hasProvider($name) + { + return isset(self::$providers[$name]); + } + + /** + * Get the list of registered provider names + * + * @return array + */ + public static function getProviderNames() + { + return array_keys(self::$providers); + } + + /** + * Clear cached instances + * + * @return void + */ + public static function clearCache() + { + self::$instances = []; + } +} diff --git a/src/Installer.php b/src/Installer.php index 8600d9d..cbe891a 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -97,7 +97,11 @@ protected function installConfigurations() && Configuration::updateValue('CAPTCHA_ENABLE_LOGGED_CUSTOMERS', 1) && Configuration::updateValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', version_compare(_PS_VERSION_, '8.0') < 0 ? '1' : '0' - ); + ) + && Configuration::updateValue('CAPTCHA_PROVIDER', 'google_recaptcha') + && Configuration::updateValue('CAPTCHA_VERSION', 2) + && Configuration::updateValue('CAPTCHA_V3_MINIMAL_SCORE', 0.5) + && Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', 'easy'); } /** diff --git a/src/Provider/AbstractCaptchaProvider.php b/src/Provider/AbstractCaptchaProvider.php new file mode 100644 index 0000000..4d43592 --- /dev/null +++ b/src/Provider/AbstractCaptchaProvider.php @@ -0,0 +1,200 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use Context; +use EiCaptcha; + +/** + * Class AbstractCaptchaProvider + * + * Base class with common functionality for all captcha providers + * + * @since 3.0.0 + */ +abstract class AbstractCaptchaProvider implements CaptchaProviderInterface +{ + /** + * @var EiCaptcha + */ + protected $module; + + /** + * @var Context + */ + protected $context; + + /** + * @var string Last error message + */ + protected $lastError = ''; + + /** + * @var array Themes available for providers that support theming + */ + protected $themes = [0 => 'light', 1 => 'dark']; + + /** + * @var string Captcha language + */ + protected $captchaLang = 'en'; + + /** + * Constructor + * + * @param EiCaptcha $module + */ + public function __construct(EiCaptcha $module) + { + $this->module = $module; + $this->context = $module->getContext(); + $this->captchaLang = $this->context->language->iso_code; + + // Allow forcing a specific language + $forceLang = Configuration::get('CAPTCHA_FORCE_LANG'); + if (!empty($forceLang) && \Validate::isLanguageIsoCode($forceLang)) { + $this->captchaLang = $forceLang; + } + } + + /** + * @inheritDoc + */ + public function getLastError() + { + return $this->lastError; + } + + /** + * Set the last error message + * + * @param string $error + */ + protected function setLastError($error) + { + $this->lastError = $error; + $this->log($error); + } + + /** + * Check if captcha should be displayed to the current customer + * + * @return bool + */ + public function shouldDisplayToCustomer() + { + if ( + Configuration::get('CAPTCHA_ENABLE_LOGGED_CUSTOMERS') == 0 + && $this->context->customer->id > 0 + && $this->context->customer->email != null + ) { + return false; + } + + return true; + } + + /** + * Log a message if debug mode is enabled + * + * @param string $message + */ + protected function log($message) + { + if (Configuration::get('CAPTCHA_DEBUG')) { + $this->module->getDebugger()->log(sprintf('[%s] %s', $this->getName(), $message)); + } + } + + /** + * Get the theme selected in configuration + * + * @return string Theme name (light or dark) + */ + protected function getTheme() + { + return $this->themes[Configuration::get('CAPTCHA_THEME')]; + } + + /** + * Get the captcha language + * + * @return string Language ISO code + */ + protected function getCaptchaLang() + { + return $this->captchaLang; + } + + /** + * @inheritDoc + */ + public function getCssFiles() + { + return []; + } + + /** + * @inheritDoc + */ + public function getJsFiles() + { + return []; + } + + /** + * Translate a string in the module context + * + * @param string $string + * + * @return string + */ + protected function l($string) + { + return $this->module->l($string, strtolower(str_replace('Provider', '', basename(get_class($this))))); + } + + /** + * @inheritDoc + */ + public function getTemplateVars() + { + return [ + 'displayCaptcha' => $this->shouldDisplayToCustomer(), + 'provider' => $this->getName(), + 'captchalang' => $this->captchaLang, + 'captchatheme' => $this->getTheme(), + ]; + } + + /** + * Get configuration value specific to this provider + * + * @param string $key Configuration key + * @param mixed $default Default value if not set + * + * @return mixed Configuration value + */ + protected function getConfig($key, $default = null) + { + $value = Configuration::get($key); + + return $value !== false ? $value : $default; + } +} diff --git a/src/Provider/CaptchaProviderInterface.php b/src/Provider/CaptchaProviderInterface.php new file mode 100644 index 0000000..9088e9b --- /dev/null +++ b/src/Provider/CaptchaProviderInterface.php @@ -0,0 +1,111 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +/** + * Interface CaptchaProviderInterface + * + * Defines the contract for all captcha providers + * + * @since 3.0.0 + */ +interface CaptchaProviderInterface +{ + /** + * Get the provider name/identifier + * + * @return string Provider identifier (e.g., 'google_recaptcha', 'hcaptcha', 'math') + */ + public function getName(); + + /** + * Get the provider display name for UI + * + * @return string Provider display name (e.g., 'Google reCAPTCHA', 'hCaptcha') + */ + public function getDisplayName(); + + /** + * Validate the captcha response + * + * @param string $response The captcha response token from the form + * @param string $remoteIp The user's IP address + * + * @return bool True if validation succeeds, false otherwise + */ + public function validate($response, $remoteIp); + + /** + * Get configuration fields specific to this provider + * Returns an array of HelperForm field definitions + * + * @return array Configuration fields array compatible with PrestaShop HelperForm + */ + public function getConfigFields(); + + /** + * Get JavaScript/HTML to include in the page header + * + * @param array $context Additional context (controller, form type, etc.) + * + * @return string HTML/JavaScript code to include in header + */ + public function renderHeader(array $context = []); + + /** + * Get template variables for rendering the captcha field + * + * @return array Smarty template variables + */ + public function getTemplateVars(); + + /** + * Check if the provider is properly configured + * + * @return bool True if all required configuration is present + */ + public function isConfigured(); + + /** + * Get the template file path for this provider + * + * @return string Template file path relative to module directory + */ + public function getTemplatePath(); + + /** + * Get the last error message (if validation failed) + * + * @return string Error message or empty string + */ + public function getLastError(); + + /** + * Get additional CSS files required by this provider + * + * @return array Array of CSS file paths + */ + public function getCssFiles(); + + /** + * Get additional JS files required by this provider + * + * @return array Array of JS file paths + */ + public function getJsFiles(); +} diff --git a/src/Provider/GoogleEnterpriseProvider.php b/src/Provider/GoogleEnterpriseProvider.php new file mode 100644 index 0000000..b7d7703 --- /dev/null +++ b/src/Provider/GoogleEnterpriseProvider.php @@ -0,0 +1,193 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use ContactController; + +/** + * Class GoogleEnterpriseProvider + * + * Provider for Google reCAPTCHA Enterprise + * + * Note: This provider extends GoogleRecaptchaProvider as the frontend implementation + * is identical. The main difference is in the backend validation which uses the + * Google Cloud reCAPTCHA Enterprise API. + * + * @since 3.0.0 + */ +class GoogleEnterpriseProvider extends GoogleRecaptchaProvider +{ + /** + * @inheritDoc + */ + public function getName() + { + return 'google_enterprise'; + } + + /** + * @inheritDoc + */ + public function getDisplayName() + { + return 'Google reCAPTCHA Enterprise'; + } + + /** + * @inheritDoc + */ + public function validate($response, $remoteIp) + { + if (!$this->shouldDisplayToCustomer()) { + return true; + } + + // Check if Google Cloud library is available + if (!class_exists('\Google\Cloud\RecaptchaEnterprise\V1\RecaptchaEnterpriseServiceClient')) { + $this->setLastError($this->l('Google Cloud reCAPTCHA Enterprise library is not installed. Please run: composer require google/cloud-recaptcha-enterprise')); + $this->log('Google Cloud reCAPTCHA Enterprise library not found'); + + // Fallback to standard reCAPTCHA validation + return parent::validate($response, $remoteIp); + } + + try { + $projectId = $this->getConfig('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT'); + $apiKey = $this->getConfig('CAPTCHA_GOOGLE_ENTERPRISE_KEY'); + + if (empty($projectId) || empty($apiKey)) { + $this->setLastError($this->l('Google reCAPTCHA Enterprise is not properly configured')); + + return false; + } + + // Create the reCAPTCHA Enterprise client + $client = new \Google\Cloud\RecaptchaEnterprise\V1\RecaptchaEnterpriseServiceClient([ + 'credentials' => $apiKey, + ]); + + // Prepare the assessment request + $projectName = $client->projectName($projectId); + $event = (new \Google\Cloud\RecaptchaEnterprise\V1\Event()) + ->setToken($response) + ->setSiteKey($this->getConfig('CAPTCHA_PUBLIC_KEY')); + + $assessment = (new \Google\Cloud\RecaptchaEnterprise\V1\Assessment()) + ->setEvent($event); + + $request = (new \Google\Cloud\RecaptchaEnterprise\V1\CreateAssessmentRequest()) + ->setParent($projectName) + ->setAssessment($assessment); + + // Create the assessment + $response = $client->createAssessment($request); + + // Check if the token is valid + if (!$response->getTokenProperties()->getValid()) { + $this->setLastError($this->l('Please validate the captcha field before submitting your request')); + $this->log('Invalid token: ' . $response->getTokenProperties()->getInvalidReason()); + + return false; + } + + // Check the risk score (for V3) + $captchaVersion = $this->getConfig('CAPTCHA_VERSION', 2); + if ($captchaVersion == 3) { + $score = $response->getRiskAnalysis()->getScore(); + $minScore = (float) $this->getConfig('CAPTCHA_V3_MINIMAL_SCORE', 0.5); + + if ($score < $minScore) { + $errorMessage = sprintf( + 'Your request has been blocked by the captcha system, due to a low score of %s, required score is %s', + $score, + $minScore + ); + $this->setLastError($this->l('Please validate the captcha field before submitting your request')); + $this->log($errorMessage); + + return false; + } + } + + $this->log($this->l('Captcha submitted with success')); + + return true; + } catch (\Exception $e) { + $this->setLastError($this->l('Error validating captcha with Google Enterprise: ') . $e->getMessage()); + $this->log('Google Enterprise validation error: ' . $e->getMessage()); + + // Fallback to standard validation in case of error + return parent::validate($response, $remoteIp); + } + } + + /** + * @inheritDoc + */ + public function getConfigFields() + { + $fields = parent::getConfigFields(); + + // Add Enterprise-specific fields + $enterpriseFields = [ + [ + 'type' => 'text', + 'label' => $this->l('Google Cloud Project ID'), + 'name' => 'CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', + 'required' => true, + 'desc' => $this->l('Your Google Cloud project ID where reCAPTCHA Enterprise is enabled'), + 'empty_message' => $this->l('Please fill the Google Cloud project ID'), + 'tab' => 'general', + ], + [ + 'type' => 'textarea', + 'label' => $this->l('Service Account Key JSON'), + 'name' => 'CAPTCHA_GOOGLE_ENTERPRISE_KEY', + 'required' => true, + 'desc' => $this->l('Paste the content of your service account JSON key file'), + 'empty_message' => $this->l('Please fill the service account key'), + 'tab' => 'general', + 'rows' => 10, + ], + ]; + + return array_merge($fields, $enterpriseFields); + } + + /** + * @inheritDoc + */ + public function isConfigured() + { + $publicKey = $this->getConfig('CAPTCHA_PUBLIC_KEY'); + $projectId = $this->getConfig('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT'); + $apiKey = $this->getConfig('CAPTCHA_GOOGLE_ENTERPRISE_KEY'); + + return !empty($publicKey) && !empty($projectId) && !empty($apiKey); + } + + /** + * @inheritDoc + */ + public function getTemplatePath() + { + // Use the same template as Google reCAPTCHA (frontend is identical) + return 'module:eicaptcha/views/templates/hook/providers/google_recaptcha.tpl'; + } +} diff --git a/src/Provider/GoogleRecaptchaProvider.php b/src/Provider/GoogleRecaptchaProvider.php new file mode 100644 index 0000000..cd52bf6 --- /dev/null +++ b/src/Provider/GoogleRecaptchaProvider.php @@ -0,0 +1,313 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use ContactController; +use ReCaptcha\ReCaptcha; +use Tools; + +/** + * Class GoogleRecaptchaProvider + * + * Provider for Google reCAPTCHA v2 and v3 + * + * @since 3.0.0 + */ +class GoogleRecaptchaProvider extends AbstractCaptchaProvider +{ + /** + * @inheritDoc + */ + public function getName() + { + return 'google_recaptcha'; + } + + /** + * @inheritDoc + */ + public function getDisplayName() + { + return 'Google reCAPTCHA v2/v3'; + } + + /** + * @inheritDoc + */ + public function validate($response, $remoteIp) + { + if (!$this->shouldDisplayToCustomer()) { + return true; + } + + $captchaVersion = $this->getConfig('CAPTCHA_VERSION', 2); + $captchaV3MinScore = (float) $this->getConfig('CAPTCHA_V3_MINIMAL_SCORE', 0.5); + + // Fix issue if allow_url_fopen is set to 0 + if (function_exists('ini_get') && !ini_get('allow_url_fopen')) { + $recaptchaMethod = new \ReCaptcha\RequestMethod\CurlPost(); + } else { + $recaptchaMethod = null; + } + + $captcha = new ReCaptcha($this->getConfig('CAPTCHA_PRIVATE_KEY'), $recaptchaMethod); + + if ($captchaVersion == 3) { + $captcha->setScoreThreshold($captchaV3MinScore); + } + + $result = $captcha->verify($response, $remoteIp); + + if (!$result->isSuccess()) { + $errorMessage = $this->l('Please validate the captcha field before submitting your request'); + $this->setLastError($errorMessage); + $this->log(sprintf($this->l('Recaptcha response %s'), print_r($result->getErrorCodes(), true))); + + if ($captchaVersion == 3) { + if ($result->getScore() < $captchaV3MinScore) { + $errorMessageV3 = sprintf( + 'Your request has been blocked by the captcha system, due to a low score of %s, required score is %s', + $result->getScore(), + $captchaV3MinScore + ); + $this->log($errorMessageV3); + } + } + + return false; + } + + $this->log($this->l('Captcha submitted with success')); + + return true; + } + + /** + * @inheritDoc + */ + public function getConfigFields() + { + return [ + [ + 'type' => 'radio', + 'label' => $this->l('Recaptcha Version'), + 'name' => 'CAPTCHA_VERSION', + 'required' => true, + 'class' => 't', + 'values' => [ + [ + 'id' => 'v2', + 'value' => 2, + 'label' => $this->l('V2'), + ], + [ + 'id' => 'v3', + 'value' => 3, + 'label' => $this->l('V3'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Captcha V3 minimum score'), + 'hint' => sprintf( + $this->l('The minimum score required to validate the captcha is a number between 0 and 1. Default is 0.5 (recommended: 0.5 for normal security, 0.3 for less strict, 0.7 for more strict). %s'), + 'Learn more' + ), + 'name' => 'CAPTCHA_V3_MINIMAL_SCORE', + 'required' => true, + 'class' => 'fixed-width-sm', + 'suffix' => '(0.0 - 1.0)', + 'empty_message' => $this->l('Please fill the captcha v3 minimal score.'), + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Captcha public key (Site key)'), + 'name' => 'CAPTCHA_PUBLIC_KEY', + 'required' => true, + 'empty_message' => $this->l('Please fill the captcha public key'), + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Captcha private key (Secret key)'), + 'name' => 'CAPTCHA_PRIVATE_KEY', + 'required' => true, + 'empty_message' => $this->l('Please fill the captcha private key'), + 'tab' => 'general', + ], + ]; + } + + /** + * @inheritDoc + */ + public function renderHeader(array $context = []) + { + if (!$this->shouldDisplayToCustomer()) { + return ''; + } + + $captchaVersion = $this->getConfig('CAPTCHA_VERSION', 2); + + // Add Content box to contact form page in order to display captcha + if (isset($context['controller']) && $context['controller'] instanceof ContactController + && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 + ) { + $this->context->controller->registerJavascript( + 'modules-eicaptcha-contact-form', + 'modules/' . $this->module->name . '/views/js/eicaptcha-contact-form-v' . $captchaVersion . '.js' + ); + } + + if ($captchaVersion == 2) { + return $this->renderHeaderV2(); + } else { + return $this->renderHeaderV3(); + } + } + + /** + * Return content for (re)captcha v2 + * + * @return string|void + */ + protected function renderHeaderV2() + { + $controller = $this->context->controller; + $loadEverywhere = Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1; + $isAuthController = $controller instanceof \AuthController || $controller instanceof \RegistrationController; + $isContactController = $controller instanceof ContactController; + + if (( + $isAuthController + && Configuration::get('CAPTCHA_ENABLE_ACCOUNT') == 1 + ) + || + ($isContactController + && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 + ) + || $loadEverywhere + ) { + $this->context->controller->registerStylesheet( + 'module-eicaptcha', + 'modules/' . $this->module->name . '/views/css/eicaptcha.css' + ); + + // Dynamic insertion of the content + $publicKey = $this->getConfig('CAPTCHA_PUBLIC_KEY'); + $theme = $this->getTheme(); + $lang = $this->getCaptchaLang(); + + $js = ''; + + if ($isContactController && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1) { + $js .= ''; + } + + return $js; + } + + return ''; + } + + /** + * Return content for recaptcha v3 + * + * @return string|void + */ + protected function renderHeaderV3() + { + $controller = $this->context->controller; + $loadEverywhere = Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1; + + if ( + ($controller instanceof ContactController + && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 + ) + || $loadEverywhere + ) { + $publicKey = $this->getConfig('CAPTCHA_PUBLIC_KEY'); + $js = ' + + '; + + return $js; + } + + return ''; + } + + /** + * @inheritDoc + */ + public function getTemplateVars() + { + $vars = parent::getTemplateVars(); + $vars['captchaVersion'] = $this->getConfig('CAPTCHA_VERSION', 2); + $vars['publicKey'] = $this->getConfig('CAPTCHA_PUBLIC_KEY'); + + return $vars; + } + + /** + * @inheritDoc + */ + public function isConfigured() + { + $publicKey = $this->getConfig('CAPTCHA_PUBLIC_KEY'); + $privateKey = $this->getConfig('CAPTCHA_PRIVATE_KEY'); + + return !empty($publicKey) && !empty($privateKey); + } + + /** + * @inheritDoc + */ + public function getTemplatePath() + { + return 'module:eicaptcha/views/templates/hook/providers/google_recaptcha.tpl'; + } +} diff --git a/src/Provider/HCaptchaProvider.php b/src/Provider/HCaptchaProvider.php new file mode 100644 index 0000000..f13942e --- /dev/null +++ b/src/Provider/HCaptchaProvider.php @@ -0,0 +1,217 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use ContactController; +use Tools; + +/** + * Class HCaptchaProvider + * + * Provider for hCaptcha (privacy-focused alternative to reCAPTCHA) + * + * @since 3.0.0 + */ +class HCaptchaProvider extends AbstractCaptchaProvider +{ + /** + * hCaptcha API verification endpoint + */ + const VERIFY_URL = 'https://hcaptcha.com/siteverify'; + + /** + * @inheritDoc + */ + public function getName() + { + return 'hcaptcha'; + } + + /** + * @inheritDoc + */ + public function getDisplayName() + { + return 'hCaptcha'; + } + + /** + * @inheritDoc + */ + public function validate($response, $remoteIp) + { + if (!$this->shouldDisplayToCustomer()) { + return true; + } + + if (empty($response)) { + $this->setLastError($this->l('Please validate the captcha field before submitting your request')); + + return false; + } + + $secretKey = $this->getConfig('CAPTCHA_HCAPTCHA_SECRET_KEY'); + + if (empty($secretKey)) { + $this->setLastError($this->l('hCaptcha secret key is not configured')); + + return false; + } + + // Prepare POST data + $data = [ + 'secret' => $secretKey, + 'response' => $response, + 'remoteip' => $remoteIp, + ]; + + // Make API request + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data), + ], + ]; + + $context = stream_context_create($options); + $verify = file_get_contents(self::VERIFY_URL, false, $context); + + if ($verify === false) { + $this->setLastError($this->l('Unable to verify captcha response with hCaptcha API')); + + return false; + } + + $verifyResponse = json_decode($verify, true); + + if (!isset($verifyResponse['success']) || $verifyResponse['success'] !== true) { + $errorMessage = $this->l('Please validate the captcha field before submitting your request'); + $this->setLastError($errorMessage); + + if (isset($verifyResponse['error-codes'])) { + $this->log(sprintf($this->l('hCaptcha response errors: %s'), print_r($verifyResponse['error-codes'], true))); + } + + return false; + } + + $this->log($this->l('Captcha submitted with success')); + + return true; + } + + /** + * @inheritDoc + */ + public function getConfigFields() + { + return [ + [ + 'type' => 'text', + 'label' => $this->l('hCaptcha Site Key'), + 'name' => 'CAPTCHA_HCAPTCHA_SITE_KEY', + 'required' => true, + 'desc' => $this->l('Get your site key from https://dashboard.hcaptcha.com/'), + 'empty_message' => $this->l('Please fill the hCaptcha site key'), + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('hCaptcha Secret Key'), + 'name' => 'CAPTCHA_HCAPTCHA_SECRET_KEY', + 'required' => true, + 'desc' => $this->l('Get your secret key from https://dashboard.hcaptcha.com/'), + 'empty_message' => $this->l('Please fill the hCaptcha secret key'), + 'tab' => 'general', + ], + ]; + } + + /** + * @inheritDoc + */ + public function renderHeader(array $context = []) + { + if (!$this->shouldDisplayToCustomer()) { + return ''; + } + + $controller = $this->context->controller; + $loadEverywhere = Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1; + $isAuthController = $controller instanceof \AuthController || $controller instanceof \RegistrationController; + $isContactController = $controller instanceof ContactController; + + if (( + $isAuthController + && Configuration::get('CAPTCHA_ENABLE_ACCOUNT') == 1 + ) + || + ($isContactController + && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 + ) + || $loadEverywhere + ) { + $this->context->controller->registerStylesheet( + 'module-eicaptcha', + 'modules/' . $this->module->name . '/views/css/eicaptcha.css' + ); + + $siteKey = $this->getConfig('CAPTCHA_HCAPTCHA_SITE_KEY'); + $lang = $this->getCaptchaLang(); + + // hCaptcha uses a different language code format + $js = ''; + + return $js; + } + + return ''; + } + + /** + * @inheritDoc + */ + public function getTemplateVars() + { + $vars = parent::getTemplateVars(); + $vars['siteKey'] = $this->getConfig('CAPTCHA_HCAPTCHA_SITE_KEY'); + + return $vars; + } + + /** + * @inheritDoc + */ + public function isConfigured() + { + $siteKey = $this->getConfig('CAPTCHA_HCAPTCHA_SITE_KEY'); + $secretKey = $this->getConfig('CAPTCHA_HCAPTCHA_SECRET_KEY'); + + return !empty($siteKey) && !empty($secretKey); + } + + /** + * @inheritDoc + */ + public function getTemplatePath() + { + return 'module:eicaptcha/views/templates/hook/providers/hcaptcha.tpl'; + } +} diff --git a/src/Provider/MathCaptchaProvider.php b/src/Provider/MathCaptchaProvider.php new file mode 100644 index 0000000..d49d78a --- /dev/null +++ b/src/Provider/MathCaptchaProvider.php @@ -0,0 +1,280 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use Tools; + +/** + * Class MathCaptchaProvider + * + * Simple math-based captcha provider (no external dependencies) + * + * @since 3.0.0 + */ +class MathCaptchaProvider extends AbstractCaptchaProvider +{ + /** + * Session key for storing the captcha answer + */ + const SESSION_KEY = 'eicaptcha_math_answer'; + + /** + * Session key for storing the captcha timestamp + */ + const SESSION_TIMESTAMP_KEY = 'eicaptcha_math_timestamp'; + + /** + * Session key for storing the captcha token (CSRF protection) + */ + const SESSION_TOKEN_KEY = 'eicaptcha_math_token'; + + /** + * Captcha expiration time (5 minutes) + */ + const EXPIRATION_TIME = 300; + + /** + * @inheritDoc + */ + public function getName() + { + return 'math'; + } + + /** + * @inheritDoc + */ + public function getDisplayName() + { + return 'Math Captcha'; + } + + /** + * @inheritDoc + */ + public function validate($response, $remoteIp) + { + if (!$this->shouldDisplayToCustomer()) { + return true; + } + + // Start session if not already started + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + + // Get user response + $userAnswer = Tools::getValue('math-captcha-response'); + $userToken = Tools::getValue('math-captcha-token'); + + if (empty($userAnswer)) { + $this->setLastError($this->l('Please solve the math captcha')); + + return false; + } + + // Check if captcha exists in session + if (!isset($_SESSION[self::SESSION_KEY]) || !isset($_SESSION[self::SESSION_TIMESTAMP_KEY]) || !isset($_SESSION[self::SESSION_TOKEN_KEY])) { + $this->setLastError($this->l('Captcha session expired, please refresh the page')); + + return false; + } + + // Check token (CSRF protection) + if ($userToken !== $_SESSION[self::SESSION_TOKEN_KEY]) { + $this->setLastError($this->l('Invalid captcha token')); + $this->log('Invalid captcha token - possible CSRF attack'); + + return false; + } + + // Check expiration + $timestamp = $_SESSION[self::SESSION_TIMESTAMP_KEY]; + if (time() - $timestamp > self::EXPIRATION_TIME) { + unset($_SESSION[self::SESSION_KEY], $_SESSION[self::SESSION_TIMESTAMP_KEY], $_SESSION[self::SESSION_TOKEN_KEY]); + $this->setLastError($this->l('Captcha expired, please refresh the page')); + + return false; + } + + // Validate answer + $correctAnswer = $_SESSION[self::SESSION_KEY]; + $isValid = ((int) $userAnswer === $correctAnswer); + + // Clear session (one-time use) + unset($_SESSION[self::SESSION_KEY], $_SESSION[self::SESSION_TIMESTAMP_KEY], $_SESSION[self::SESSION_TOKEN_KEY]); + + if (!$isValid) { + $this->setLastError($this->l('Incorrect answer to the math captcha')); + $this->log(sprintf('Math captcha failed: expected %s, got %s', $correctAnswer, $userAnswer)); + + return false; + } + + $this->log($this->l('Captcha submitted with success')); + + return true; + } + + /** + * @inheritDoc + */ + public function getConfigFields() + { + return [ + [ + 'type' => 'radio', + 'label' => $this->l('Math Captcha Difficulty'), + 'name' => 'CAPTCHA_MATH_DIFFICULTY', + 'required' => true, + 'values' => [ + [ + 'id' => 'easy', + 'value' => 'easy', + 'label' => $this->l('Easy (1-10)'), + ], + [ + 'id' => 'medium', + 'value' => 'medium', + 'label' => $this->l('Medium (1-50)'), + ], + [ + 'id' => 'hard', + 'value' => 'hard', + 'label' => $this->l('Hard (1-100)'), + ], + ], + 'tab' => 'general', + ], + ]; + } + + /** + * @inheritDoc + */ + public function renderHeader(array $context = []) + { + // No external scripts needed for math captcha + return ''; + } + + /** + * @inheritDoc + */ + public function getTemplateVars() + { + $vars = parent::getTemplateVars(); + + // Generate new math question + $mathData = $this->generateMathQuestion(); + + $vars['mathQuestion'] = $mathData['question']; + $vars['mathToken'] = $mathData['token']; + + return $vars; + } + + /** + * Generate a new math question + * + * @return array Array with 'question', 'answer', and 'token' + */ + protected function generateMathQuestion() + { + // Start session if not already started + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + + $difficulty = $this->getConfig('CAPTCHA_MATH_DIFFICULTY', 'easy'); + + // Determine number range based on difficulty + switch ($difficulty) { + case 'hard': + $max = 100; + break; + case 'medium': + $max = 50; + break; + case 'easy': + default: + $max = 10; + break; + } + + // Generate random numbers + $num1 = rand(1, $max); + $num2 = rand(1, $max); + + // Random operation (addition or subtraction) + $operations = ['+', '-']; + $operation = $operations[array_rand($operations)]; + + // Ensure no negative results for subtraction + if ($operation === '-' && $num1 < $num2) { + $temp = $num1; + $num1 = $num2; + $num2 = $temp; + } + + // Calculate answer + switch ($operation) { + case '+': + $answer = $num1 + $num2; + break; + case '-': + $answer = $num1 - $num2; + break; + default: + $answer = $num1 + $num2; + } + + // Generate CSRF token + $token = bin2hex(random_bytes(16)); + + // Store in session + $_SESSION[self::SESSION_KEY] = $answer; + $_SESSION[self::SESSION_TIMESTAMP_KEY] = time(); + $_SESSION[self::SESSION_TOKEN_KEY] = $token; + + // Return question + return [ + 'question' => sprintf('%d %s %d', $num1, $operation, $num2), + 'answer' => $answer, + 'token' => $token, + ]; + } + + /** + * @inheritDoc + */ + public function isConfigured() + { + // Math captcha doesn't require external configuration + return true; + } + + /** + * @inheritDoc + */ + public function getTemplatePath() + { + return 'module:eicaptcha/views/templates/hook/providers/math.tpl'; + } +} diff --git a/upgrade/upgrade-3.0.0.php b/upgrade/upgrade-3.0.0.php new file mode 100644 index 0000000..41c1f61 --- /dev/null +++ b/upgrade/upgrade-3.0.0.php @@ -0,0 +1,62 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * Upgrade to version 3.0.0 + * Migrates from single captcha system to multi-provider architecture + * + * @param EiCaptcha $module + * + * @return bool + */ +function upgrade_module_3_0_0($module) +{ + $result = true; + + // Set default provider to google_recaptcha for backward compatibility + if (!Configuration::get('CAPTCHA_PROVIDER')) { + $result = $result && Configuration::updateValue('CAPTCHA_PROVIDER', 'google_recaptcha'); + } + + // Ensure CAPTCHA_VERSION is set (default to V2 if not set) + if (!Configuration::get('CAPTCHA_VERSION')) { + $result = $result && Configuration::updateValue('CAPTCHA_VERSION', 2); + } + + // Ensure CAPTCHA_V3_MINIMAL_SCORE is set + if (!Configuration::get('CAPTCHA_V3_MINIMAL_SCORE')) { + $result = $result && Configuration::updateValue('CAPTCHA_V3_MINIMAL_SCORE', 0.5); + } + + // Initialize new provider configurations + if (!Configuration::get('CAPTCHA_MATH_DIFFICULTY')) { + $result = $result && Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', 'easy'); + } + + // Log migration if debug is enabled + if (Configuration::get('CAPTCHA_DEBUG')) { + $debugger = $module->getDebugger(); + $debugger->log('Module upgraded to version 3.0.0 - Multi-provider architecture enabled'); + $debugger->log('Current provider: ' . Configuration::get('CAPTCHA_PROVIDER')); + } + + return $result; +} diff --git a/views/templates/hook/hookDisplayCustomerAccountForm.tpl b/views/templates/hook/hookDisplayCustomerAccountForm.tpl index 1a4a164..72a5950 100644 --- a/views/templates/hook/hookDisplayCustomerAccountForm.tpl +++ b/views/templates/hook/hookDisplayCustomerAccountForm.tpl @@ -23,29 +23,10 @@ * http://www.h-hennes.fr/blog/ *} -
- {if $captchaVersion == 2} - -
- {** - * Le contenu du captcha est automatiquement ajouté dans le selecteur #captcha-box - * Captcha content is automaticaly added into the selector #captcha-box - *} -
- -
- {else} - - - - {/if} -
+{* Load the provider-specific template *} +{if isset($provider) && $provider} + {include file="module:eicaptcha/views/templates/hook/providers/{$provider}.tpl"} +{else} + {* Fallback to google_recaptcha for backward compatibility *} + {include file="module:eicaptcha/views/templates/hook/providers/google_recaptcha.tpl"} +{/if} diff --git a/views/templates/hook/hookDisplayNewsletterRegistration.tpl b/views/templates/hook/hookDisplayNewsletterRegistration.tpl index 35bda4e..6872c17 100644 --- a/views/templates/hook/hookDisplayNewsletterRegistration.tpl +++ b/views/templates/hook/hookDisplayNewsletterRegistration.tpl @@ -23,20 +23,10 @@ * http://www.h-hennes.fr/blog/ *} -
- {if $captchaVersion == 2} -
- - {else} - - - - {/if} -
+{* Load the provider-specific template *} +{if isset($provider) && $provider} + {include file="module:eicaptcha/views/templates/hook/providers/{$provider}.tpl"} +{else} + {* Fallback to google_recaptcha for backward compatibility *} + {include file="module:eicaptcha/views/templates/hook/providers/google_recaptcha.tpl"} +{/if} diff --git a/views/templates/hook/providers/google_recaptcha.tpl b/views/templates/hook/providers/google_recaptcha.tpl new file mode 100644 index 0000000..e527a89 --- /dev/null +++ b/views/templates/hook/providers/google_recaptcha.tpl @@ -0,0 +1,53 @@ +{* +* 2007-2016 PrestaShop +* +* NOTICE OF LICENSE +* +* This source file is subject to the Academic Free License (AFL 3.0) +* that is bundled with this package in the file LICENSE.txt. +* It is also available through the world-wide-web at this URL: +* http://opensource.org/licenses/afl-3.0.php +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* DISCLAIMER +* +* Do not edit or add to this file if you wish to upgrade PrestaShop to newer +* versions in the future. If you wish to customize PrestaShop for your +* needs please refer to http://www.prestashop.com for more information. +* +* @author Hennes Hervé +* @copyright Hennes Hervé +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +* http://www.h-hennes.fr/blog/ +*} + +{if $displayCaptcha} +
+ {if $captchaVersion == 2} + +
+ {** + * Le contenu du captcha est automatiquement ajouté dans le selecteur #captcha-box + * Captcha content is automaticaly added into the selector #captcha-box + *} +
+ +
+ {else} + + + + {/if} +
+{/if} diff --git a/views/templates/hook/providers/hcaptcha.tpl b/views/templates/hook/providers/hcaptcha.tpl new file mode 100644 index 0000000..a8e8f81 --- /dev/null +++ b/views/templates/hook/providers/hcaptcha.tpl @@ -0,0 +1,25 @@ +{* +* NOTICE OF LICENSE +* +* This source file is subject to the Academic Free License (AFL 3.0) +* that is bundled with this package in the file LICENSE.txt. +* It is also available through the world-wide-web at this URL: +* http://opensource.org/licenses/afl-3.0.php +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* @author Hennes Hervé +* @copyright Hennes Hervé +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +* http://www.h-hennes.fr/blog/ +*} + +{if $displayCaptcha} +
+ +
+
+
+
+{/if} diff --git a/views/templates/hook/providers/math.tpl b/views/templates/hook/providers/math.tpl new file mode 100644 index 0000000..9e28275 --- /dev/null +++ b/views/templates/hook/providers/math.tpl @@ -0,0 +1,42 @@ +{* +* NOTICE OF LICENSE +* +* This source file is subject to the Academic Free License (AFL 3.0) +* that is bundled with this package in the file LICENSE.txt. +* It is also available through the world-wide-web at this URL: +* http://opensource.org/licenses/afl-3.0.php +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* @author Hennes Hervé +* @copyright Hennes Hervé +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +* http://www.h-hennes.fr/blog/ +*} + +{if $displayCaptcha} +
+ +
+
+ + + +
+ + {l s='Please solve this simple math problem to verify you are human' mod='eicaptcha'} + +
+
+{/if} From f8d3571a94722a9f0cd59af71b03a8fe134e9426 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 12:02:47 +0000 Subject: [PATCH 2/4] docs: Add comprehensive provider development guide - Complete guide for creating custom captcha providers - Step-by-step implementation instructions - Code examples for all required methods - Template creation guidelines - Best practices and security recommendations - Real-world examples (Cloudflare Turnstile) - Troubleshooting and testing checklist --- PROVIDER.md | 742 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 PROVIDER.md diff --git a/PROVIDER.md b/PROVIDER.md new file mode 100644 index 0000000..047e78f --- /dev/null +++ b/PROVIDER.md @@ -0,0 +1,742 @@ +# Guide de développement de providers pour eicaptcha + +Ce document explique comment créer et intégrer un nouveau provider de captcha dans le module eicaptcha. + +## Table des matières + +- [Architecture](#architecture) +- [Créer un nouveau provider](#créer-un-nouveau-provider) +- [Implémenter les méthodes](#implémenter-les-méthodes) +- [Créer le template](#créer-le-template) +- [Enregistrer le provider](#enregistrer-le-provider) +- [Tests et validation](#tests-et-validation) +- [Exemples complets](#exemples-complets) +- [Best practices](#best-practices) + +## Architecture + +Le module eicaptcha v3.0 utilise une architecture modulaire basée sur le pattern Strategy : + +``` +src/Provider/ +├── CaptchaProviderInterface.php # Interface à implémenter +├── AbstractCaptchaProvider.php # Classe de base (optionnelle) +└── [VotreProvider]Provider.php # Votre implémentation +``` + +### Composants principaux + +- **CaptchaProviderInterface** : Contrat que tous les providers doivent respecter +- **AbstractCaptchaProvider** : Classe abstraite avec logique commune (recommandée) +- **CaptchaFactory** : Factory pour instancier les providers dynamiquement + +## Créer un nouveau provider + +### Étape 1 : Créer la classe du provider + +Créez un nouveau fichier dans `src/Provider/` nommé `[NomProvider]Provider.php`. + +**Exemple : Cloudflare Turnstile** + +```php +shouldDisplayToCustomer()) { + return true; + } + + // 2. Vérifier que la réponse n'est pas vide + if (empty($response)) { + $this->setLastError($this->l('Please validate the captcha field')); + return false; + } + + // 3. Récupérer les clés de configuration + $secretKey = $this->getConfig('CAPTCHA_TURNSTILE_SECRET_KEY'); + + if (empty($secretKey)) { + $this->setLastError($this->l('Provider not configured')); + return false; + } + + // 4. Préparer la requête API + $data = [ + 'secret' => $secretKey, + 'response' => $response, + 'remoteip' => $remoteIp, + ]; + + // 5. Appeler l'API de vérification + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data), + ], + ]; + + $context = stream_context_create($options); + $verify = file_get_contents(self::VERIFY_URL, false, $context); + + if ($verify === false) { + $this->setLastError($this->l('Unable to verify captcha')); + return false; + } + + // 6. Traiter la réponse + $verifyResponse = json_decode($verify, true); + + if (!isset($verifyResponse['success']) || $verifyResponse['success'] !== true) { + $this->setLastError($this->l('Captcha validation failed')); + + // Logger les erreurs si disponibles + if (isset($verifyResponse['error-codes'])) { + $this->log('Errors: ' . print_r($verifyResponse['error-codes'], true)); + } + + return false; + } + + // 7. Validation réussie + $this->log($this->l('Captcha validated successfully')); + return true; +} +``` + +#### 4. `getConfigFields()` : array + +Retourne les champs de configuration à afficher dans l'admin PrestaShop. + +**Format :** Array compatible avec `HelperForm` de PrestaShop + +```php +public function getConfigFields() +{ + return [ + [ + 'type' => 'text', + 'label' => $this->l('Turnstile Site Key'), + 'name' => 'CAPTCHA_TURNSTILE_SITE_KEY', + 'required' => true, + 'desc' => $this->l('Get your site key from Cloudflare dashboard'), + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Turnstile Secret Key'), + 'name' => 'CAPTCHA_TURNSTILE_SECRET_KEY', + 'required' => true, + 'desc' => $this->l('Get your secret key from Cloudflare dashboard'), + 'tab' => 'general', + ], + [ + 'type' => 'radio', + 'label' => $this->l('Widget Theme'), + 'name' => 'CAPTCHA_TURNSTILE_THEME', + 'values' => [ + ['id' => 'light', 'value' => 'light', 'label' => $this->l('Light')], + ['id' => 'dark', 'value' => 'dark', 'label' => $this->l('Dark')], + ['id' => 'auto', 'value' => 'auto', 'label' => $this->l('Auto')], + ], + 'tab' => 'general', + ], + ]; +} +``` + +**Types de champs disponibles :** +- `text` : Champ texte simple +- `textarea` : Zone de texte multi-lignes +- `radio` : Boutons radio +- `switch` : Interrupteur ON/OFF +- `select` : Liste déroulante +- `html` : Contenu HTML personnalisé + +#### 5. `renderHeader(array $context = [])` : string + +Retourne le HTML/JavaScript à insérer dans le `` de la page. + +**Paramètre :** +- `$context` : Array contenant `controller`, `form_type`, etc. + +```php +public function renderHeader(array $context = []) +{ + if (!$this->shouldDisplayToCustomer()) { + return ''; + } + + $controller = $this->context->controller; + $loadEverywhere = Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1; + + $shouldLoad = ( + ($controller instanceof \AuthController && Configuration::get('CAPTCHA_ENABLE_ACCOUNT')) + || ($controller instanceof \ContactController && Configuration::get('CAPTCHA_ENABLE_CONTACT')) + || $loadEverywhere + ); + + if ($shouldLoad) { + $siteKey = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + + return ''; + } + + return ''; +} +``` + +#### 6. `getTemplateVars()` : array + +Retourne les variables Smarty à passer au template. + +```php +public function getTemplateVars() +{ + // Récupérer les variables de base (displayCaptcha, provider, lang, theme) + $vars = parent::getTemplateVars(); + + // Ajouter des variables spécifiques + $vars['siteKey'] = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + $vars['turnstileTheme'] = $this->getConfig('CAPTCHA_TURNSTILE_THEME', 'auto'); + + return $vars; +} +``` + +#### 7. `isConfigured()` : bool + +Vérifie que le provider est correctement configuré. + +```php +public function isConfigured() +{ + $siteKey = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + $secretKey = $this->getConfig('CAPTCHA_TURNSTILE_SECRET_KEY'); + + return !empty($siteKey) && !empty($secretKey); +} +``` + +#### 8. `getTemplatePath()` : string + +Retourne le chemin du template Smarty. + +```php +public function getTemplatePath() +{ + return 'module:eicaptcha/views/templates/hook/providers/turnstile.tpl'; +} +``` + +### Méthodes optionnelles + +#### `getCssFiles()` : array + +Retourne un array de fichiers CSS à charger. + +```php +public function getCssFiles() +{ + return [ + 'modules/eicaptcha/views/css/turnstile.css', + ]; +} +``` + +#### `getJsFiles()` : array + +Retourne un array de fichiers JavaScript à charger. + +```php +public function getJsFiles() +{ + return [ + 'modules/eicaptcha/views/js/turnstile.js', + ]; +} +``` + +## Créer le template + +### Étape 2 : Créer le template Smarty + +Créez un fichier `views/templates/hook/providers/[nom_provider].tpl`. + +**Exemple : turnstile.tpl** + +```smarty +{* +* Cloudflare Turnstile Template +* +* @author Your Name +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +*} + +{if $displayCaptcha} +
+ +
+
+
+
+{/if} +``` + +### Variables Smarty disponibles + +Variables fournies par `AbstractCaptchaProvider::getTemplateVars()` : + +- `$displayCaptcha` : bool - Si le captcha doit être affiché +- `$provider` : string - Nom du provider (`getName()`) +- `$captchalang` : string - Code langue (ex: 'fr', 'en') +- `$captchatheme` : string - Thème ('light' ou 'dark') + +Variables personnalisées ajoutées dans `getTemplateVars()` de votre provider. + +## Enregistrer le provider + +### Étape 3 : Ajouter le provider à la Factory + +Éditez `src/Factory/CaptchaFactory.php` et ajoutez votre provider : + +```php +private static $providers = [ + 'google_recaptcha' => GoogleRecaptchaProvider::class, + 'google_enterprise' => GoogleEnterpriseProvider::class, + 'hcaptcha' => HCaptchaProvider::class, + 'math' => MathCaptchaProvider::class, + 'turnstile' => TurnstileProvider::class, // <- Ajouter ici +]; +``` + +N'oubliez pas d'ajouter le `use` en haut du fichier : + +```php +use Eicaptcha\Module\Provider\TurnstileProvider; +``` + +### Étape 4 : Mettre à jour ConfigForm.php + +Ajoutez les nouveaux champs de configuration dans `src/ConfigForm.php` : + +**Dans `postProcess()` :** + +```php +// Turnstile +Configuration::updateValue('CAPTCHA_TURNSTILE_SITE_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY')); +Configuration::updateValue('CAPTCHA_TURNSTILE_SECRET_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY')); +Configuration::updateValue('CAPTCHA_TURNSTILE_THEME', Tools::getValue('CAPTCHA_TURNSTILE_THEME')); +``` + +**Dans `getConfigFieldsValues()` :** + +```php +// Turnstile +'CAPTCHA_TURNSTILE_SITE_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY', Configuration::get('CAPTCHA_TURNSTILE_SITE_KEY')), +'CAPTCHA_TURNSTILE_SECRET_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY', Configuration::get('CAPTCHA_TURNSTILE_SECRET_KEY')), +'CAPTCHA_TURNSTILE_THEME' => Tools::getValue('CAPTCHA_TURNSTILE_THEME', Configuration::get('CAPTCHA_TURNSTILE_THEME') ?: 'auto'), +``` + +### Étape 5 : Mettre à jour Installer.php (optionnel) + +Si vous voulez définir des valeurs par défaut : + +```php +protected function installConfigurations() +{ + return Configuration::updateGlobalValue('CAPTCHA_ENABLE_ACCOUNT', 0) + // ... autres configs ... + && Configuration::updateValue('CAPTCHA_TURNSTILE_THEME', 'auto'); +} +``` + +### Étape 6 : Adapter le code pour récupérer la réponse + +Si votre provider utilise un nom de champ différent de `g-recaptcha-response`, adaptez `eicaptcha.php::_validateCaptcha()` : + +```php +protected function _validateCaptcha() +{ + $provider = $this->getCaptchaProvider(); + + if (!$provider->shouldDisplayToCustomer()) { + return true; + } + + // Adaptez selon votre provider + $fieldName = 'g-recaptcha-response'; // Défaut + + if ($provider->getName() === 'turnstile') { + $fieldName = 'cf-turnstile-response'; + } elseif ($provider->getName() === 'math') { + $fieldName = 'math-captcha-response'; + } + + $response = Tools::getValue($fieldName); + $remoteIp = Tools::getRemoteAddr(); + + $isValid = $provider->validate($response, $remoteIp); + + if (!$isValid) { + $this->context->controller->errors[] = $provider->getLastError(); + } + + return $isValid; +} +``` + +**Ou mieux, créez une méthode dans l'interface :** + +```php +// Dans CaptchaProviderInterface.php +public function getResponseFieldName(); + +// Dans votre provider +public function getResponseFieldName() +{ + return 'cf-turnstile-response'; +} +``` + +## Tests et validation + +### Liste de vérification + +- [ ] Le provider apparaît dans la liste déroulante admin +- [ ] Les champs de configuration s'affichent correctement +- [ ] Le captcha s'affiche sur le formulaire de contact +- [ ] Le captcha s'affiche sur le formulaire d'inscription +- [ ] La validation fonctionne (accepte les bonnes réponses) +- [ ] La validation rejette les mauvaises réponses +- [ ] Les messages d'erreur sont affichés +- [ ] Le debug log fonctionne +- [ ] Compatible avec les clients connectés (si désactivé) +- [ ] Le template s'adapte aux thèmes + +### Tests manuels + +1. **Installation** + ```bash + # Vider le cache PrestaShop + rm -rf var/cache/* + ``` + +2. **Configuration** + - Aller dans Modules > Ei Captcha + - Sélectionner votre provider + - Renseigner les clés + - Sauvegarder + +3. **Tests formulaires** + - Tester formulaire contact (avec/sans captcha valide) + - Tester inscription client (avec/sans captcha valide) + - Vérifier les erreurs s'affichent + +4. **Debug** + - Activer le mode debug + - Vérifier `logs/debug.log` + +## Exemples complets + +### Exemple 1 : Provider avec API simple (comme hCaptcha) + +Voir `src/Provider/HCaptchaProvider.php` pour un exemple complet. + +**Caractéristiques :** +- Validation via POST à une API REST +- Deux clés : Site Key + Secret Key +- Réponse JSON simple + +### Exemple 2 : Provider sans dépendances externes (Math Captcha) + +Voir `src/Provider/MathCaptchaProvider.php` pour un exemple complet. + +**Caractéristiques :** +- Génération côté serveur +- Stockage en session +- Protection CSRF +- Pas d'API externe + +### Exemple 3 : Provider avec librairie externe (Google Enterprise) + +Voir `src/Provider/GoogleEnterpriseProvider.php` pour un exemple complet. + +**Caractéristiques :** +- Utilise SDK Google Cloud +- Vérification de dépendances composer +- Fallback si librairie absente + +## Best practices + +### Sécurité + +1. **Toujours valider côté serveur** + ```php + // ✅ BON + public function validate($response, $remoteIp) + { + // Validation API serveur + } + + // ❌ MAUVAIS + // Faire confiance uniquement au JavaScript + ``` + +2. **Envoyer l'IP utilisateur** + ```php + $data = [ + 'secret' => $secretKey, + 'response' => $response, + 'remoteip' => $remoteIp, // Important pour détecter abus + ]; + ``` + +3. **Logger les erreurs en mode debug** + ```php + if (isset($errors)) { + $this->log('Validation errors: ' . print_r($errors, true)); + } + ``` + +4. **Utiliser `setLastError()` pour feedback utilisateur** + ```php + $this->setLastError($this->l('Captcha validation failed')); + ``` + +### Performance + +1. **Lazy loading des scripts** + ```php + // Ne charger que si nécessaire + if ($controller instanceof ContactController && Configuration::get('CAPTCHA_ENABLE_CONTACT')) { + return $script; + } + ``` + +2. **Mettre en cache si possible** + ```php + // La factory met déjà en cache les instances + // Pas besoin de recréer à chaque fois + ``` + +### Compatibilité + +1. **Vérifier les dépendances** + ```php + public function isConfigured() + { + if (!class_exists('SomeRequiredClass')) { + return false; + } + return !empty($this->getConfig('KEY')); + } + ``` + +2. **Fallback gracieux** + ```php + try { + // Tentative avec nouvelle méthode + } catch (\Exception $e) { + $this->log('Error: ' . $e->getMessage()); + // Fallback vers méthode alternative + } + ``` + +3. **Support multi-langues** + ```php + // Toujours utiliser $this->l() pour les traductions + $this->l('Error message') + ``` + +### Code quality + +1. **Docblocks complets** + ```php + /** + * Validate the captcha response + * + * @param string $response The captcha response token + * @param string $remoteIp The user's IP address + * + * @return bool True if valid, false otherwise + * + * @since 3.1.0 + */ + public function validate($response, $remoteIp) + ``` + +2. **Constantes pour URLs** + ```php + const VERIFY_URL = 'https://api.example.com/verify'; + + // Utiliser + file_get_contents(self::VERIFY_URL, ...); + ``` + +3. **Validation des configs** + ```php + public function getConfig($key, $default = null) + { + $value = Configuration::get($key); + return $value !== false ? $value : $default; + } + ``` + +## Support et contribution + +### Soumettre un nouveau provider + +1. Créer une branche : `git checkout -b feature/add-[provider]-support` +2. Implémenter le provider selon ce guide +3. Ajouter des tests si possible +4. Mettre à jour `changelog.txt` +5. Créer une Pull Request + +### Documentation + +- Ajouter un README spécifique dans `docs/providers/[provider].md` +- Documenter les prérequis (clés API, comptes nécessaires) +- Fournir des liens vers la documentation officielle + +### Questions ? + +- GitHub Issues : https://github.com/nenes25/eicaptcha/issues +- Wiki : https://github.com/nenes25/eicaptcha/wiki + +--- + +**Version de ce guide :** 3.0.0 +**Dernière mise à jour :** 2025-11-21 From 05a5ee6f05df688bd5022e16e5b055140f2de8d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 12:51:06 +0000 Subject: [PATCH 3/4] feat: Add Cloudflare Turnstile provider Cloudflare Turnstile is a free, privacy-friendly alternative to reCAPTCHA: - No Google tracking - Free tier: 1M requests/month - Modern, user-friendly interface - Support for light, dark, and auto themes - Compact and normal widget sizes **New Provider:** - TurnstileProvider: Full implementation with API validation - Template: turnstile.tpl with theme and size options - Configuration fields: Site Key, Secret Key, Theme, Size **API Integration:** - Endpoint: https://challenges.cloudflare.com/turnstile/v0/siteverify - Response field: cf-turnstile-response - Dashboard: https://dash.cloudflare.com/ **Architecture Improvements:** - Added getResponseFieldName() to CaptchaProviderInterface - Default implementation in AbstractCaptchaProvider - Dynamic field name support in _validateCaptcha() - Math and Turnstile providers now declare custom field names **Configuration:** - 4 new config keys (site/secret keys, theme, size) - Default theme: auto (follows system preferences) - Default size: normal - Full integration in ConfigForm and Installer This brings the total to 5 captcha providers: 1. Google reCAPTCHA v2/v3 2. Google reCAPTCHA Enterprise 3. hCaptcha 4. Math Captcha (native) 5. Cloudflare Turnstile (new) --- eicaptcha.php | 2 +- src/ConfigForm.php | 11 + src/Factory/CaptchaFactory.php | 2 + src/Installer.php | 4 +- src/Provider/AbstractCaptchaProvider.php | 9 + src/Provider/CaptchaProviderInterface.php | 7 + src/Provider/MathCaptchaProvider.php | 8 + src/Provider/TurnstileProvider.php | 274 +++++++++++++++++++ views/templates/hook/providers/turnstile.tpl | 29 ++ 9 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 src/Provider/TurnstileProvider.php create mode 100644 views/templates/hook/providers/turnstile.tpl diff --git a/eicaptcha.php b/eicaptcha.php index 48ecacc..038f524 100644 --- a/eicaptcha.php +++ b/eicaptcha.php @@ -378,7 +378,7 @@ protected function _validateCaptcha() return true; } - $response = Tools::getValue('g-recaptcha-response'); + $response = Tools::getValue($provider->getResponseFieldName()); $remoteIp = Tools::getRemoteAddr(); $isValid = $provider->validate($response, $remoteIp); diff --git a/src/ConfigForm.php b/src/ConfigForm.php index 343e072..28937c3 100644 --- a/src/ConfigForm.php +++ b/src/ConfigForm.php @@ -410,6 +410,12 @@ public function postProcess() Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')); Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY')); + // Cloudflare Turnstile + Configuration::updateValue('CAPTCHA_TURNSTILE_SITE_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY')); + Configuration::updateValue('CAPTCHA_TURNSTILE_SECRET_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY')); + Configuration::updateValue('CAPTCHA_TURNSTILE_THEME', Tools::getValue('CAPTCHA_TURNSTILE_THEME')); + Configuration::updateValue('CAPTCHA_TURNSTILE_SIZE', Tools::getValue('CAPTCHA_TURNSTILE_SIZE')); + return $this->module->displayConfirmation($this->l('Settings updated')); } } @@ -444,6 +450,11 @@ public function getConfigFieldsValues() // Google Enterprise 'CAPTCHA_GOOGLE_ENTERPRISE_PROJECT' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')), 'CAPTCHA_GOOGLE_ENTERPRISE_KEY' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_KEY')), + // Cloudflare Turnstile + 'CAPTCHA_TURNSTILE_SITE_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY', Configuration::get('CAPTCHA_TURNSTILE_SITE_KEY')), + 'CAPTCHA_TURNSTILE_SECRET_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY', Configuration::get('CAPTCHA_TURNSTILE_SECRET_KEY')), + 'CAPTCHA_TURNSTILE_THEME' => Tools::getValue('CAPTCHA_TURNSTILE_THEME', Configuration::get('CAPTCHA_TURNSTILE_THEME') ?: 'auto'), + 'CAPTCHA_TURNSTILE_SIZE' => Tools::getValue('CAPTCHA_TURNSTILE_SIZE', Configuration::get('CAPTCHA_TURNSTILE_SIZE') ?: 'normal'), ]; } diff --git a/src/Factory/CaptchaFactory.php b/src/Factory/CaptchaFactory.php index d6a3233..e536e92 100644 --- a/src/Factory/CaptchaFactory.php +++ b/src/Factory/CaptchaFactory.php @@ -24,6 +24,7 @@ use Eicaptcha\Module\Provider\GoogleEnterpriseProvider; use Eicaptcha\Module\Provider\HCaptchaProvider; use Eicaptcha\Module\Provider\MathCaptchaProvider; +use Eicaptcha\Module\Provider\TurnstileProvider; /** * Class CaptchaFactory @@ -42,6 +43,7 @@ class CaptchaFactory 'google_enterprise' => GoogleEnterpriseProvider::class, 'hcaptcha' => HCaptchaProvider::class, 'math' => MathCaptchaProvider::class, + 'turnstile' => TurnstileProvider::class, ]; /** diff --git a/src/Installer.php b/src/Installer.php index cbe891a..76ddbcb 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -101,7 +101,9 @@ protected function installConfigurations() && Configuration::updateValue('CAPTCHA_PROVIDER', 'google_recaptcha') && Configuration::updateValue('CAPTCHA_VERSION', 2) && Configuration::updateValue('CAPTCHA_V3_MINIMAL_SCORE', 0.5) - && Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', 'easy'); + && Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', 'easy') + && Configuration::updateValue('CAPTCHA_TURNSTILE_THEME', 'auto') + && Configuration::updateValue('CAPTCHA_TURNSTILE_SIZE', 'normal'); } /** diff --git a/src/Provider/AbstractCaptchaProvider.php b/src/Provider/AbstractCaptchaProvider.php index 4d43592..d9df98a 100644 --- a/src/Provider/AbstractCaptchaProvider.php +++ b/src/Provider/AbstractCaptchaProvider.php @@ -158,6 +158,15 @@ public function getJsFiles() return []; } + /** + * @inheritDoc + */ + public function getResponseFieldName() + { + // Default to Google reCAPTCHA field name for backward compatibility + return 'g-recaptcha-response'; + } + /** * Translate a string in the module context * diff --git a/src/Provider/CaptchaProviderInterface.php b/src/Provider/CaptchaProviderInterface.php index 9088e9b..ddb6eb0 100644 --- a/src/Provider/CaptchaProviderInterface.php +++ b/src/Provider/CaptchaProviderInterface.php @@ -108,4 +108,11 @@ public function getCssFiles(); * @return array Array of JS file paths */ public function getJsFiles(); + + /** + * Get the name of the form field containing the captcha response + * + * @return string Field name (e.g., 'g-recaptcha-response', 'cf-turnstile-response') + */ + public function getResponseFieldName(); } diff --git a/src/Provider/MathCaptchaProvider.php b/src/Provider/MathCaptchaProvider.php index d49d78a..5a3cf35 100644 --- a/src/Provider/MathCaptchaProvider.php +++ b/src/Provider/MathCaptchaProvider.php @@ -277,4 +277,12 @@ public function getTemplatePath() { return 'module:eicaptcha/views/templates/hook/providers/math.tpl'; } + + /** + * @inheritDoc + */ + public function getResponseFieldName() + { + return 'math-captcha-response'; + } } diff --git a/src/Provider/TurnstileProvider.php b/src/Provider/TurnstileProvider.php new file mode 100644 index 0000000..c53253a --- /dev/null +++ b/src/Provider/TurnstileProvider.php @@ -0,0 +1,274 @@ + and contributors / https://github.com/nenes25/eicaptcha + * @copyright since 2013 Hervé HENNES + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License ("AFL") v. 3.0 + */ + +namespace Eicaptcha\Module\Provider; + +use Configuration; +use ContactController; +use Tools; + +/** + * Class TurnstileProvider + * + * Provider for Cloudflare Turnstile + * Free, privacy-friendly alternative to reCAPTCHA + * + * @since 3.0.0 + */ +class TurnstileProvider extends AbstractCaptchaProvider +{ + /** + * Cloudflare Turnstile API verification endpoint + */ + const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + /** + * @inheritDoc + */ + public function getName() + { + return 'turnstile'; + } + + /** + * @inheritDoc + */ + public function getDisplayName() + { + return 'Cloudflare Turnstile'; + } + + /** + * @inheritDoc + */ + public function validate($response, $remoteIp) + { + if (!$this->shouldDisplayToCustomer()) { + return true; + } + + if (empty($response)) { + $this->setLastError($this->l('Please validate the captcha field before submitting your request')); + + return false; + } + + $secretKey = $this->getConfig('CAPTCHA_TURNSTILE_SECRET_KEY'); + + if (empty($secretKey)) { + $this->setLastError($this->l('Turnstile secret key is not configured')); + + return false; + } + + // Prepare POST data + $data = [ + 'secret' => $secretKey, + 'response' => $response, + 'remoteip' => $remoteIp, + ]; + + // Make API request + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data), + ], + ]; + + $context = stream_context_create($options); + $verify = file_get_contents(self::VERIFY_URL, false, $context); + + if ($verify === false) { + $this->setLastError($this->l('Unable to verify captcha response with Cloudflare Turnstile API')); + + return false; + } + + $verifyResponse = json_decode($verify, true); + + if (!isset($verifyResponse['success']) || $verifyResponse['success'] !== true) { + $errorMessage = $this->l('Please validate the captcha field before submitting your request'); + $this->setLastError($errorMessage); + + if (isset($verifyResponse['error-codes'])) { + $this->log(sprintf($this->l('Turnstile response errors: %s'), print_r($verifyResponse['error-codes'], true))); + } + + return false; + } + + $this->log($this->l('Captcha submitted with success')); + + return true; + } + + /** + * @inheritDoc + */ + public function getConfigFields() + { + return [ + [ + 'type' => 'text', + 'label' => $this->l('Turnstile Site Key'), + 'name' => 'CAPTCHA_TURNSTILE_SITE_KEY', + 'required' => true, + 'desc' => sprintf( + $this->l('Get your site key from %s'), + 'Cloudflare Dashboard' + ), + 'empty_message' => $this->l('Please fill the Turnstile site key'), + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Turnstile Secret Key'), + 'name' => 'CAPTCHA_TURNSTILE_SECRET_KEY', + 'required' => true, + 'desc' => $this->l('Your secret key from Cloudflare Dashboard'), + 'empty_message' => $this->l('Please fill the Turnstile secret key'), + 'tab' => 'general', + ], + [ + 'type' => 'radio', + 'label' => $this->l('Widget Theme'), + 'name' => 'CAPTCHA_TURNSTILE_THEME', + 'required' => true, + 'values' => [ + [ + 'id' => 'light', + 'value' => 'light', + 'label' => $this->l('Light'), + ], + [ + 'id' => 'dark', + 'value' => 'dark', + 'label' => $this->l('Dark'), + ], + [ + 'id' => 'auto', + 'value' => 'auto', + 'label' => $this->l('Auto (matches system theme)'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'radio', + 'label' => $this->l('Widget Size'), + 'name' => 'CAPTCHA_TURNSTILE_SIZE', + 'required' => true, + 'values' => [ + [ + 'id' => 'normal', + 'value' => 'normal', + 'label' => $this->l('Normal'), + ], + [ + 'id' => 'compact', + 'value' => 'compact', + 'label' => $this->l('Compact'), + ], + ], + 'tab' => 'general', + ], + ]; + } + + /** + * @inheritDoc + */ + public function renderHeader(array $context = []) + { + if (!$this->shouldDisplayToCustomer()) { + return ''; + } + + $controller = $this->context->controller; + $loadEverywhere = Configuration::get('CAPTCHA_LOAD_EVERYWHERE') == 1; + $isAuthController = $controller instanceof \AuthController || $controller instanceof \RegistrationController; + $isContactController = $controller instanceof ContactController; + + if (( + $isAuthController + && Configuration::get('CAPTCHA_ENABLE_ACCOUNT') == 1 + ) + || + ($isContactController + && Configuration::get('CAPTCHA_ENABLE_CONTACT') == 1 + ) + || $loadEverywhere + ) { + $this->context->controller->registerStylesheet( + 'module-eicaptcha', + 'modules/' . $this->module->name . '/views/css/eicaptcha.css' + ); + + $siteKey = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + + $js = ''; + + return $js; + } + + return ''; + } + + /** + * @inheritDoc + */ + public function getTemplateVars() + { + $vars = parent::getTemplateVars(); + $vars['siteKey'] = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + $vars['turnstileTheme'] = $this->getConfig('CAPTCHA_TURNSTILE_THEME', 'auto'); + $vars['turnstileSize'] = $this->getConfig('CAPTCHA_TURNSTILE_SIZE', 'normal'); + + return $vars; + } + + /** + * @inheritDoc + */ + public function isConfigured() + { + $siteKey = $this->getConfig('CAPTCHA_TURNSTILE_SITE_KEY'); + $secretKey = $this->getConfig('CAPTCHA_TURNSTILE_SECRET_KEY'); + + return !empty($siteKey) && !empty($secretKey); + } + + /** + * @inheritDoc + */ + public function getTemplatePath() + { + return 'module:eicaptcha/views/templates/hook/providers/turnstile.tpl'; + } + + /** + * Get the response field name for Turnstile + * + * @return string + */ + public function getResponseFieldName() + { + return 'cf-turnstile-response'; + } +} diff --git a/views/templates/hook/providers/turnstile.tpl b/views/templates/hook/providers/turnstile.tpl new file mode 100644 index 0000000..615a942 --- /dev/null +++ b/views/templates/hook/providers/turnstile.tpl @@ -0,0 +1,29 @@ +{* +* NOTICE OF LICENSE +* +* This source file is subject to the Academic Free License (AFL 3.0) +* that is bundled with this package in the file LICENSE.txt. +* It is also available through the world-wide-web at this URL: +* http://opensource.org/licenses/afl-3.0.php +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* @author Hennes Hervé +* @copyright Hennes Hervé +* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0) +* http://www.h-hennes.fr/blog/ +*} + +{if $displayCaptcha} +
+ +
+
+
+
+{/if} From 0d82616cae809040396be84091c671c10883d8df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 13:03:23 +0000 Subject: [PATCH 4/4] refactor: Make configuration fields fully dynamic **Problem:** - Configuration fields were hardcoded in ConfigForm.php - Adding new providers required editing ConfigForm in 3 places - Inconsistent with provider architecture where providers define their own fields **Solution:** - Created getProviderSpecificFields() to dynamically load from current provider - Split fields into logical groups (provider, common, advanced) - Generic postProcess() loops through all fields automatically - Generic getConfigFieldsValues() loads all values dynamically - Added getAllConfigurationFields() helper for field aggregation **Result:** - Adding new provider now requires ZERO changes to ConfigForm.php - Only need to implement getConfigFields() in provider class - Fully consistent with provider architecture - ~200 lines of hardcoded fields removed **Changes:** - src/ConfigForm.php: Complete refactoring with new methods: * getProviderSpecificFields(): Loads fields from current provider * getCommonFields(): Returns always-visible common fields * getAdvancedFields(): Returns advanced configuration * getAllConfigurationFields(): Aggregates all fields from all sources * Simplified postProcess(): Generic loop through all fields * Simplified getConfigFieldsValues(): Generic value loading **Impact:** Provider developers now only need to: 1. Create Provider class with getConfigFields() 2. Register in CaptchaFactory 3. Create template file No ConfigForm.php modifications needed when adding new providers. --- src/ConfigForm.php | 622 ++++++++++++++++++++++++--------------------- 1 file changed, 332 insertions(+), 290 deletions(-) diff --git a/src/ConfigForm.php b/src/ConfigForm.php index 28937c3..c0f458c 100644 --- a/src/ConfigForm.php +++ b/src/ConfigForm.php @@ -66,246 +66,33 @@ public function renderForm() ], 'tabs' => [ 'general' => $this->l('General configuration'), - 'advanced' => $this->l('Advanded parameters'), + 'advanced' => $this->l('Advanced parameters'), ], 'description' => $this->l('Configure your captcha provider and protect your website from spam and bots'), - 'input' => [ + 'input' => array_merge( + // Provider selection [ - 'type' => 'select', - 'label' => $this->l('Captcha Provider'), - 'name' => 'CAPTCHA_PROVIDER', - 'required' => true, - 'desc' => $this->l('Select the captcha system you want to use'), - 'options' => [ - 'query' => $this->getAvailableProviders(), - 'id' => 'id', - 'name' => 'name', - ], - 'tab' => 'general', - ], - [ - 'type' => 'radio', - 'label' => $this->l('Recaptcha Version'), - 'name' => 'CAPTCHA_VERSION', - 'required' => true, - 'class' => 't', - 'values' => [ - [ - 'id' => 'v2', - 'value' => 2, - 'label' => $this->l('V2'), - ], - [ - 'id' => 'v3', - 'value' => 3, - 'label' => $this->l('V3'), - ], - ], - 'tab' => 'general', - ], - [ - 'type' => 'text', - 'label' => $this->l('Captcha V3 mininum score'), - 'hint' => sprintf( - $this->l('The minimum score required to validate the captcha is a number between 0 and 1. Default is 0.5 (recommended: 0.5 for normal security, 0.3 for less strict, 0.7 for more strict). %s'), - 'Learn more' - ), - 'name' => 'CAPTCHA_V3_MINIMAL_SCORE', - 'required' => true, - 'class' => 'fixed-width-sm', - 'suffix' => '(0.0 - 1.0)', - 'empty_message' => $this->l('Please fill the captcha v3 minimal score.'), - 'tab' => 'general', - ], - [ - 'type' => 'text', - 'label' => $this->l('Captcha public key (Site key)'), - 'name' => 'CAPTCHA_PUBLIC_KEY', - 'required' => true, - 'empty_message' => $this->l('Please fill the captcha public key'), - 'tab' => 'general', - ], - [ - 'type' => 'text', - 'label' => $this->l('Captcha private key (Secret key)'), - 'name' => 'CAPTCHA_PRIVATE_KEY', - 'required' => true, - 'empty_message' => $this->l('Please fill the captcha private key'), - 'tab' => 'general', - ], - [ - 'type' => 'switch', - 'label' => $this->l('Enable Captcha for logged customers'), - 'name' => 'CAPTCHA_ENABLE_LOGGED_CUSTOMERS', - 'required' => true, - 'class' => 't', - 'is_bool' => true, - 'hint' => $this->l('Define if logged customers need to use captcha or not'), - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'general', - ], - [ - 'type' => 'switch', - 'label' => $this->l('Enable Captcha for contact form'), - 'name' => 'CAPTCHA_ENABLE_CONTACT', - 'required' => true, - 'class' => 't', - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'general', - ], - [ - 'type' => 'switch', - 'label' => $this->l('Enable Captcha for account creation'), - 'name' => 'CAPTCHA_ENABLE_ACCOUNT', - 'required' => true, - 'class' => 't', - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'general', - ], - [ - 'type' => 'switch', - 'label' => $this->l('Enable Captcha for newsletter registration'), - 'hint' => $this->l('Only availaibles in certain conditions*'), - 'name' => 'CAPTCHA_ENABLE_NEWSLETTER', - 'required' => true, - 'class' => 't', - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'general', - ], - [ - 'type' => 'text', - 'label' => $this->l('Force Captcha language'), - 'hint' => $this->l('Language code ( en-GB | fr | de | de-AT | ... ) - Leave empty for autodetect'), - 'desc' => $this->l('For available language codes see: https://developers.google.com/recaptcha/docs/language'), - 'name' => 'CAPTCHA_FORCE_LANG', - 'required' => false, - 'tab' => 'general', - ], - [ - 'type' => 'radio', - 'label' => $this->l('Theme'), - 'name' => 'CAPTCHA_THEME', - 'required' => true, - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'cdark', - 'value' => 1, - 'label' => $this->l('Dark'), - ], - [ - 'id' => 'clight', - 'value' => 0, - 'label' => $this->l('Light'), + [ + 'type' => 'select', + 'label' => $this->l('Captcha Provider'), + 'name' => 'CAPTCHA_PROVIDER', + 'required' => true, + 'desc' => $this->l('Select the captcha system you want to use'), + 'options' => [ + 'query' => $this->getAvailableProviders(), + 'id' => 'id', + 'name' => 'name', ], + 'tab' => 'general', ], - 'tab' => 'general', ], - [ - 'type' => 'switch', - 'name' => 'CAPTCHA_DEBUG', - 'label' => $this->l('Enable Debug'), - 'hint' => $this->l('Use only for debug'), - 'desc' => sprintf( - $this->l('Enable loging for debuging module, see file %s'), - _PS_MODULE_DIR_ . 'eicaptcha/logs/debug.log' - ), - 'required' => false, - 'class' => 't', - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'advanced', - ], - [ - 'type' => 'switch', - 'name' => 'CAPTCHA_LOAD_EVERYWHERE', - 'label' => $this->l('Load Recaptcha library everywhere'), - 'hint' => $this->l('Let this option disabled by default if you don\'t use a specific contact form'), - 'desc' => $this->l('Only If you use a theme with elementor or warehouse and your contact form is not on the default page, enable this option.'), - 'required' => false, - 'class' => 't', - 'is_bool' => true, - 'values' => [ - [ - 'id' => 'active_on', - 'value' => 1, - 'label' => $this->l('Enabled'), - ], - [ - 'id' => 'active_off', - 'value' => 0, - 'label' => $this->l('Disabled'), - ], - ], - 'tab' => 'advanced', - ], - [ - 'type' => 'html', - 'label' => $this->l('Check module installation'), - 'name' => 'enable_debug_html', - 'html_content' => '' . $this->l('Check if module is well installed') . '', - 'desc' => $this->l('click on this link will reload the page, please go again in tab "advanced parameters" to see the results'), - 'tab' => 'advanced', - ], - ], + // Provider-specific fields (dynamically loaded) + $this->getProviderSpecificFields(), + // Common fields + $this->getCommonFields(), + // Advanced fields + $this->getAdvancedFields() + ), 'submit' => [ 'title' => $this->l('Save'), 'class' => 'button btn btn-default pull-right', @@ -370,6 +157,225 @@ public function renderForm() return $helper->generateForm([$fields_form]); } + /** + * Get provider-specific configuration fields + * + * @return array + * + * @since 3.0.0 + */ + protected function getProviderSpecificFields() + { + try { + // Get the currently selected provider + $currentProvider = Tools::getValue('CAPTCHA_PROVIDER', Configuration::get('CAPTCHA_PROVIDER') ?: 'google_recaptcha'); + $provider = CaptchaFactory::create($this->module, $currentProvider); + + // Get provider-specific fields + return $provider->getConfigFields(); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get common configuration fields (applicable to all providers) + * + * @return array + * + * @since 3.0.0 + */ + protected function getCommonFields() + { + return [ + [ + 'type' => 'switch', + 'label' => $this->l('Enable Captcha for logged customers'), + 'name' => 'CAPTCHA_ENABLE_LOGGED_CUSTOMERS', + 'required' => true, + 'class' => 't', + 'is_bool' => true, + 'hint' => $this->l('Define if logged customers need to use captcha or not'), + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'switch', + 'label' => $this->l('Enable Captcha for contact form'), + 'name' => 'CAPTCHA_ENABLE_CONTACT', + 'required' => true, + 'class' => 't', + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'switch', + 'label' => $this->l('Enable Captcha for account creation'), + 'name' => 'CAPTCHA_ENABLE_ACCOUNT', + 'required' => true, + 'class' => 't', + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'switch', + 'label' => $this->l('Enable Captcha for newsletter registration'), + 'hint' => $this->l('Only availaibles in certain conditions*'), + 'name' => 'CAPTCHA_ENABLE_NEWSLETTER', + 'required' => true, + 'class' => 't', + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'general', + ], + [ + 'type' => 'text', + 'label' => $this->l('Force Captcha language'), + 'hint' => $this->l('Language code ( en-GB | fr | de | de-AT | ... ) - Leave empty for autodetect'), + 'desc' => $this->l('For available language codes see: https://developers.google.com/recaptcha/docs/language'), + 'name' => 'CAPTCHA_FORCE_LANG', + 'required' => false, + 'tab' => 'general', + ], + [ + 'type' => 'radio', + 'label' => $this->l('Theme'), + 'name' => 'CAPTCHA_THEME', + 'required' => true, + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'cdark', + 'value' => 1, + 'label' => $this->l('Dark'), + ], + [ + 'id' => 'clight', + 'value' => 0, + 'label' => $this->l('Light'), + ], + ], + 'tab' => 'general', + ], + ]; + } + + /** + * Get advanced configuration fields + * + * @return array + * + * @since 3.0.0 + */ + protected function getAdvancedFields() + { + return [ + [ + 'type' => 'switch', + 'name' => 'CAPTCHA_DEBUG', + 'label' => $this->l('Enable Debug'), + 'hint' => $this->l('Use only for debug'), + 'desc' => sprintf( + $this->l('Enable loging for debuging module, see file %s'), + _PS_MODULE_DIR_ . 'eicaptcha/logs/debug.log' + ), + 'required' => false, + 'class' => 't', + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'advanced', + ], + [ + 'type' => 'switch', + 'name' => 'CAPTCHA_LOAD_EVERYWHERE', + 'label' => $this->l('Load Recaptcha library everywhere'), + 'hint' => $this->l('Let this option disabled by default if you don\'t use a specific contact form'), + 'desc' => $this->l('Only If you use a theme with elementor or warehouse and your contact form is not on the default page, enable this option.'), + 'required' => false, + 'class' => 't', + 'is_bool' => true, + 'values' => [ + [ + 'id' => 'active_on', + 'value' => 1, + 'label' => $this->l('Enabled'), + ], + [ + 'id' => 'active_off', + 'value' => 0, + 'label' => $this->l('Disabled'), + ], + ], + 'tab' => 'advanced', + ], + [ + 'type' => 'html', + 'label' => $this->l('Check module installation'), + 'name' => 'enable_debug_html', + 'html_content' => '' . $this->l('Check if module is well installed') . '', + 'desc' => $this->l('click on this link will reload the page, please go again in tab "advanced parameters" to see the results'), + 'tab' => 'advanced', + ], + ]; + } + /** * Post Process in back office * @@ -378,43 +384,32 @@ public function renderForm() public function postProcess() { if (Tools::isSubmit('SubmitCaptchaConfiguration')) { + // Validate specific fields if needed $minScore = Tools::getValue('CAPTCHA_V3_MINIMAL_SCORE'); - - if (!is_numeric($minScore) || $minScore < 0 || $minScore > 1) { + if (!empty($minScore) && (!is_numeric($minScore) || $minScore < 0 || $minScore > 1)) { return $this->module->displayError($this->l('The V3 minimal score must be a number between 0 and 1')); } - Configuration::updateValue('CAPTCHA_VERSION', Tools::getValue('CAPTCHA_VERSION')); - Configuration::updateValue('CAPTCHA_V3_MINIMAL_SCORE', (float) $minScore); - Configuration::updateValue('CAPTCHA_PUBLIC_KEY', Tools::getValue('CAPTCHA_PUBLIC_KEY')); - Configuration::updateValue('CAPTCHA_PRIVATE_KEY', Tools::getValue('CAPTCHA_PRIVATE_KEY')); - Configuration::updateValue('CAPTCHA_ENABLE_LOGGED_CUSTOMERS', Tools::getValue('CAPTCHA_ENABLE_LOGGED_CUSTOMERS')); - Configuration::updateValue('CAPTCHA_ENABLE_ACCOUNT', (int) Tools::getValue('CAPTCHA_ENABLE_ACCOUNT')); - Configuration::updateValue('CAPTCHA_ENABLE_CONTACT', (int) Tools::getValue('CAPTCHA_ENABLE_CONTACT')); - Configuration::updateValue('CAPTCHA_ENABLE_NEWSLETTER', (int) Tools::getValue('CAPTCHA_ENABLE_NEWSLETTER')); - Configuration::updateValue('CAPTCHA_FORCE_LANG', Tools::getValue('CAPTCHA_FORCE_LANG')); - Configuration::updateValue('CAPTCHA_THEME', (int) Tools::getValue('CAPTCHA_THEME')); - Configuration::updateValue('CAPTCHA_DEBUG', (int) Tools::getValue('CAPTCHA_DEBUG')); - Configuration::updateValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', (int) Tools::getValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE')); - Configuration::updateValue('CAPTCHA_LOAD_EVERYWHERE', (int) Tools::getValue('CAPTCHA_LOAD_EVERYWHERE')); + // Save CAPTCHA_PROVIDER first Configuration::updateValue('CAPTCHA_PROVIDER', Tools::getValue('CAPTCHA_PROVIDER')); - // hCaptcha - Configuration::updateValue('CAPTCHA_HCAPTCHA_SITE_KEY', Tools::getValue('CAPTCHA_HCAPTCHA_SITE_KEY')); - Configuration::updateValue('CAPTCHA_HCAPTCHA_SECRET_KEY', Tools::getValue('CAPTCHA_HCAPTCHA_SECRET_KEY')); + // Save all configuration values dynamically + $allFields = $this->getAllConfigurationFields(); - // Math Captcha - Configuration::updateValue('CAPTCHA_MATH_DIFFICULTY', Tools::getValue('CAPTCHA_MATH_DIFFICULTY')); + foreach ($allFields as $field) { + if (isset($field['name'])) { + $value = Tools::getValue($field['name']); - // Google Enterprise - Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')); - Configuration::updateValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY')); + // Handle different field types + if (isset($field['type']) && $field['type'] === 'switch') { + $value = (int) $value; + } elseif (isset($field['type']) && $field['type'] === 'text' && $field['name'] === 'CAPTCHA_V3_MINIMAL_SCORE') { + $value = (float) $value; + } - // Cloudflare Turnstile - Configuration::updateValue('CAPTCHA_TURNSTILE_SITE_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY')); - Configuration::updateValue('CAPTCHA_TURNSTILE_SECRET_KEY', Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY')); - Configuration::updateValue('CAPTCHA_TURNSTILE_THEME', Tools::getValue('CAPTCHA_TURNSTILE_THEME')); - Configuration::updateValue('CAPTCHA_TURNSTILE_SIZE', Tools::getValue('CAPTCHA_TURNSTILE_SIZE')); + Configuration::updateValue($field['name'], $value); + } + } return $this->module->displayConfirmation($this->l('Settings updated')); } @@ -427,35 +422,82 @@ public function postProcess() */ public function getConfigFieldsValues() { - return [ - 'CAPTCHA_PROVIDER' => Tools::getValue('CAPTCHA_PROVIDER', Configuration::get('CAPTCHA_PROVIDER') ?: 'google_recaptcha'), - 'CAPTCHA_VERSION' => Tools::getValue('CAPTCHA_VERSION', Configuration::get('CAPTCHA_VERSION')), - 'CAPTCHA_V3_MINIMAL_SCORE' => Tools::getValue('CAPTCHA_V3_MINIMAL_SCORE', Configuration::get('CAPTCHA_V3_MINIMAL_SCORE')), - 'CAPTCHA_PRIVATE_KEY' => Tools::getValue('CAPTCHA_PRIVATE_KEY', Configuration::get('CAPTCHA_PRIVATE_KEY')), - 'CAPTCHA_PUBLIC_KEY' => Tools::getValue('CAPTCHA_PUBLIC_KEY', Configuration::get('CAPTCHA_PUBLIC_KEY')), - 'CAPTCHA_ENABLE_LOGGED_CUSTOMERS' => Tools::getValue('CAPTCHA_ENABLE_LOGGED_CUSTOMERS', Configuration::get('CAPTCHA_ENABLE_LOGGED_CUSTOMERS')), - 'CAPTCHA_ENABLE_ACCOUNT' => Tools::getValue('CAPTCHA_ENABLE_ACCOUNT', Configuration::get('CAPTCHA_ENABLE_ACCOUNT')), - 'CAPTCHA_ENABLE_CONTACT' => Tools::getValue('CAPTCHA_ENABLE_CONTACT', Configuration::get('CAPTCHA_ENABLE_CONTACT')), - 'CAPTCHA_ENABLE_NEWSLETTER' => Tools::getValue('CAPTCHA_ENABLE_NEWSLETTER', Configuration::get('CAPTCHA_ENABLE_NEWSLETTER')), - 'CAPTCHA_FORCE_LANG' => Tools::getValue('CAPTCHA_FORCE_LANG', Configuration::get('CAPTCHA_FORCE_LANG')), - 'CAPTCHA_THEME' => Tools::getValue('CAPTCHA_THEME', Configuration::get('CAPTCHA_THEME')), - 'CAPTCHA_DEBUG' => Tools::getValue('CAPTCHA_DEBUG', Configuration::get('CAPTCHA_DEBUG')), - 'CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE' => Tools::getValue('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', Configuration::get('CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE')), - 'CAPTCHA_LOAD_EVERYWHERE' => Tools::getValue('CAPTCHA_LOAD_EVERYWHERE', Configuration::get('CAPTCHA_LOAD_EVERYWHERE')), - // hCaptcha - 'CAPTCHA_HCAPTCHA_SITE_KEY' => Tools::getValue('CAPTCHA_HCAPTCHA_SITE_KEY', Configuration::get('CAPTCHA_HCAPTCHA_SITE_KEY')), - 'CAPTCHA_HCAPTCHA_SECRET_KEY' => Tools::getValue('CAPTCHA_HCAPTCHA_SECRET_KEY', Configuration::get('CAPTCHA_HCAPTCHA_SECRET_KEY')), - // Math Captcha - 'CAPTCHA_MATH_DIFFICULTY' => Tools::getValue('CAPTCHA_MATH_DIFFICULTY', Configuration::get('CAPTCHA_MATH_DIFFICULTY') ?: 'easy'), - // Google Enterprise - 'CAPTCHA_GOOGLE_ENTERPRISE_PROJECT' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_PROJECT')), - 'CAPTCHA_GOOGLE_ENTERPRISE_KEY' => Tools::getValue('CAPTCHA_GOOGLE_ENTERPRISE_KEY', Configuration::get('CAPTCHA_GOOGLE_ENTERPRISE_KEY')), - // Cloudflare Turnstile - 'CAPTCHA_TURNSTILE_SITE_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SITE_KEY', Configuration::get('CAPTCHA_TURNSTILE_SITE_KEY')), - 'CAPTCHA_TURNSTILE_SECRET_KEY' => Tools::getValue('CAPTCHA_TURNSTILE_SECRET_KEY', Configuration::get('CAPTCHA_TURNSTILE_SECRET_KEY')), - 'CAPTCHA_TURNSTILE_THEME' => Tools::getValue('CAPTCHA_TURNSTILE_THEME', Configuration::get('CAPTCHA_TURNSTILE_THEME') ?: 'auto'), - 'CAPTCHA_TURNSTILE_SIZE' => Tools::getValue('CAPTCHA_TURNSTILE_SIZE', Configuration::get('CAPTCHA_TURNSTILE_SIZE') ?: 'normal'), + $values = []; + + // Get all configuration fields + $allFields = $this->getAllConfigurationFields(); + + foreach ($allFields as $field) { + if (isset($field['name'])) { + $default = ''; + + // Set default values for specific fields + if ($field['name'] === 'CAPTCHA_PROVIDER') { + $default = 'google_recaptcha'; + } elseif ($field['name'] === 'CAPTCHA_MATH_DIFFICULTY') { + $default = 'easy'; + } elseif ($field['name'] === 'CAPTCHA_TURNSTILE_THEME') { + $default = 'auto'; + } elseif ($field['name'] === 'CAPTCHA_TURNSTILE_SIZE') { + $default = 'normal'; + } + + $values[$field['name']] = Tools::getValue( + $field['name'], + Configuration::get($field['name']) ?: $default + ); + } + } + + return $values; + } + + /** + * Get all configuration fields from all sources + * + * @return array + * + * @since 3.0.0 + */ + protected function getAllConfigurationFields() + { + $allFields = []; + + // Provider selector + $allFields[] = ['name' => 'CAPTCHA_PROVIDER']; + + // Get all providers and their fields + try { + $providers = CaptchaFactory::getAllProviders($this->module); + + foreach ($providers as $provider) { + $providerFields = $provider->getConfigFields(); + $allFields = array_merge($allFields, $providerFields); + } + } catch (\Exception $e) { + // Continue even if providers fail to load + } + + // Common fields + $commonFields = [ + ['name' => 'CAPTCHA_ENABLE_LOGGED_CUSTOMERS'], + ['name' => 'CAPTCHA_ENABLE_CONTACT'], + ['name' => 'CAPTCHA_ENABLE_ACCOUNT'], + ['name' => 'CAPTCHA_ENABLE_NEWSLETTER'], + ['name' => 'CAPTCHA_FORCE_LANG'], + ['name' => 'CAPTCHA_THEME'], ]; + $allFields = array_merge($allFields, $commonFields); + + // Advanced fields + $advancedFields = [ + ['name' => 'CAPTCHA_DEBUG', 'type' => 'switch'], + ['name' => 'CAPTCHA_LOAD_EVERYWHERE', 'type' => 'switch'], + ['name' => 'CAPTCHA_USE_AUTHCONTROLLER_OVERRIDE', 'type' => 'switch'], + ]; + $allFields = array_merge($allFields, $advancedFields); + + return $allFields; } /**