feat(api): implement recaptcha v3 anti-spam protection

- Add RecaptchaValidatorInterface and RecaptchaResult entity in Domain
- Create RecaptchaValidationService in Application layer
- Implement GoogleRecaptchaValidator for API integration
- Add recaptcha-settings schema and admin FormBuilder
- Integrate reCAPTCHA validation in NewsletterAjaxHandler
- Integrate reCAPTCHA validation in ContactFormAjaxHandler
- Update FooterRenderer and ContactFormRenderer with reCAPTCHA scripts
- Configure DIContainer with RecaptchaValidationService injection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2026-01-08 17:01:46 -06:00
parent 0f6387ab46
commit d135ec8a41
16 changed files with 1299 additions and 37 deletions

View File

@@ -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;
/**
* FooterRenderer - Renderiza el footer del sitio
@@ -17,6 +18,7 @@ use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
* - Webhook URL nunca se expone al cliente
* - Escaping de todos los outputs
* - Nonce para formulario newsletter
* - reCAPTCHA v3 para proteccion anti-spam
*
* @package ROITheme\Public\Footer\Infrastructure\Ui
*/
@@ -25,7 +27,8 @@ final class FooterRenderer implements RendererInterface
private const NONCE_ACTION = 'roi_newsletter_nonce';
public function __construct(
private CSSGeneratorInterface $cssGenerator
private CSSGeneratorInterface $cssGenerator,
private ?RecaptchaValidationService $recaptchaService = null
) {}
public function supports(string $componentType): bool
@@ -65,7 +68,10 @@ final class FooterRenderer implements RendererInterface
// Generar JavaScript
$js = $this->generateJS($data);
return $css . $html . $js;
// Generar script reCAPTCHA (si esta habilitado)
$recaptchaScript = $this->generateRecaptchaScript();
return $css . $html . $js . $recaptchaScript;
}
private function generateCSS(array $data, bool $showDesktop, bool $showMobile): string
@@ -336,6 +342,7 @@ final class FooterRenderer implements RendererInterface
$html .= '<form id="roi-newsletter-form" class="newsletter-form">';
$html .= '<input type="hidden" name="action" value="roi_newsletter_subscribe">';
$html .= '<input type="hidden" name="nonce" value="' . esc_attr($nonce) . '">';
$html .= '<input type="hidden" name="recaptcha_token" id="newsletter-recaptcha-token" value="">';
$html .= '<input type="text" name="name" class="newsletter-input" placeholder="' . $newsletterNamePlaceholder . '">';
$html .= '<input type="email" name="email" class="newsletter-input" placeholder="' . $newsletterEmailPlaceholder . '" required>';
$html .= '<input type="tel" name="whatsapp" class="newsletter-input" placeholder="' . $newsletterWhatsappPlaceholder . '">';
@@ -382,18 +389,28 @@ final class FooterRenderer implements RendererInterface
$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->getNewsletterAction()) : '';
$js = <<<JS
<script>
(function() {
const form = document.getElementById('roi-newsletter-form');
if (!form) return;
const recaptchaEnabled = {$this->boolToJs($recaptchaEnabled)};
const recaptchaSiteKey = '{$recaptchaSiteKey}';
const recaptchaAction = '{$recaptchaAction}';
form.addEventListener('submit', async function(e) {
e.preventDefault();
const btn = form.querySelector('.newsletter-btn');
const msgDiv = form.querySelector('.newsletter-message');
const emailInput = form.querySelector('input[name="email"]');
const tokenInput = document.getElementById('newsletter-recaptcha-token');
const originalText = btn.textContent;
// Reset message
@@ -413,6 +430,17 @@ final class FooterRenderer implements RendererInterface
btn.textContent = 'Enviando...';
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)
}
}
const formData = new FormData(form);
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
@@ -427,7 +455,7 @@ final class FooterRenderer implements RendererInterface
if (result.success) {
msgDiv.textContent = '{$successMsg}';
msgDiv.classList.add('success');
emailInput.value = '';
form.reset();
} else {
msgDiv.textContent = result.data?.message || '{$errorMsg}';
msgDiv.classList.add('error');
@@ -448,6 +476,40 @@ JS;
return $js;
}
/**
* Genera el script de Google reCAPTCHA v3
*/
private function generateRecaptchaScript(): string
{
if (!$this->isRecaptchaEnabled()) {
return '';
}
$siteKey = $this->recaptchaService->getSiteKey();
if (empty($siteKey)) {
return '';
}
$siteKey = esc_attr($siteKey);
return '<script src="https://www.google.com/recaptcha/api.js?render=' . $siteKey . '" async defer></script>';
}
/**
* Verifica si reCAPTCHA esta habilitado
*/
private function isRecaptchaEnabled(): bool
{
return $this->recaptchaService !== null && $this->recaptchaService->isRecaptchaEnabled();
}
/**
* Convierte bool a string JavaScript
*/
private function boolToJs(bool $value): string
{
return $value ? 'true' : 'false';
}
private function toBool($value): bool
{
return $value === true || $value === '1' || $value === 1;