'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. * * A mesma escada vive agora em platform_derived_key() (functions_settings.php), * que é o que os módulos novos usam — o captcha, por exemplo. Esta cópia fica * porque separa os domínios de outra maneira (ver o comentário lá dentro sobre * a chave do ambiente) e porque trocá-la num site a correr invalida os * formulários abertos nesse instante. Quem a converter faz uma janela que * aceite as duas assinaturas antes de tirar a antiga. */ 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