shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
20 KiB
PHP
491 lines
20 KiB
PHP
<?php
|
|
// ─── Anti-spam de formulários públicos: o motor de pontuação (partilhado) ────
|
|
//
|
|
// Nasceu no dance, a defender os formulários de contacto e de aula
|
|
// experimental — que são o funil de inscrições da academia — e vive agora aqui.
|
|
// A auditoria de 2026-09-07 encontrou CINCO implementações de anti-spam na
|
|
// frota, e a do dance era a única com pontuação de conteúdo. Também era, das
|
|
// que existiam, a que corria sem a validação de identificadores SQL que o
|
|
// functions_antispam.php ao lado já tinha: a cópia mais rica era a cópia sem a
|
|
// guarda. É o argumento inteiro contra implementações paralelas.
|
|
//
|
|
// ─── A relação com functions_antispam.php ───────────────────────────────────
|
|
//
|
|
// São dois módulos e não um por decisão, não por esquecimento. O outro
|
|
// (honeypot + blocklist + limite deslizante de 24 h por email e IP) está em
|
|
// produção no hub e tem outra API; fundi-los às cegas mudava o comportamento de
|
|
// um site a que ninguém pediu alterações. Este trata do que aquele não faz:
|
|
// **pontuar o conteúdo** de um formulário de angariação, onde recusar de mais
|
|
// custa inscrições e recusar de menos custa uma caixa de correio.
|
|
//
|
|
// Convergir os dois é trabalho para quando houver quem teste o hub. Até lá,
|
|
// quem escolhe é o site: este para formulários de angariação com texto livre,
|
|
// aquele para formulários de suporte com identidade conhecida.
|
|
//
|
|
// ─── O desenho, que se mantém do dance ──────────────────────────────────────
|
|
//
|
|
// Nada aqui devolve um erro a quem escreve. Uma submissão recusada recebe a
|
|
// mesma página de obrigado que uma válida — um bot não fica a saber o que o
|
|
// apanhou — e fica gravada, para que um falso positivo seja recuperável em vez
|
|
// de desaparecer. Um falso positivo é uma lead verdadeira.
|
|
//
|
|
// As camadas, da mais barata para a mais cara:
|
|
//
|
|
// 1. honeypot — campo escondido; preenchido é um bot.
|
|
// 2. carimbo assinado — `form_ts` + HMAC. Sem assinatura, o carimbo era um
|
|
// número que qualquer script punha à mão.
|
|
// 3. prova de JavaScript — um campo que só o browser preenche. NÃO bloqueia
|
|
// sozinho: quem desliga o JS continua a enviar.
|
|
// 4. limite por IP — inclui as tentativas já bloqueadas, senão bastava
|
|
// insistir para o contador nunca subir.
|
|
// 5. conteúdo — pontuação. Uma mensagem legítima pode trazer um
|
|
// link ("vi no vosso instagram"), por isso um link
|
|
// sozinho não chega ao limite e dois chegam.
|
|
//
|
|
// ─── Ligar num site ─────────────────────────────────────────────────────────
|
|
//
|
|
// require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
|
|
// platform_antispam_config([
|
|
// 'key_domain' => 'dsa-antispam|', // NÃO mudar num site a correr: ver abaixo
|
|
// 'log_table' => 'blocked_submissions',
|
|
// 'keywords' => [...], // vocabulário que não aparece no vosso negócio
|
|
// 'native' => '/\b(ola|gostaria|aulas?)\b/u',
|
|
// ]);
|
|
//
|
|
// `key_domain` entra no HMAC do carimbo. Mudá-lo num site a correr invalida os
|
|
// formulários que estiverem abertos nesse momento, e essas submissões são
|
|
// recusadas em silêncio — leads perdidas por uma questão de arrumação. Por isso
|
|
// é configurável e não fixo: cada site mantém o domínio com que já assina.
|
|
|
|
require_once __DIR__ . '/functions_settings.php';
|
|
require_once __DIR__ . '/functions_client_ip.php';
|
|
|
|
/**
|
|
* Configuração do módulo. Chamar com um array para definir; sem argumentos, lê.
|
|
* Os valores por omissão são conservadores de propósito — sem `keywords`, a
|
|
* camada de vocabulário simplesmente não pontua.
|
|
*/
|
|
function platform_antispam_config(?array $set = null): array
|
|
{
|
|
static $cfg = [
|
|
'key_domain' => 'antispam|',
|
|
'log_table' => 'blocked_submissions',
|
|
'min_seconds' => 4, // ninguém preenche um formulário em menos
|
|
'max_seconds' => 21600, // 6 h — separador aberto de manhã ainda serve
|
|
'score_block' => 5,
|
|
'ip_limit' => 5,
|
|
'ip_window' => 60, // minutos
|
|
'keywords' => [],
|
|
'native' => '', // regex das marcas da língua local (ver looks_foreign)
|
|
'setting_key' => 'antispam_key',
|
|
];
|
|
if ($set !== null) {
|
|
$cfg = array_merge($cfg, $set);
|
|
}
|
|
return $cfg;
|
|
}
|
|
|
|
/**
|
|
* Um identificador SQL que podemos interpolar?
|
|
*
|
|
* Nomes de tabela não podem ir por placeholder, e estas funções recebem-nos de
|
|
* quem chama. **Falha aberto**: um identificador inválido é um erro de
|
|
* programação do site, e não é o anti-spam que há-de derrubar o formulário por
|
|
* causa dele — regista-se e deixa-se passar.
|
|
*/
|
|
function platform_antispam_ident_ok(string $ident): bool
|
|
{
|
|
if (preg_match('/^[A-Za-z0-9_]+$/', $ident)) {
|
|
return true;
|
|
}
|
|
error_log('[antispam] identificador SQL inválido, ignorado: ' . $ident);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Chave dos HMAC do carimbo.
|
|
*
|
|
* Tem de ser **a mesma entre pedidos**: o formulário é assinado num pedido e
|
|
* verificado noutro, e uma chave que mudasse a cada pedido recusava toda a
|
|
* gente com "carimbo inválido". Daí os três degraus, todos estáveis:
|
|
*
|
|
* 1. APP_SECRET_KEY / BACKUP_TOKEN do ambiente (o caso normal);
|
|
* 2. uma chave aleatória gravada em system_settings, quando não há a primeira;
|
|
* 3. derivada da configuração do install, para instalações sem chave e sem a
|
|
* tabela de settings. Não é um segredo tão bom como os outros, mas é
|
|
* constante — e uma defesa que tranca todos os formulários é pior do que
|
|
* uma assinatura mais fraca.
|
|
*/
|
|
function platform_antispam_key(?PDO $pdo = null): string
|
|
{
|
|
static $key = null;
|
|
if ($key !== null) {
|
|
return $key;
|
|
}
|
|
$cfg = platform_antispam_config();
|
|
|
|
// Em bruto, e NÃO derivada com o domínio, quando vem do ambiente.
|
|
//
|
|
// Separar domínios seria melhor prática — a mesma chave assina carimbos e
|
|
// cifra backups. Mas a chave do HMAC não se muda num site a correr: os
|
|
// formulários abertos nesse instante passam a ter carimbo inválido e são
|
|
// recusados em silêncio, ou seja, leads perdidas por uma questão de
|
|
// arrumação. Fica como está, de propósito, e quem quiser trocar faz uma
|
|
// janela que aceite as duas assinaturas antes de tirar a antiga.
|
|
$fromEnv = platform_secret_key();
|
|
if ($fromEnv !== null) {
|
|
return $key = $fromEnv;
|
|
}
|
|
|
|
$stored = '';
|
|
if ($pdo instanceof PDO) {
|
|
$stored = (string) platform_settings_get($pdo, $cfg['setting_key'], '');
|
|
if ($stored === '') {
|
|
$fresh = bin2hex(random_bytes(32));
|
|
try {
|
|
// Nunca cifrada: platform_settings_is_secret('antispam_key') é
|
|
// falso de propósito. Cifrá-la aqui e lê-la noutro sítio sem
|
|
// decifrar dava uma chave diferente a cada leitura, ou seja
|
|
// todos os formulários recusados.
|
|
platform_settings_set($pdo, $cfg['setting_key'], $fresh, null, false);
|
|
$stored = $fresh;
|
|
} catch (Throwable $e) {
|
|
error_log('[antispam] chave não persistida (' . $e->getMessage() . '); a derivar da configuração');
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($stored === '') {
|
|
$env = function (string $k): string {
|
|
return function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
|
|
};
|
|
$stored = 'derived|' . $env('DB_NAME') . '|' . $env('DB_USER') . '|' . $env('DB_PASS')
|
|
. '|' . (defined('APP_BASE_URL') ? APP_BASE_URL : '');
|
|
}
|
|
|
|
return $key = hash('sha256', $cfg['key_domain'] . $stored, true);
|
|
}
|
|
|
|
function platform_antispam_sign(string $form, int $ts, ?PDO $pdo = null): string
|
|
{
|
|
return hash_hmac('sha256', $form . '|' . $ts, platform_antispam_key($pdo));
|
|
}
|
|
|
|
/**
|
|
* Campos-armadilha de um formulário público. `$form` entra na assinatura, para
|
|
* que um carimbo colhido no formulário de contacto não sirva no de inscrição.
|
|
*
|
|
* O <script> sai uma vez por página (dois formulários na mesma página partilham
|
|
* o mesmo) e preenche `form_js` com o início da assinatura — um cliente que só
|
|
* leia o HTML e reenvie os campos manda-o vazio, porque no HTML está vazio.
|
|
*/
|
|
function platform_antispam_trap_field(string $form = 'form', ?PDO $pdo = null): string
|
|
{
|
|
static $scriptPrinted = false;
|
|
|
|
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
|
$ts = time();
|
|
$sig = platform_antispam_sign($form, $ts, $pdo);
|
|
|
|
$html = '<input type="text" name="website" value="" tabindex="-1" autocomplete="off"'
|
|
. ' style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'
|
|
. '<input type="hidden" name="form_ts" value="' . $ts . '">'
|
|
. '<input type="hidden" name="form_sig" value="' . $esc($sig) . '">'
|
|
. '<input type="hidden" name="form_js" value="" data-js-check="' . $esc(substr($sig, 0, 12)) . '">';
|
|
|
|
if (!$scriptPrinted) {
|
|
$scriptPrinted = true;
|
|
$html .= '<script>document.querySelectorAll(\'input[data-js-check]\').forEach('
|
|
. 'function(i){i.value=i.dataset.jsCheck;});</script>';
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Recusa directa: honeypot, carimbo em falta/forjado, ou depressa/velho de mais.
|
|
* Devolve o motivo (para o registo) ou null quando passa.
|
|
*/
|
|
function platform_antispam_trap_reason(string $form, ?PDO $pdo = null): ?string
|
|
{
|
|
$cfg = platform_antispam_config();
|
|
|
|
if (trim((string) ($_POST['website'] ?? '')) !== '') {
|
|
return 'honeypot';
|
|
}
|
|
|
|
$ts = (int) ($_POST['form_ts'] ?? 0);
|
|
$sig = (string) ($_POST['form_sig'] ?? '');
|
|
if ($ts <= 0 || $sig === '' || !hash_equals(platform_antispam_sign($form, $ts, $pdo), $sig)) {
|
|
return 'carimbo inválido';
|
|
}
|
|
|
|
$elapsed = time() - $ts;
|
|
if ($elapsed < $cfg['min_seconds']) {
|
|
return 'submetido em ' . max(0, $elapsed) . 's';
|
|
}
|
|
if ($elapsed > $cfg['max_seconds']) {
|
|
return 'formulário expirado';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Texto comprido, em inglês corrente, sem uma única marca da língua do site.
|
|
*
|
|
* Vale pontos e não uma recusa: um estrangeiro a perguntar por aulas escreve
|
|
* exactamente assim, e esse tem de passar. A regex da língua local vem da
|
|
* configuração — sem ela, esta camada não pontua, porque um módulo partilhado
|
|
* não pode adivinhar em que língua escrevem os clientes de cada site.
|
|
*/
|
|
function platform_antispam_looks_foreign(string $text): bool
|
|
{
|
|
$cfg = platform_antispam_config();
|
|
if ($cfg['native'] === '' || mb_strlen($text) < 60) {
|
|
return false;
|
|
}
|
|
$lower = mb_strtolower($text, 'UTF-8');
|
|
|
|
if (preg_match($cfg['native'], $lower)) {
|
|
return false;
|
|
}
|
|
|
|
$en = preg_match_all(
|
|
'/\b(the|your|and|are|with|for|our|we|you|this|that|they|before|across|using|each|from|have|will|would|can|about|more|their)\b/u',
|
|
$lower
|
|
);
|
|
|
|
return $en >= 4;
|
|
}
|
|
|
|
/** O campo que só o browser preenche foi preenchido? */
|
|
function platform_antispam_has_js_proof(): bool
|
|
{
|
|
$sig = (string) ($_POST['form_sig'] ?? '');
|
|
$js = (string) ($_POST['form_js'] ?? '');
|
|
return $sig !== '' && $js !== '' && hash_equals(substr($sig, 0, 12), $js);
|
|
}
|
|
|
|
/**
|
|
* Pontuação do conteúdo. `$fields['name']` é tratado com mais severidade do que
|
|
* o texto livre: um link no nome não tem leitura inocente.
|
|
*
|
|
* @param array{name?:string, text?:string, modality?:string} $fields
|
|
* @param string[] $allowedChoices Lista fechada de um <select>; vazia = não verifica
|
|
* @return array{0:int, 1:string[]}
|
|
*/
|
|
function platform_antispam_content_score(array $fields, array $allowedChoices = []): array
|
|
{
|
|
$cfg = platform_antispam_config();
|
|
$name = trim((string) ($fields['name'] ?? ''));
|
|
$text = trim((string) ($fields['text'] ?? ''));
|
|
$all = $name . "\n" . $text;
|
|
$lower = mb_strtolower($all, 'UTF-8');
|
|
|
|
$score = 0;
|
|
$reasons = [];
|
|
|
|
$urlPattern = '~(https?://|www\.|\[url|<a\s)~i';
|
|
if (preg_match($urlPattern, $name)) {
|
|
$score += 5;
|
|
$reasons[] = 'link no nome';
|
|
}
|
|
$urlCount = preg_match_all($urlPattern, $text);
|
|
if ($urlCount >= 2) {
|
|
$score += 5;
|
|
$reasons[] = $urlCount . ' links na mensagem';
|
|
} elseif ($urlCount === 1) {
|
|
$score += 3;
|
|
$reasons[] = 'link na mensagem';
|
|
}
|
|
|
|
// Domínio escrito de propósito ao lado do filtro — "brnd .li/delist",
|
|
// "exemplo (dot) com". Quem escreve assim sabe que há um filtro a evitar, o
|
|
// que por si só diz o que a mensagem é. Vale 3 e não 5 porque apanha por
|
|
// engano um ponto mal espaçado ("a minha filha .Com sete anos").
|
|
if (preg_match('~\b[a-z0-9][a-z0-9-]+\s+\.\s?(com|net|org|io|co|li|ru|xyz|info|shop|link|site|online|biz)\b~i', $all)
|
|
|| preg_match('~\(\s*dot\s*\)|\bdot\s+(com|net|org)\b|\bhxxps?\b~i', $all)) {
|
|
$score += 3;
|
|
$reasons[] = 'domínio disfarçado';
|
|
}
|
|
|
|
if (preg_match('/[\p{Cyrillic}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\p{Arabic}\p{Hebrew}\p{Thai}]/u', $all)) {
|
|
$score += 5;
|
|
$reasons[] = 'escrita não-latina';
|
|
}
|
|
|
|
$hits = [];
|
|
foreach ($cfg['keywords'] as $word) {
|
|
if (str_contains($lower, $word)) {
|
|
$hits[] = $word;
|
|
}
|
|
}
|
|
if ($hits) {
|
|
$score += min(6, 3 * count($hits));
|
|
$reasons[] = 'vocabulário: ' . implode(', ', array_slice($hits, 0, 3));
|
|
}
|
|
|
|
// Dois pontos, e não três, por causa de um caso concreto: um estrangeiro a
|
|
// perguntar por aulas, com o JavaScript desligado, somava logo o limite.
|
|
if (platform_antispam_looks_foreign($text)) {
|
|
$score += 2;
|
|
$reasons[] = 'texto em língua estrangeira';
|
|
}
|
|
|
|
if (preg_match('~<\s*(a|script|iframe|img|div)\b~i', $all) || str_contains($lower, '[/url]')) {
|
|
$score += 3;
|
|
$reasons[] = 'html na mensagem';
|
|
}
|
|
|
|
$choice = trim((string) ($fields['modality'] ?? $fields['choice'] ?? ''));
|
|
if ($choice !== '' && $allowedChoices && !in_array($choice, $allowedChoices, true)) {
|
|
$score += 3;
|
|
$reasons[] = 'opção inexistente';
|
|
}
|
|
|
|
if (!platform_antispam_has_js_proof()) {
|
|
$score += 2;
|
|
$reasons[] = 'sem javascript';
|
|
}
|
|
|
|
return [$score, $reasons];
|
|
}
|
|
|
|
/**
|
|
* Quantas submissões deste IP na última janela, contando as bloqueadas — senão
|
|
* quem fosse recusado voltava a começar do zero a cada tentativa.
|
|
*/
|
|
function platform_antispam_ip_count(PDO $pdo, string $form, string $table, string $ip): int
|
|
{
|
|
if ($ip === '') {
|
|
return 0;
|
|
}
|
|
$cfg = platform_antispam_config();
|
|
$count = 0;
|
|
|
|
if (platform_antispam_ident_ok($table)) {
|
|
try {
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COUNT(*) FROM `{$table}` WHERE ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
|
|
);
|
|
$stmt->execute([$ip, $cfg['ip_window']]);
|
|
$count += (int) $stmt->fetchColumn();
|
|
} catch (PDOException) { /* tabela ainda não migrada — não trancar por isso */ }
|
|
}
|
|
|
|
$log = $cfg['log_table'];
|
|
if (platform_antispam_ident_ok($log)) {
|
|
try {
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COUNT(*) FROM `{$log}`
|
|
WHERE ip = ? AND form = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)"
|
|
);
|
|
$stmt->execute([$ip, $form, $cfg['ip_window']]);
|
|
$count += (int) $stmt->fetchColumn();
|
|
} catch (PDOException) { /* idem */ }
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* A verificação completa de um formulário público. Devolve null quando pode
|
|
* seguir, ou o motivo quando não — já registado.
|
|
*
|
|
* Chamar depois da verificação de CSRF. Quem chama responde sempre da mesma
|
|
* maneira (a página de obrigado), passe ou não.
|
|
*/
|
|
function platform_antispam_check(
|
|
PDO $pdo,
|
|
string $form,
|
|
string $table,
|
|
array $fields,
|
|
array $allowedChoices = []
|
|
): ?string {
|
|
// O IP verdadeiro, não o REMOTE_ADDR: atrás de um proxy o REMOTE_ADDR é
|
|
// sempre o mesmo, e o limite por IP passava a contar toda a gente no mesmo
|
|
// balde — cinco visitantes diferentes trancavam o formulário a todos, e um
|
|
// bot sozinho nunca chegava ao limite dele.
|
|
// platform_client_ip(), o nome canónico de functions_client_ip.php — e não
|
|
// auth_client_ip(), que é apenas um alias criado dentro do
|
|
// functions_auth.php. Um módulo partilhado não pode contar com que o site
|
|
// tenha carregado a autenticação: no dance tinha, e por isso o alias
|
|
// funcionava aqui sem ninguém reparar na dependência que faltava.
|
|
$ip = platform_client_ip() ?? '';
|
|
|
|
// O portão de país corre primeiro: é mais barato que as outras verificações
|
|
// e não depende de token nenhum. Desligado, devolve null.
|
|
if (function_exists('geo_form_block_reason')) {
|
|
$geoReason = geo_form_block_reason($ip ?: null);
|
|
if ($geoReason !== null) {
|
|
platform_antispam_log($pdo, $form, $geoReason, 0, $ip);
|
|
return $geoReason;
|
|
}
|
|
}
|
|
|
|
$cfg = platform_antispam_config();
|
|
$reason = platform_antispam_trap_reason($form, $pdo);
|
|
$score = 0;
|
|
|
|
if ($reason === null && platform_antispam_ip_count($pdo, $form, $table, $ip) >= $cfg['ip_limit']) {
|
|
$reason = 'limite de ' . $cfg['ip_limit'] . ' envios/hora deste IP';
|
|
}
|
|
|
|
if ($reason === null) {
|
|
[$score, $reasons] = platform_antispam_content_score($fields, $allowedChoices);
|
|
if ($score >= $cfg['score_block']) {
|
|
$reason = implode('; ', $reasons);
|
|
}
|
|
}
|
|
|
|
if ($reason === null) {
|
|
return null;
|
|
}
|
|
|
|
platform_antispam_log($pdo, $form, $reason, $score, $ip);
|
|
return $reason;
|
|
}
|
|
|
|
/**
|
|
* Grava a tentativa recusada. O payload é o $_POST inteiro menos o token CSRF —
|
|
* é o que permite ler um falso positivo e responder à pessoa à mesma.
|
|
*/
|
|
function platform_antispam_log(PDO $pdo, string $form, string $reason, int $score, string $ip): void
|
|
{
|
|
$cfg = platform_antispam_config();
|
|
$log = $cfg['log_table'];
|
|
if (!platform_antispam_ident_ok($log)) {
|
|
return;
|
|
}
|
|
|
|
$payload = $_POST;
|
|
unset($payload['csrf_token'], $payload['form_sig'], $payload['form_js'], $payload['form_ts']);
|
|
|
|
try {
|
|
$pdo->prepare(
|
|
"INSERT INTO `{$log}` (form, reason, score, ip, user_agent, payload)
|
|
VALUES (?, ?, ?, ?, ?, ?)"
|
|
)->execute([
|
|
$form,
|
|
mb_substr($reason, 0, 190),
|
|
$score,
|
|
$ip,
|
|
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 250),
|
|
mb_substr((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 0, 4000),
|
|
]);
|
|
|
|
// Cada recusa conta para o bloqueio de IP. É isto que dá memória às
|
|
// defesas: sozinho, este contador esquece tudo à hora seguinte, e um
|
|
// bot que volte todos os dias é sempre um visitante novo.
|
|
if (function_exists('platform_ip_record_offence')) {
|
|
platform_ip_record_offence($pdo, 'antispam:' . $form, mb_substr($reason, 0, 120), $ip ?: null);
|
|
}
|
|
} catch (PDOException $e) {
|
|
// Tabela por migrar, ou disco cheio: o bloqueio mantém-se, só não fica
|
|
// registado. Recusar por não se conseguir escrever o registo seria
|
|
// trocar spam por leads perdidas.
|
|
error_log('[antispam] registo falhou: ' . $e->getMessage());
|
|
}
|
|
}
|