diff --git a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php index cb05ddc8..2c8c4f37 100644 --- a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php +++ b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php @@ -128,6 +128,11 @@ final class AdminDashboardRenderer implements DashboardRendererInterface 'label' => 'AdSense', 'icon' => 'bi-megaphone', ], + 'recaptcha-settings' => [ + 'id' => 'recaptcha-settings', + 'label' => 'reCAPTCHA', + 'icon' => 'bi-shield-check', + ], 'custom-css-manager' => [ 'id' => 'custom-css-manager', 'label' => 'CSS Personalizado', diff --git a/Admin/RecaptchaSettings/Infrastructure/FieldMapping/RecaptchaSettingsFieldMapper.php b/Admin/RecaptchaSettings/Infrastructure/FieldMapping/RecaptchaSettingsFieldMapper.php new file mode 100644 index 00000000..78970a98 --- /dev/null +++ b/Admin/RecaptchaSettings/Infrastructure/FieldMapping/RecaptchaSettingsFieldMapper.php @@ -0,0 +1,42 @@ + ['group' => 'visibility', 'attribute' => 'is_enabled'], + + // Credentials + 'recaptchaSiteKey' => ['group' => 'credentials', 'attribute' => 'site_key'], + 'recaptchaSecretKey' => ['group' => 'credentials', 'attribute' => 'secret_key'], + + // Behavior + 'recaptchaScoreThreshold' => ['group' => 'behavior', 'attribute' => 'score_threshold'], + 'recaptchaActionNewsletter' => ['group' => 'behavior', 'attribute' => 'action_newsletter'], + 'recaptchaActionContact' => ['group' => 'behavior', 'attribute' => 'action_contact'], + 'recaptchaFailOpen' => ['group' => 'behavior', 'attribute' => 'fail_open'], + 'recaptchaLogBlocked' => ['group' => 'behavior', 'attribute' => 'log_blocked'], + ]; + } +} diff --git a/Admin/RecaptchaSettings/Infrastructure/Ui/RecaptchaSettingsFormBuilder.php b/Admin/RecaptchaSettings/Infrastructure/Ui/RecaptchaSettingsFormBuilder.php new file mode 100644 index 00000000..0e93e764 --- /dev/null +++ b/Admin/RecaptchaSettings/Infrastructure/Ui/RecaptchaSettingsFormBuilder.php @@ -0,0 +1,314 @@ +buildHeader($componentId); + $html .= $this->buildVisibilityGroup($componentId); + $html .= $this->buildCredentialsGroup($componentId); + $html .= $this->buildBehaviorGroup($componentId); + $html .= $this->buildHelpSection(); + + return $html; + } + + private function buildHeader(string $componentId): string + { + $html = '
renderer->getFieldValue($componentId, 'visibility', 'is_enabled', false); + $html .= $this->buildToggle( + 'recaptchaIsEnabled', + 'Habilitar reCAPTCHA v3', + $isEnabled, + 'Activa la proteccion reCAPTCHA en Newsletter y Formulario de Contacto' + ); + + $html .= '
'; + $html .= ''; + + return $html; + } + + private function buildCredentialsGroup(string $componentId): string + { + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' Credenciales'; + $html .= '
'; + + $html .= '
'; + + // Site Key + $html .= '
'; + $siteKey = $this->renderer->getFieldValue($componentId, 'credentials', 'site_key', ''); + $html .= $this->buildTextInput( + 'recaptchaSiteKey', + 'Site Key (Clave del sitio)', + $siteKey, + 'Clave publica visible en frontend' + ); + $html .= '
'; + + // Secret Key + $html .= '
'; + $secretKey = $this->renderer->getFieldValue($componentId, 'credentials', 'secret_key', ''); + $html .= $this->buildPasswordInput( + 'recaptchaSecretKey', + 'Secret Key (Clave secreta)', + $secretKey, + 'Clave privada - NUNCA se expone en frontend' + ); + $html .= '
'; + + $html .= '
'; + + $html .= '
'; + $html .= ' '; + $html .= ' Obtiene tus claves en Google reCAPTCHA Admin. '; + $html .= ' Asegurate de seleccionar reCAPTCHA v3.'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + return $html; + } + + private function buildBehaviorGroup(string $componentId): string + { + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' Comportamiento'; + $html .= '
'; + + $html .= '
'; + + // Score Threshold + $html .= '
'; + $threshold = $this->renderer->getFieldValue($componentId, 'behavior', 'score_threshold', '0.5'); + $html .= $this->buildSelect( + 'recaptchaScoreThreshold', + 'Umbral de Score', + $threshold, + [ + '0.3' => '0.3 - Permisivo (menos bloqueos)', + '0.5' => '0.5 - Balanceado (recomendado)', + '0.7' => '0.7 - Estricto', + '0.9' => '0.9 - Muy estricto (mas bloqueos)' + ], + 'Score minimo para considerar humano (0=bot, 1=humano)' + ); + $html .= '
'; + + // Fail Open + $html .= '
'; + $failOpen = $this->renderer->getFieldValue($componentId, 'behavior', 'fail_open', true); + $html .= $this->buildToggle( + 'recaptchaFailOpen', + 'Permitir si API falla', + $failOpen, + 'Si Google no responde, permite el envio (recomendado)' + ); + $html .= '
'; + + $html .= '
'; + + $html .= '
'; + + $html .= '
'; + + // Action Newsletter + $html .= '
'; + $actionNewsletter = $this->renderer->getFieldValue($componentId, 'behavior', 'action_newsletter', 'newsletter_submit'); + $html .= $this->buildTextInput( + 'recaptchaActionNewsletter', + 'Accion Newsletter', + $actionNewsletter, + 'Identificador para formulario newsletter' + ); + $html .= '
'; + + // Action Contact + $html .= '
'; + $actionContact = $this->renderer->getFieldValue($componentId, 'behavior', 'action_contact', 'contact_submit'); + $html .= $this->buildTextInput( + 'recaptchaActionContact', + 'Accion Contacto', + $actionContact, + 'Identificador para formulario contacto' + ); + $html .= '
'; + + // Log Blocked + $html .= '
'; + $logBlocked = $this->renderer->getFieldValue($componentId, 'behavior', 'log_blocked', true); + $html .= $this->buildToggle( + 'recaptchaLogBlocked', + 'Registrar bloqueados', + $logBlocked, + 'Guarda log de intentos bloqueados' + ); + $html .= '
'; + + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + return $html; + } + + private function buildHelpSection(): string + { + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' Como funciona reCAPTCHA v3'; + $html .= '
'; + $html .= ' '; + $html .= '
'; + $html .= '
'; + + return $html; + } + + private function buildToggle(string $id, string $label, mixed $value, string $helpText = ''): string + { + $checked = ($value === true || $value === '1' || $value === 1) ? ' checked' : ''; + + $html = '
'; + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + if (!empty($helpText)) { + $html .= '
' . esc_html($helpText) . '
'; + } + $html .= '
'; + + return $html; + } + + private function buildTextInput(string $id, string $label, mixed $value, string $helpText = ''): string + { + $value = is_string($value) ? $value : ''; + + $html = '
'; + $html .= ' '; + $html .= ' '; + if (!empty($helpText)) { + $html .= '
' . esc_html($helpText) . '
'; + } + $html .= '
'; + + return $html; + } + + private function buildPasswordInput(string $id, string $label, mixed $value, string $helpText = ''): string + { + $value = is_string($value) ? $value : ''; + + $html = '
'; + $html .= ' '; + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + if (!empty($helpText)) { + $html .= '
' . esc_html($helpText) . '
'; + } + $html .= '
'; + + return $html; + } + + private function buildSelect(string $id, string $label, mixed $value, array $options, string $helpText = ''): string + { + $value = is_string($value) ? $value : ''; + + $html = '
'; + $html .= ' '; + $html .= ' '; + if (!empty($helpText)) { + $html .= '
' . esc_html($helpText) . '
'; + } + $html .= '
'; + + return $html; + } +} diff --git a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php index a945af90..639ae158 100644 --- a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php +++ b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php @@ -35,6 +35,7 @@ final class FieldMapperProvider 'AdsensePlacement', 'ArchiveHeader', 'PostGrid', + 'RecaptchaSettings', ]; public function __construct( diff --git a/Public/ContactForm/Infrastructure/Api/WordPress/ContactFormAjaxHandler.php b/Public/ContactForm/Infrastructure/Api/WordPress/ContactFormAjaxHandler.php index 23c69ba3..d278f2ff 100644 --- a/Public/ContactForm/Infrastructure/Api/WordPress/ContactFormAjaxHandler.php +++ b/Public/ContactForm/Infrastructure/Api/WordPress/ContactFormAjaxHandler.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ROITheme\Public\ContactForm\Infrastructure\Api\WordPress; use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface; +use ROITheme\Shared\Application\Services\RecaptchaValidationService; /** * ContactFormAjaxHandler - Procesa envios del formulario de contacto @@ -12,6 +13,7 @@ use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface; * * SEGURIDAD: * - Verifica nonce + * - reCAPTCHA v3 para proteccion anti-spam * - Webhook URL NUNCA se expone al cliente * - Webhook URL se obtiene de BD server-side * - Rate limiting basico @@ -25,7 +27,8 @@ final class ContactFormAjaxHandler private const COMPONENT_NAME = 'contact-form'; public function __construct( - private ComponentSettingsRepositoryInterface $settingsRepository + private ComponentSettingsRepositoryInterface $settingsRepository, + private ?RecaptchaValidationService $recaptchaService = null ) {} /** @@ -52,7 +55,15 @@ final class ContactFormAjaxHandler return; } - // 2. Rate limiting basico (1 envio por IP cada 30 segundos) + // 2. Validar reCAPTCHA (si esta habilitado) + if (!$this->validateRecaptcha()) { + wp_send_json_error([ + 'message' => __('Verificacion de seguridad fallida. Por favor intenta de nuevo.', 'roi-theme') + ], 403); + return; + } + + // 3. Rate limiting basico (1 envio por IP cada 30 segundos) if (!$this->checkRateLimit()) { wp_send_json_error([ 'message' => __('Por favor espera un momento antes de enviar otro mensaje.', 'roi-theme') @@ -60,7 +71,7 @@ final class ContactFormAjaxHandler return; } - // 3. Sanitizar y validar inputs + // 4. Sanitizar y validar inputs $formData = $this->sanitizeFormData($_POST); $validation = $this->validateFormData($formData); @@ -72,7 +83,7 @@ final class ContactFormAjaxHandler return; } - // 4. Obtener configuracion del componente (incluye webhook URL) + // 5. Obtener configuracion del componente (incluye webhook URL) $settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME); if (empty($settings)) { @@ -98,10 +109,10 @@ final class ContactFormAjaxHandler return; } - // 5. Preparar payload para webhook + // 6. Preparar payload para webhook $payload = $this->preparePayload($formData, $includePageUrl, $includeTimestamp); - // 6. Enviar a webhook + // 7. Enviar a webhook $result = $this->sendToWebhook($webhookUrl, $webhookMethod, $payload); if ($result['success']) { @@ -300,4 +311,31 @@ final class ContactFormAjaxHandler { return $value === true || $value === '1' || $value === 1; } + + /** + * Validar reCAPTCHA token + * + * @return bool True si pasa la validacion o si reCAPTCHA no esta habilitado + */ + private function validateRecaptcha(): bool + { + // Si el servicio no esta inyectado, permitir + if ($this->recaptchaService === null) { + return true; + } + + // Si reCAPTCHA no esta habilitado, permitir + if (!$this->recaptchaService->isRecaptchaEnabled()) { + return true; + } + + // Obtener token del POST + $token = sanitize_text_field($_POST['recaptcha_token'] ?? ''); + + // Obtener accion configurada para contacto + $action = $this->recaptchaService->getContactAction(); + + // Validar con el servicio + return $this->recaptchaService->validateSubmission($token, $action); + } } diff --git a/Public/ContactForm/Infrastructure/Ui/ContactFormRenderer.php b/Public/ContactForm/Infrastructure/Ui/ContactFormRenderer.php index a7f412d4..ce7fec43 100644 --- a/Public/ContactForm/Infrastructure/Ui/ContactFormRenderer.php +++ b/Public/ContactForm/Infrastructure/Ui/ContactFormRenderer.php @@ -7,6 +7,7 @@ use ROITheme\Shared\Domain\Contracts\RendererInterface; use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface; use ROITheme\Shared\Domain\Entities\Component; use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper; +use ROITheme\Shared\Application\Services\RecaptchaValidationService; /** * ContactFormRenderer - Renderiza formulario de contacto con webhook @@ -18,6 +19,7 @@ use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper; * - Envio a webhook configurable (no expuesto en frontend) * - Info de contacto configurable * - Mensajes de exito/error personalizables + * - reCAPTCHA v3 para proteccion anti-spam * * @package ROITheme\Public\ContactForm\Infrastructure\Ui */ @@ -26,7 +28,8 @@ final class ContactFormRenderer implements RendererInterface private const COMPONENT_NAME = 'contact-form'; public function __construct( - private CSSGeneratorInterface $cssGenerator + private CSSGeneratorInterface $cssGenerator, + private ?RecaptchaValidationService $recaptchaService = null ) {} public function render(Component $component): string @@ -49,8 +52,9 @@ final class ContactFormRenderer implements RendererInterface $css = $this->generateCSS($data); $html = $this->buildHTML($data, $visibilityClass); $js = $this->buildJS($data); + $recaptchaScript = $this->generateRecaptchaScript(); - return sprintf("\n%s\n", $css, $html, $js); + return sprintf("\n%s\n\n%s", $css, $html, $js, $recaptchaScript); } /** @@ -276,6 +280,7 @@ final class ContactFormRenderer implements RendererInterface // Right column - Form $html .= '
'; $html .= sprintf('
', esc_attr($nonce)); + $html .= ''; $html .= '
'; // Full name field @@ -400,17 +405,27 @@ final class ContactFormRenderer implements RendererInterface // AJAX URL for WordPress $ajaxUrl = admin_url('admin-ajax.php'); + // Obtener configuracion reCAPTCHA + $recaptchaEnabled = $this->isRecaptchaEnabled(); + $recaptchaSiteKey = $recaptchaEnabled ? esc_js($this->recaptchaService->getSiteKey()) : ''; + $recaptchaAction = $recaptchaEnabled ? esc_js($this->recaptchaService->getContactAction()) : ''; + $js = <<boolToJs($recaptchaEnabled)}; + const recaptchaSiteKey = '{$recaptchaSiteKey}'; + const recaptchaAction = '{$recaptchaAction}'; + form.addEventListener('submit', async function(e) { e.preventDefault(); const submitBtn = form.querySelector('button[type="submit"]'); const messageDiv = document.getElementById('roiContactFormMessage'); + const tokenInput = document.getElementById('contact-recaptcha-token'); const originalBtnHtml = submitBtn.innerHTML; const nonce = form.dataset.nonce; @@ -419,14 +434,25 @@ final class ContactFormRenderer implements RendererInterface submitBtn.innerHTML = '' + '{$sendingMessage}'; messageDiv.style.display = 'none'; - // Collect form data - const formData = new FormData(form); - formData.append('action', 'roi_contact_form_submit'); - formData.append('nonce', nonce); - formData.append('pageUrl', window.location.href); - formData.append('pageTitle', document.title); - try { + // Ejecutar reCAPTCHA si esta habilitado + if (recaptchaEnabled && typeof grecaptcha !== 'undefined' && recaptchaSiteKey) { + try { + const token = await grecaptcha.execute(recaptchaSiteKey, { action: recaptchaAction }); + tokenInput.value = token; + } catch (recaptchaError) { + console.warn('reCAPTCHA error:', recaptchaError); + // Continuar sin token (fail-open en backend) + } + } + + // Collect form data + const formData = new FormData(form); + formData.append('action', 'roi_contact_form_submit'); + formData.append('nonce', nonce); + formData.append('pageUrl', window.location.href); + formData.append('pageTitle', document.title); + const response = await fetch('{$ajaxUrl}', { method: 'POST', body: formData @@ -600,6 +626,7 @@ JS; // Modal Body $html .= '