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;
/**
* 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("<style>%s</style>\n%s\n<script>%s</script>", $css, $html, $js);
return sprintf("<style>%s</style>\n%s\n<script>%s</script>\n%s", $css, $html, $js, $recaptchaScript);
}
/**
@@ -276,6 +280,7 @@ final class ContactFormRenderer implements RendererInterface
// Right column - Form
$html .= '<div class="col-lg-7">';
$html .= sprintf('<form id="roiContactForm" data-nonce="%s">', esc_attr($nonce));
$html .= '<input type="hidden" name="recaptcha_token" id="contact-recaptcha-token" value="">';
$html .= '<div class="row g-3">';
// 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 = <<<JS
(function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('roiContactForm');
if (!form) return;
const recaptchaEnabled = {$this->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 = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$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 .= '<div class="modal-body">';
$html .= sprintf('<form id="roiContactModalForm" data-nonce="%s">', esc_attr($nonce));
$html .= '<input type="hidden" name="recaptcha_token" id="modal-contact-recaptcha-token" value="">';
$html .= '<div class="row g-3">';
// Full name field
@@ -680,17 +707,27 @@ JS;
// 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 = <<<JS
(function() {
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('roiContactModalForm');
if (!form) return;
const recaptchaEnabled = {$this->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('roiContactModalMessage');
const tokenInput = document.getElementById('modal-contact-recaptcha-token');
const originalBtnHtml = submitBtn.innerHTML;
const nonce = form.dataset.nonce;
@@ -699,14 +736,25 @@ JS;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$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
@@ -761,4 +809,38 @@ 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';
}
}