'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 ';
}
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