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

@@ -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',

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\RecaptchaSettings\Infrastructure\FieldMapping;
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
/**
* Field Mapper para reCAPTCHA Settings
*
* RESPONSABILIDAD:
* - Mapear field IDs del formulario a atributos de BD
* - Solo conoce sus propios campos (modularidad)
*
* @package ROITheme\Admin\RecaptchaSettings\Infrastructure\FieldMapping
*/
final class RecaptchaSettingsFieldMapper implements FieldMapperInterface
{
public function getComponentName(): string
{
return 'recaptcha-settings';
}
public function getFieldMapping(): array
{
return [
// Visibility
'recaptchaIsEnabled' => ['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'],
];
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\RecaptchaSettings\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
/**
* FormBuilder para reCAPTCHA Settings
*
* RESPONSABILIDAD: Generar formulario de configuracion de Google reCAPTCHA v3
* para proteccion anti-spam de formularios.
*
* @package ROITheme\Admin\RecaptchaSettings\Infrastructure\Ui
*/
final class RecaptchaSettingsFormBuilder
{
public function __construct(
private AdminDashboardRenderer $renderer
) {}
public function buildForm(string $componentId): string
{
$html = '';
$html .= $this->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 = '<div class="rounded p-4 mb-4 shadow text-white" ';
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
$html .= ' <div>';
$html .= ' <h3 class="h4 mb-1 fw-bold">';
$html .= ' <i class="bi bi-shield-check me-2" style="color: #FF8600;"></i>';
$html .= ' Google reCAPTCHA v3';
$html .= ' </h3>';
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
$html .= ' Proteccion anti-spam invisible para formularios';
$html .= ' </p>';
$html .= ' </div>';
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="recaptcha-settings">';
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
$html .= ' Restaurar valores por defecto';
$html .= ' </button>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildVisibilityGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
$html .= ' Estado';
$html .= ' </h5>';
$isEnabled = $this->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 .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildCredentialsGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-key me-2" style="color: #FF8600;"></i>';
$html .= ' Credenciales';
$html .= ' </h5>';
$html .= ' <div class="row g-3">';
// Site Key
$html .= ' <div class="col-md-6">';
$siteKey = $this->renderer->getFieldValue($componentId, 'credentials', 'site_key', '');
$html .= $this->buildTextInput(
'recaptchaSiteKey',
'Site Key (Clave del sitio)',
$siteKey,
'Clave publica visible en frontend'
);
$html .= ' </div>';
// Secret Key
$html .= ' <div class="col-md-6">';
$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 .= ' </div>';
$html .= ' </div>';
$html .= ' <div class="alert alert-warning small mb-0 mt-3">';
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
$html .= ' Obtiene tus claves en <a href="https://www.google.com/recaptcha/admin" target="_blank" class="alert-link">Google reCAPTCHA Admin</a>. ';
$html .= ' Asegurate de seleccionar <strong>reCAPTCHA v3</strong>.';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildBehaviorGroup(string $componentId): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #1e3a5f;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-sliders me-2" style="color: #FF8600;"></i>';
$html .= ' Comportamiento';
$html .= ' </h5>';
$html .= ' <div class="row g-3">';
// Score Threshold
$html .= ' <div class="col-md-6">';
$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 .= ' </div>';
// Fail Open
$html .= ' <div class="col-md-6">';
$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 .= ' </div>';
$html .= ' </div>';
$html .= ' <hr class="my-3">';
$html .= ' <div class="row g-3">';
// Action Newsletter
$html .= ' <div class="col-md-4">';
$actionNewsletter = $this->renderer->getFieldValue($componentId, 'behavior', 'action_newsletter', 'newsletter_submit');
$html .= $this->buildTextInput(
'recaptchaActionNewsletter',
'Accion Newsletter',
$actionNewsletter,
'Identificador para formulario newsletter'
);
$html .= ' </div>';
// Action Contact
$html .= ' <div class="col-md-4">';
$actionContact = $this->renderer->getFieldValue($componentId, 'behavior', 'action_contact', 'contact_submit');
$html .= $this->buildTextInput(
'recaptchaActionContact',
'Accion Contacto',
$actionContact,
'Identificador para formulario contacto'
);
$html .= ' </div>';
// Log Blocked
$html .= ' <div class="col-md-4">';
$logBlocked = $this->renderer->getFieldValue($componentId, 'behavior', 'log_blocked', true);
$html .= $this->buildToggle(
'recaptchaLogBlocked',
'Registrar bloqueados',
$logBlocked,
'Guarda log de intentos bloqueados'
);
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildHelpSection(): string
{
$html = '<div class="card shadow-sm mb-3 bg-light">';
$html .= ' <div class="card-body">';
$html .= ' <h6 class="fw-bold mb-2" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-info-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Como funciona reCAPTCHA v3';
$html .= ' </h6>';
$html .= ' <ul class="small mb-0">';
$html .= ' <li><strong>Invisible:</strong> No requiere interaccion del usuario (sin checkboxes ni imagenes)</li>';
$html .= ' <li><strong>Score:</strong> Google asigna un score de 0.0 (bot) a 1.0 (humano)</li>';
$html .= ' <li><strong>Protege:</strong> Newsletter Footer y Formulario de Contacto</li>';
$html .= ' <li><strong>Logs:</strong> Los intentos bloqueados se registran en <code>wp-content/debug.log</code></li>';
$html .= ' </ul>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
private function buildToggle(string $id, string $label, mixed $value, string $helpText = ''): string
{
$checked = ($value === true || $value === '1' || $value === 1) ? ' checked' : '';
$html = ' <div class="mb-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '"' . $checked . '>';
$html .= ' <label class="form-check-label fw-semibold" for="' . esc_attr($id) . '">';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' </div>';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . esc_html($helpText) . '</div>';
}
$html .= ' </div>';
return $html;
}
private function buildTextInput(string $id, string $label, mixed $value, string $helpText = ''): string
{
$value = is_string($value) ? $value : '';
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <input type="text" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . esc_html($helpText) . '</div>';
}
$html .= ' </div>';
return $html;
}
private function buildPasswordInput(string $id, string $label, mixed $value, string $helpText = ''): string
{
$value = is_string($value) ? $value : '';
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <div class="input-group input-group-sm">';
$html .= ' <input type="password" class="form-control form-control-sm" id="' . esc_attr($id) . '" value="' . esc_attr($value) . '">';
$html .= ' <button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility(\'' . esc_attr($id) . '\')">';
$html .= ' <i class="bi bi-eye"></i>';
$html .= ' </button>';
$html .= ' </div>';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . esc_html($helpText) . '</div>';
}
$html .= ' </div>';
return $html;
}
private function buildSelect(string $id, string $label, mixed $value, array $options, string $helpText = ''): string
{
$value = is_string($value) ? $value : '';
$html = ' <div class="mb-3">';
$html .= ' <label for="' . esc_attr($id) . '" class="form-label small mb-1 fw-semibold">';
$html .= ' ' . esc_html($label);
$html .= ' </label>';
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($id) . '">';
foreach ($options as $optionValue => $optionLabel) {
$selected = ($value === (string) $optionValue) ? ' selected' : '';
$html .= ' <option value="' . esc_attr($optionValue) . '"' . $selected . '>' . esc_html($optionLabel) . '</option>';
}
$html .= ' </select>';
if (!empty($helpText)) {
$html .= ' <div class="form-text small">' . esc_html($helpText) . '</div>';
}
$html .= ' </div>';
return $html;
}
}

View File

@@ -35,6 +35,7 @@ final class FieldMapperProvider
'AdsensePlacement',
'ArchiveHeader',
'PostGrid',
'RecaptchaSettings',
];
public function __construct(

View File

@@ -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);
}
}

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,6 +434,18 @@ final class ContactFormRenderer implements RendererInterface
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
messageDiv.style.display = 'none';
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');
@@ -426,7 +453,6 @@ final class ContactFormRenderer implements RendererInterface
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
try {
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,6 +736,18 @@ JS;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>' + '{$sendingMessage}';
messageDiv.style.display = 'none';
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');
@@ -706,7 +755,6 @@ JS;
formData.append('pageUrl', window.location.href);
formData.append('pageTitle', document.title);
try {
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';
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ROITheme\Public\Footer\Infrastructure\Api\WordPress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Application\Services\RecaptchaValidationService;
/**
* NewsletterAjaxHandler - Procesa suscripciones al newsletter
@@ -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
* - Rate limiting basico
* - Sanitizacion de inputs
@@ -24,7 +26,8 @@ final class NewsletterAjaxHandler
private const COMPONENT_NAME = 'footer';
public function __construct(
private ComponentSettingsRepositoryInterface $settingsRepository
private ComponentSettingsRepositoryInterface $settingsRepository,
private ?RecaptchaValidationService $recaptchaService = null
) {}
/**
@@ -50,7 +53,15 @@ final class NewsletterAjaxHandler
return;
}
// 2. Rate limiting (1 suscripcion por IP cada 60 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 (1 suscripcion por IP cada 60 segundos)
if (!$this->checkRateLimit()) {
wp_send_json_error([
'message' => __('Por favor espera un momento antes de intentar de nuevo.', 'roi-theme')
@@ -58,7 +69,7 @@ final class NewsletterAjaxHandler
return;
}
// 3. Validar y sanitizar campos
// 4. Validar y sanitizar campos
$email = sanitize_email($_POST['email'] ?? '');
$name = sanitize_text_field($_POST['name'] ?? '');
$whatsapp = sanitize_text_field($_POST['whatsapp'] ?? '');
@@ -70,7 +81,7 @@ final class NewsletterAjaxHandler
return;
}
// 4. Obtener configuracion del componente
// 5. Obtener configuracion del componente
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
@@ -94,7 +105,7 @@ final class NewsletterAjaxHandler
return;
}
// 5. Preparar payload
// 6. Preparar payload
$payload = [
'email' => $email,
'name' => $name,
@@ -111,7 +122,7 @@ final class NewsletterAjaxHandler
// Debug: Log payload enviado
error_log('ROI Theme Newsletter: Enviando a webhook - ' . wp_json_encode($payload));
// 6. Enviar a webhook
// 7. Enviar a webhook
$result = $this->sendToWebhook($webhookUrl, $payload);
if ($result['success']) {
@@ -200,4 +211,31 @@ final class NewsletterAjaxHandler
return $ip;
}
/**
* 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 newsletter
$action = $this->recaptchaService->getNewsletterAction();
// Validar con el servicio
return $this->recaptchaService->validateSubmission($token, $action);
}
}

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;

View File

@@ -0,0 +1,90 @@
{
"component_name": "recaptcha-settings",
"version": "1.0.0",
"description": "Configuracion de Google reCAPTCHA v3 para proteccion anti-spam",
"groups": [
{
"name": "visibility",
"label": "Visibilidad",
"priority": 10,
"fields": [
{
"name": "is_enabled",
"label": "Habilitar reCAPTCHA",
"type": "boolean",
"default": false,
"description": "Activa la proteccion reCAPTCHA v3 en los formularios"
}
]
},
{
"name": "credentials",
"label": "Credenciales",
"priority": 20,
"fields": [
{
"name": "site_key",
"label": "Site Key",
"type": "text",
"default": "",
"description": "Clave publica de reCAPTCHA v3 (visible en frontend)"
},
{
"name": "secret_key",
"label": "Secret Key",
"type": "text",
"default": "",
"description": "Clave secreta de reCAPTCHA v3 (solo backend, nunca exponer)"
}
]
},
{
"name": "behavior",
"label": "Comportamiento",
"priority": 70,
"fields": [
{
"name": "score_threshold",
"label": "Umbral de Score",
"type": "select",
"default": "0.5",
"options": [
{"value": "0.3", "label": "0.3 - Permisivo (menos bloqueos)"},
{"value": "0.5", "label": "0.5 - Balanceado (recomendado)"},
{"value": "0.7", "label": "0.7 - Estricto"},
{"value": "0.9", "label": "0.9 - Muy estricto (mas bloqueos)"}
],
"description": "Score minimo para considerar humano (0.0=bot, 1.0=humano)"
},
{
"name": "action_newsletter",
"label": "Accion Newsletter",
"type": "text",
"default": "newsletter_submit",
"description": "Nombre de accion para formulario newsletter"
},
{
"name": "action_contact",
"label": "Accion Contacto",
"type": "text",
"default": "contact_submit",
"description": "Nombre de accion para formulario de contacto"
},
{
"name": "fail_open",
"label": "Permitir en caso de fallo",
"type": "boolean",
"default": true,
"description": "Si la API de Google falla, permitir el envio (fail-open)"
},
{
"name": "log_blocked",
"label": "Registrar intentos bloqueados",
"type": "boolean",
"default": true,
"description": "Guardar log de intentos bloqueados por bajo score"
}
]
}
]
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\Services;
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
/**
* RecaptchaValidationService - Servicio de aplicacion para validacion reCAPTCHA
*
* RESPONSABILIDAD: Orquestar la validacion de tokens reCAPTCHA v3,
* aplicando la configuracion del componente (threshold, fail-open, logging).
*
* DEPENDENCIAS (inyectadas via constructor):
* - RecaptchaValidatorInterface: Para validar tokens con API de Google
* - ComponentSettingsRepositoryInterface: Para obtener configuracion de BD
*
* USO:
* ```php
* $service = new RecaptchaValidationService($validator, $settingsRepo);
* $isValid = $service->validateSubmission($token, 'newsletter_submit');
* ```
*
* @package ROITheme\Shared\Application\Services
*/
final class RecaptchaValidationService
{
private const COMPONENT_NAME = 'recaptcha-settings';
public function __construct(
private RecaptchaValidatorInterface $validator,
private ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Validar un envio de formulario con reCAPTCHA
*
* @param string $token Token de reCAPTCHA del frontend
* @param string $action Accion a validar (ej: 'newsletter_submit')
* @return bool True si la validacion pasa, false si falla
*/
public function validateSubmission(string $token, string $action): bool
{
$settings = $this->getSettings();
// Si reCAPTCHA esta deshabilitado, permitir siempre
if (!$this->isEnabled($settings)) {
return true;
}
// Validar que tengamos las credenciales
$secretKey = $this->getSecretKey($settings);
if (empty($secretKey)) {
$this->log('reCAPTCHA: Secret key no configurada, permitiendo envio');
return true;
}
// Si no hay token y fail-open esta habilitado, permitir
if (empty($token)) {
if ($this->isFailOpen($settings)) {
$this->log('reCAPTCHA: Token vacio, fail-open habilitado');
return true;
}
$this->logBlocked('Token vacio', 0.0, $action);
return false;
}
// Validar con API de Google
$result = $this->validator->validate($token, $action, $secretKey);
// Manejar errores de API
if (!$result->isSuccess()) {
if ($this->isFailOpen($settings)) {
$this->log('reCAPTCHA: API error, fail-open habilitado: ' . implode(', ', $result->getErrorCodes()));
return true;
}
$this->logBlocked('API error: ' . implode(', ', $result->getErrorCodes()), 0.0, $action);
return false;
}
// Verificar threshold
$threshold = $this->getThreshold($settings);
$isValid = $result->isValid($threshold);
if (!$isValid && $this->shouldLogBlocked($settings)) {
$this->logBlocked('Score bajo', $result->getScore(), $action, $threshold);
}
return $isValid;
}
/**
* Verificar si reCAPTCHA esta habilitado
*
* @return bool True si esta habilitado
*/
public function isRecaptchaEnabled(): bool
{
return $this->isEnabled($this->getSettings());
}
/**
* Obtener el Site Key para uso en frontend
*
* @return string Site key o cadena vacia si no configurado
*/
public function getSiteKey(): string
{
$settings = $this->getSettings();
return $settings['credentials']['site_key'] ?? '';
}
/**
* Obtener la accion configurada para newsletter
*
* @return string Nombre de la accion
*/
public function getNewsletterAction(): string
{
$settings = $this->getSettings();
return $settings['behavior']['action_newsletter'] ?? 'newsletter_submit';
}
/**
* Obtener la accion configurada para contacto
*
* @return string Nombre de la accion
*/
public function getContactAction(): string
{
$settings = $this->getSettings();
return $settings['behavior']['action_contact'] ?? 'contact_submit';
}
/**
* Obtener configuracion del componente
*
* @return array<string, mixed>
*/
private function getSettings(): array
{
return $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
}
/**
* Verificar si esta habilitado
*
* @param array<string, mixed> $settings
* @return bool
*/
private function isEnabled(array $settings): bool
{
$isEnabled = $settings['visibility']['is_enabled'] ?? false;
return $isEnabled === true || $isEnabled === '1' || $isEnabled === 1;
}
/**
* Obtener secret key
*
* @param array<string, mixed> $settings
* @return string
*/
private function getSecretKey(array $settings): string
{
return $settings['credentials']['secret_key'] ?? '';
}
/**
* Obtener threshold configurado
*
* @param array<string, mixed> $settings
* @return float
*/
private function getThreshold(array $settings): float
{
$threshold = $settings['behavior']['score_threshold'] ?? '0.5';
return (float) $threshold;
}
/**
* Verificar si fail-open esta habilitado
*
* @param array<string, mixed> $settings
* @return bool
*/
private function isFailOpen(array $settings): bool
{
$failOpen = $settings['behavior']['fail_open'] ?? true;
return $failOpen === true || $failOpen === '1' || $failOpen === 1;
}
/**
* Verificar si se debe loguear intentos bloqueados
*
* @param array<string, mixed> $settings
* @return bool
*/
private function shouldLogBlocked(array $settings): bool
{
$logBlocked = $settings['behavior']['log_blocked'] ?? true;
return $logBlocked === true || $logBlocked === '1' || $logBlocked === 1;
}
/**
* Registrar mensaje en log
*
* @param string $message
*/
private function log(string $message): void
{
error_log('ROI Theme ' . $message);
}
/**
* Registrar intento bloqueado
*
* @param string $reason Razon del bloqueo
* @param float $score Score obtenido
* @param string $action Accion intentada
* @param float|null $threshold Threshold configurado
*/
private function logBlocked(string $reason, float $score, string $action, ?float $threshold = null): void
{
$ip = $this->getClientIP();
$thresholdStr = $threshold !== null ? ", threshold: {$threshold}" : '';
$this->log("reCAPTCHA BLOCKED: {$reason} | action: {$action} | score: {$score}{$thresholdStr} | IP: {$ip}");
}
/**
* Obtener IP del cliente (para logging)
*
* @return string
*/
private function getClientIP(): string
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
}
if (!empty($_SERVER['REMOTE_ADDR'])) {
return sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return 'unknown';
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
/**
* RecaptchaValidatorInterface - Contrato para validacion de reCAPTCHA
*
* RESPONSABILIDAD: Definir el contrato para validar tokens de reCAPTCHA v3
* con la API de Google.
*
* PRINCIPIOS:
* - Interface Segregation: Una sola responsabilidad - validar tokens
* - Dependency Inversion: Depender de abstraccion, no de implementacion
*
* USO:
* ```php
* final class GoogleRecaptchaValidator implements RecaptchaValidatorInterface
* {
* public function validate(string $token, string $action, string $secretKey): RecaptchaResult
* {
* // Llamar a API de Google y retornar resultado
* }
* }
* ```
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface RecaptchaValidatorInterface
{
/**
* Validar un token de reCAPTCHA v3 con la API de Google
*
* @param string $token Token generado por reCAPTCHA en frontend
* @param string $action Nombre de la accion (ej: 'newsletter_submit', 'contact_submit')
* @param string $secretKey Clave secreta de reCAPTCHA
* @return RecaptchaResult Resultado de la validacion con score y estado
*/
public function validate(string $token, string $action, string $secretKey): RecaptchaResult;
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Entities;
/**
* RecaptchaResult - Entidad que representa el resultado de validacion reCAPTCHA
*
* RESPONSABILIDAD: Encapsular el resultado de la validacion de reCAPTCHA v3
* incluyendo el score, estado de exito y posibles codigos de error.
*
* INMUTABILIDAD: Esta entidad es inmutable - una vez creada no puede modificarse.
*
* SCORE reCAPTCHA v3:
* - 1.0: Muy probablemente humano
* - 0.9: Probablemente humano
* - 0.5: Indeterminado
* - 0.1: Probablemente bot
* - 0.0: Muy probablemente bot
*
* @package ROITheme\Shared\Domain\Entities
*/
final class RecaptchaResult
{
/**
* @param bool $success Si la validacion fue exitosa (token valido)
* @param float $score Score de 0.0 (bot) a 1.0 (humano)
* @param string $action Accion verificada
* @param array<string> $errorCodes Codigos de error de la API
* @param string $hostname Hostname donde se genero el token
* @param string $challengeTs Timestamp del challenge
*/
public function __construct(
private bool $success,
private float $score,
private string $action,
private array $errorCodes = [],
private string $hostname = '',
private string $challengeTs = ''
) {}
/**
* Verificar si el resultado es valido segun un threshold
*
* @param float $threshold Score minimo requerido (ej: 0.5)
* @return bool True si success=true Y score >= threshold
*/
public function isValid(float $threshold): bool
{
return $this->success && $this->score >= $threshold;
}
/**
* Verificar si la accion coincide con la esperada
*
* @param string $expectedAction Accion esperada
* @return bool True si la accion coincide
*/
public function hasValidAction(string $expectedAction): bool
{
return $this->action === $expectedAction;
}
public function isSuccess(): bool
{
return $this->success;
}
public function getScore(): float
{
return $this->score;
}
public function getAction(): string
{
return $this->action;
}
/**
* @return array<string>
*/
public function getErrorCodes(): array
{
return $this->errorCodes;
}
public function hasErrors(): bool
{
return !empty($this->errorCodes);
}
public function getHostname(): string
{
return $this->hostname;
}
public function getChallengeTs(): string
{
return $this->challengeTs;
}
/**
* Crear resultado de fallo (para errores de red, timeout, etc.)
*
* @param array<string> $errorCodes Codigos de error
* @return self
*/
public static function failure(array $errorCodes = ['unknown-error']): self
{
return new self(
success: false,
score: 0.0,
action: '',
errorCodes: $errorCodes
);
}
/**
* Convertir a array para logging
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'success' => $this->success,
'score' => $this->score,
'action' => $this->action,
'error_codes' => $this->errorCodes,
'hostname' => $this->hostname,
'challenge_ts' => $this->challengeTs,
];
}
}

View File

@@ -48,6 +48,10 @@ use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
// reCAPTCHA Anti-spam System
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
use ROITheme\Shared\Infrastructure\Services\GoogleRecaptchaValidator;
use ROITheme\Shared\Application\Services\RecaptchaValidationService;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -542,4 +546,35 @@ final class DIContainer
}
return $this->instances['renderPostGridUseCase'];
}
// ===============================
// reCAPTCHA Anti-spam System
// ===============================
/**
* Obtiene el validador de reCAPTCHA (comunicacion con API de Google)
*/
public function getRecaptchaValidator(): RecaptchaValidatorInterface
{
if (!isset($this->instances['recaptchaValidator'])) {
$this->instances['recaptchaValidator'] = new GoogleRecaptchaValidator();
}
return $this->instances['recaptchaValidator'];
}
/**
* Obtiene el servicio de validacion reCAPTCHA
*
* Orquesta la validacion usando configuracion de BD
*/
public function getRecaptchaValidationService(): RecaptchaValidationService
{
if (!isset($this->instances['recaptchaValidationService'])) {
$this->instances['recaptchaValidationService'] = new RecaptchaValidationService(
$this->getRecaptchaValidator(),
$this->getComponentSettingsRepository()
);
}
return $this->instances['recaptchaValidationService'];
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\RecaptchaValidatorInterface;
use ROITheme\Shared\Domain\Entities\RecaptchaResult;
/**
* GoogleRecaptchaValidator - Implementacion de validacion reCAPTCHA con API de Google
*
* RESPONSABILIDAD: Comunicarse con la API de Google reCAPTCHA v3 para
* validar tokens y obtener scores.
*
* API ENDPOINT: https://www.google.com/recaptcha/api/siteverify
*
* RESPUESTA DE GOOGLE:
* ```json
* {
* "success": true|false,
* "score": 0.0-1.0,
* "action": "string",
* "challenge_ts": "timestamp",
* "hostname": "string",
* "error-codes": ["string"]
* }
* ```
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class GoogleRecaptchaValidator implements RecaptchaValidatorInterface
{
private const API_URL = 'https://www.google.com/recaptcha/api/siteverify';
private const TIMEOUT_SECONDS = 5;
/**
* Validar token de reCAPTCHA v3 con API de Google
*
* @param string $token Token generado por reCAPTCHA en frontend
* @param string $action Accion esperada
* @param string $secretKey Clave secreta de reCAPTCHA
* @return RecaptchaResult Resultado de la validacion
*/
public function validate(string $token, string $action, string $secretKey): RecaptchaResult
{
// Validar parametros
if (empty($token) || empty($secretKey)) {
return RecaptchaResult::failure(['missing-input-secret']);
}
// Preparar request
$response = wp_remote_post(self::API_URL, [
'timeout' => self::TIMEOUT_SECONDS,
'body' => [
'secret' => $secretKey,
'response' => $token,
'remoteip' => $this->getClientIP(),
],
]);
// Manejar error de conexion
if (is_wp_error($response)) {
error_log('ROI Theme reCAPTCHA API error: ' . $response->get_error_message());
return RecaptchaResult::failure(['connection-error']);
}
// Verificar codigo HTTP
$statusCode = wp_remote_retrieve_response_code($response);
if ($statusCode !== 200) {
error_log('ROI Theme reCAPTCHA API HTTP error: ' . $statusCode);
return RecaptchaResult::failure(['http-error-' . $statusCode]);
}
// Parsear respuesta JSON
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
error_log('ROI Theme reCAPTCHA API invalid JSON: ' . $body);
return RecaptchaResult::failure(['invalid-json']);
}
// Construir resultado
return new RecaptchaResult(
success: (bool) ($data['success'] ?? false),
score: (float) ($data['score'] ?? 0.0),
action: (string) ($data['action'] ?? ''),
errorCodes: (array) ($data['error-codes'] ?? []),
hostname: (string) ($data['hostname'] ?? ''),
challengeTs: (string) ($data['challenge_ts'] ?? '')
);
}
/**
* Obtener IP del cliente
*
* @return string
*/
private function getClientIP(): string
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return sanitize_text_field(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
}
if (!empty($_SERVER['REMOTE_ADDR'])) {
return sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
return '';
}
}

View File

@@ -347,10 +347,22 @@ function roi_render_component(string $componentName): string {
$renderer = new \ROITheme\Public\RelatedPost\Infrastructure\Ui\RelatedPostRenderer($cssGenerator);
break;
case 'contact-form':
$renderer = new \ROITheme\Public\ContactForm\Infrastructure\Ui\ContactFormRenderer($cssGenerator);
// Inyectar servicio reCAPTCHA para proteccion anti-spam
try {
$recaptchaService = \ROITheme\Shared\Infrastructure\Di\DIContainer::getInstance()->getRecaptchaValidationService();
} catch (\Throwable $e) {
$recaptchaService = null;
}
$renderer = new \ROITheme\Public\ContactForm\Infrastructure\Ui\ContactFormRenderer($cssGenerator, $recaptchaService);
break;
case 'footer':
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator);
// Inyectar servicio reCAPTCHA para proteccion anti-spam en newsletter
try {
$recaptchaService = \ROITheme\Shared\Infrastructure\Di\DIContainer::getInstance()->getRecaptchaValidationService();
} catch (\Throwable $e) {
$recaptchaService = null;
}
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator, $recaptchaService);
break;
case 'archive-header':
$renderer = new \ROITheme\Public\ArchiveHeader\Infrastructure\Ui\ArchiveHeaderRenderer($cssGenerator);

View File

@@ -143,15 +143,20 @@ try {
);
$adminAjaxHandler->register();
// Obtener servicio de validacion reCAPTCHA para inyectar en handlers
$recaptchaService = $container->getRecaptchaValidationService();
// Crear y registrar el handler AJAX para el Contact Form (público)
$contactFormAjaxHandler = new \ROITheme\Public\ContactForm\Infrastructure\Api\WordPress\ContactFormAjaxHandler(
$container->getComponentSettingsRepository()
$container->getComponentSettingsRepository(),
$recaptchaService
);
$contactFormAjaxHandler->register();
// Crear y registrar el handler AJAX para Newsletter (público)
$newsletterAjaxHandler = new \ROITheme\Public\Footer\Infrastructure\Api\WordPress\NewsletterAjaxHandler(
$container->getComponentSettingsRepository()
$container->getComponentSettingsRepository(),
$recaptchaService
);
$newsletterAjaxHandler->register();
@@ -295,9 +300,10 @@ add_action('wp_footer', function() use ($container) {
visibility: \ROITheme\Shared\Domain\ValueObjects\ComponentVisibility::allDevices()
);
// Crear renderer y renderizar modal
// Crear renderer y renderizar modal (con soporte reCAPTCHA)
$cssGenerator = new \ROITheme\Shared\Infrastructure\Services\CSSGeneratorService();
$renderer = new \ROITheme\Public\ContactForm\Infrastructure\Ui\ContactFormRenderer($cssGenerator);
$recaptchaService = $container->getRecaptchaValidationService();
$renderer = new \ROITheme\Public\ContactForm\Infrastructure\Ui\ContactFormRenderer($cssGenerator, $recaptchaService);
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $renderer->renderModal($component);