En el post anterior comparamos reCAPTCHA con las alternativas que respetan la privacidad y la recomendación para tiendas PrestaShop 8 con tráfico europeo fue Cloudflare Turnstile. Este es el tutorial técnico: cómo integrarlo en el formulario de contacto, validar el token en el servidor y registrar cada intento. El código que ves aquí es el mismo que lleva el módulo Zeyvro Turnstile 1.1.10, copiado tal cual, no una versión simplificada.
Lo que necesitas antes de empezar
- Cuenta gratuita de Cloudflare. No hace falta mover el dominio a Cloudflare para usar Turnstile.
- Widget creado. dash.cloudflare.com → Turnstile → Add widget: tu dominio, modo managed y un nombre. Cloudflare te da la Site Key (pública, va en el HTML) y la Secret Key (privada, solo en el servidor).
- PrestaShop 8.0.0 a 9.1.3, que es el rango que declara el módulo. PrestaShop 1.7 no se cubre.
- PHP con la extensión cURL, para validar el token contra Cloudflare.
Paso 1: estructura del módulo
zeyvro_turnstile/
├── zeyvro_turnstile.php ← clase principal (hooks, validación, log)
├── config.xml
├── classes/ZeyvroModuleTrait.php ← base común de los módulos Zeyvro (pestaña del BO, cachés)
├── controllers/admin/AdminZeyvroTurnstileController.php ← configuración y log
├── views/templates/
│ ├── admin/settings.tpl
│ └── front/turnstile_widget.tpl ← inyección del widget
├── sql/install.sql
├── sql/uninstall.sql ← borrado manual y documentado de la tabla
└── upgrade/ ← scripts de actualización por versión
Paso 2: los tres hooks
El módulo se engancha a tres hooks y no usa actionContactFormSubmitCaptcha: su comportamiento cambia entre versiones de PrestaShop 8 y no está en todas las tiendas con módulos de contacto propios.
public function install(): bool
{
return parent::install()
&& $this->installBase() // trait: hook auto-reparación + ensureTabs + clearAllCaches
&& $this->installSql()
&& $this->installConfig()
&& $this->registerHook('displayHeader')
&& $this->registerHook('displayBeforeBodyClosingTag')
&& $this->registerHook('actionFrontControllerSetMedia');
}
displayHeader: carga el script de Turnstile, solo en la página de contacto.displayBeforeBodyClosingTag: inyecta el widget en el formulario.actionFrontControllerSetMedia: valida el token en el servidor antes de que PrestaShop procese el mensaje.
Paso 3: cargar el script solo en contacto
/**
* Inyecta el script de la API de Turnstile solo en la página de contacto.
*/
public function hookDisplayHeader($params): string
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
$phpSelf = isset($this->context->controller->php_self)
? $this->context->controller->php_self
: null;
if ($phpSelf !== 'contact') {
return '';
}
if (empty(Configuration::get('ZEYVRO_TURNSTILE_SITE_KEY'))) {
return '';
}
$this->context->controller->registerJavascript(
'turnstile-api',
'https://challenges.cloudflare.com/turnstile/v0/api.js',
['server' => 'remote', 'position' => 'head', 'priority' => 150, 'attributes' => 'async defer']
);
return '';
}
La comprobación es php_self === 'contact': el resto de páginas de la tienda no cargan nada de Cloudflare.
Paso 4: inyectar el widget sin tocar el tema
/**
* Inyecta el widget Turnstile en el formulario de contacto vía JS.
*/
public function hookDisplayBeforeBodyClosingTag($params): string
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
$phpSelf = isset($this->context->controller->php_self)
? $this->context->controller->php_self
: null;
if ($phpSelf !== 'contact') {
return '';
}
$siteKey = Configuration::get('ZEYVRO_TURNSTILE_SITE_KEY');
if (empty($siteKey)) {
return '';
}
$mode = Configuration::get('ZEYVRO_TURNSTILE_MODE') ?: 'managed';
$this->context->smarty->assign([
'sbt_site_key' => $siteKey,
'sbt_mode' => $mode,
]);
return $this->display(__FILE__, 'views/templates/front/turnstile_widget.tpl');
}
La plantilla turnstile_widget.tpl busca el formulario de contacto por sus campos (message, from, id_contact y el botón submitMessage) y coloca el widget justo antes del botón. Así funciona con cualquier tema, sin overrides:
<div id="sbt-placeholder"
data-sitekey="{$sbt_site_key|escape:'htmlall':'UTF-8'}"
data-mode="{$sbt_mode|escape:'html':'UTF-8'}"
style="display:none;"
aria-hidden="true"></div>
{literal}
<script>
(function () {
function sbtInjectWidget() {
var placeholder = document.getElementById('sbt-placeholder');
if (!placeholder) { return; }
var siteKey = placeholder.getAttribute('data-sitekey');
var mode = placeholder.getAttribute('data-mode') || 'managed';
// Detectar forms de contacto por inputs característicos del módulo contactform PS8
var contactForms = [];
document.querySelectorAll('form').forEach(function (f) {
if (f.querySelector('textarea[name="message"]')
&& f.querySelector('input[name="from"]')
&& f.querySelector('select[name="id_contact"]')
&& f.querySelector('button[name="submitMessage"], input[name="submitMessage"]')) {
contactForms.push(f);
}
});
if (contactForms.length === 0) { return; }
contactForms.forEach(function (form) {
// No duplicar si ya hay un widget en ESTE form
if (form.querySelector('.cf-turnstile')) { return; }
var widget = document.createElement('div');
widget.className = 'cf-turnstile sbt-turnstile-widget';
widget.style.marginBottom = '12px';
widget.setAttribute('data-sitekey', siteKey);
widget.setAttribute('data-theme', 'auto');
if (mode === 'non-interactive') {
widget.setAttribute('data-appearance', 'always');
widget.setAttribute('data-execution', 'render');
} else if (mode === 'invisible') {
widget.setAttribute('data-appearance', 'interaction-only');
widget.setAttribute('data-execution', 'render');
widget.setAttribute('data-size', 'invisible');
}
var btn = form.querySelector('button[name="submitMessage"]')
|| form.querySelector('button[type="submit"]')
|| form.querySelector('input[type="submit"]');
if (btn) {
btn.parentNode.insertBefore(widget, btn);
} else {
form.appendChild(widget);
}
});
if (placeholder.parentNode) { placeholder.parentNode.removeChild(placeholder); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sbtInjectWidget);
} else {
sbtInjectWidget();
}
})();
</script>
{/literal}
Paso 5: validar el token en el servidor
Es la parte que de verdad frena a los bots. Sin ella, un bot que haga el POST directamente, sin ejecutar JavaScript, pasa igual.
/**
* Validación server-side del token Turnstile en el formulario de contacto.
*/
public function hookActionFrontControllerSetMedia($params): void
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return;
}
if (!isset($this->context->controller->php_self)
|| $this->context->controller->php_self !== 'contact') {
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !Tools::isSubmit('submitMessage')) {
return;
}
$secretKey = Configuration::get('ZEYVRO_TURNSTILE_SECRET_KEY');
if (empty($secretKey)) {
return;
}
$token = (string) Tools::getValue('cf-turnstile-response', '');
$remoteIp = Tools::getRemoteAddr();
$result = $this->verifyCloudflareTurnstile($secretKey, $token, $remoteIp);
$this->logVerification($remoteIp, $result);
if (!$result['success']
&& Configuration::get('ZEYVRO_TURNSTILE_ACTION_ON_FAIL') !== 'log_only') {
$this->context->controller->errors[] = $this->l(
'Security verification failed. Please try again.'
);
// v1.1.8 — contactform < 4.4.3 (PS 8.0 / 8.1) guarda el mensaje en Servicio al cliente sin mirar
// controller->errors. Sin submitMessage no entra en sendMessage(); el email y el mensaje se conservan.
foreach (['submitMessage', 'submitMessage_x', 'submitMessage_y'] as $key) {
unset($_POST[$key], $_GET[$key]);
}
}
}
Si la verificación falla y la acción configurada es bloquear, el módulo añade el error al formulario y quita submitMessage de la petición. Ese segundo paso existe porque el módulo de contacto anterior a 4.4.3 (PrestaShop 8.0 y 8.1) guardaba el mensaje aunque hubiera errores.
private function verifyCloudflareTurnstile(string $secret, string $token, string $remoteIp): array
{
$timeout = max(1, (int) Configuration::get('ZEYVRO_TURNSTILE_API_TIMEOUT'));
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$postData = http_build_query([
'secret' => $secret,
'response' => $token,
'remoteip' => $remoteIp,
]);
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || !empty($curlError)) {
PrestaShopLogger::addLog('[zeyvro_turnstile] cURL error: ' . $curlError, 3);
return ['success' => false, 'error_codes' => ['network-error'], 'score' => null];
}
$data = json_decode($response, true);
if (!is_array($data)) {
return ['success' => false, 'error_codes' => ['invalid-response'], 'score' => null];
}
return [
'success' => !empty($data['success']),
'error_codes' => $data['error-codes'] ?? [],
'score' => isset($data['score']) ? (float) $data['score'] : null,
];
}
Si Cloudflare no responde dentro del timeout (5 s por defecto, configurable), la verificación cuenta como fallida con el código network-error. Qué pasa después lo decides tú en la configuración: bloquear (por defecto) o solo registrar.
Paso 6: registrar cada intento
Cada verificación se guarda en la tabla zeyvro_turnstile_log: IP, navegador, fecha, resultado y códigos de error.
private function installSql(): bool
{
return Db::getInstance()->execute('
CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . "zeyvro_turnstile_log` (
`id_log` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(255) NOT NULL DEFAULT '',
`date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`success` TINYINT(1) NOT NULL DEFAULT 0,
`score` DECIMAL(4,2) NULL,
`error_codes` TEXT NULL,
PRIMARY KEY (`id_log`),
KEY `idx_date` (`date_add`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
private function logVerification(string $ip, array $result): void
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_LOG_ENABLED')) {
return;
}
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
$errorCodes = !empty($result['error_codes']) ? implode(',', $result['error_codes']) : null;
Db::getInstance()->insert('zeyvro_turnstile_log', [
'ip' => pSQL($ip),
'user_agent' => pSQL(Tools::substr($ua, 0, 255)),
'date_add' => date('Y-m-d H:i:s'),
'success' => (int) $result['success'],
'score' => $result['score'] !== null ? (float) $result['score'] : null,
'error_codes' => $errorCodes !== null ? pSQL($errorCodes) : null,
]);
}
Desde el Back Office puedes ver el log y borrar las entradas de más de 30 días. Al desinstalar, la tabla y la configuración se conservan; para borrarlas del todo está sql/uninstall.sql.
Errores de Cloudflare que vas a ver
missing-input-response: no llegó token; el JavaScript del widget no se ejecutó.invalid-input-response: token mal formado o caducado.timeout-or-duplicate: token ya usado o de más de 5 minutos; normal en formularios largos.invalid-input-secret: la Secret Key no es correcta.network-error: lo pone el módulo cuando Cloudflare no responde a tiempo.
Qué no cubre
- Login y registro de clientes: solo protege el formulario de contacto nativo.
- Formularios propios metidos a mano en páginas CMS.
- Formularios que el tema carga por AJAX después de pintar la página (por ejemplo, en un popup).
Atajo: el módulo ya hecho
Todo lo anterior es el módulo Zeyvro Turnstile: gratis, licencia MIT, con la configuración y el log en el Back Office. Pegas la Site Key y la Secret Key, lo activas y listo.
La serie sigue con cómo medir cuánto spam te quita Turnstile con tus propios datos. Si algo de este tutorial no te cuadra con tu tienda, escríbenos a hola@zeyvro.com.