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:
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ final class FieldMapperProvider
|
||||
'AdsensePlacement',
|
||||
'ArchiveHeader',
|
||||
'PostGrid',
|
||||
'RecaptchaSettings',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
90
Schemas/recaptcha-settings.json
Normal file
90
Schemas/recaptcha-settings.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
249
Shared/Application/Services/RecaptchaValidationService.php
Normal file
249
Shared/Application/Services/RecaptchaValidationService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
42
Shared/Domain/Contracts/RecaptchaValidatorInterface.php
Normal file
42
Shared/Domain/Contracts/RecaptchaValidatorInterface.php
Normal 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;
|
||||
}
|
||||
134
Shared/Domain/Entities/RecaptchaResult.php
Normal file
134
Shared/Domain/Entities/RecaptchaResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
112
Shared/Infrastructure/Services/GoogleRecaptchaValidator.php
Normal file
112
Shared/Infrastructure/Services/GoogleRecaptchaValidator.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user