." para embeber num campo escondido no render. */ function antispam_token(): string { $ts = (string) time(); return $ts . '.' . hash_hmac('sha256', $ts, antispam_secret()); } /** Os dois campos escondidos. Colar em qualquer formulário público. */ function antispam_fields(): string { $hp = htmlspecialchars(ANTISPAM_HONEYPOT, ENT_QUOTES); $token = htmlspecialchars(antispam_token(), ENT_QUOTES); // Escondido fora do ecrã (não display:none, que alguns bots ignoram) e fora // da ordem de tabulação/autofill, para nenhum utilizador real lá chegar. return '' . ''; } /** * Verifica honeypot + time-trap. Em caso de falha, $reason fica com * 'honeypot' | 'toofast' | 'stale' | 'badtoken' para registo. */ /** * @param int $minSeconds Submetido mais depressa do que isto == bot. * @param int $maxSeconds Idade máxima do carimbo; 0 desliga o limite superior. * * Os dois são parâmetros e não constantes porque o formulário certo depende do * ecrã. Num pedido de orçamento, 4 s de mínimo e 2 h de validade são acertados. * Num LOGIN não são: um gestor de passwords preenche e submete num instante * (seria recusado), e uma página de login deixada aberta num separador durante * a tarde ficaria "velha" (também recusada) — em ambos os casos recusaríamos * exactamente a pessoa certa. */ function antispam_check( array $post, ?string &$reason = null, int $minSeconds = ANTISPAM_MIN_SECONDS, int $maxSeconds = ANTISPAM_MAX_SECONDS ): bool { if (trim((string) ($post[ANTISPAM_HONEYPOT] ?? '')) !== '') { $reason = 'honeypot'; return false; } $raw = (string) ($post['antispam_token'] ?? ''); $dot = strrpos($raw, '.'); if ($dot === false) { $reason = 'badtoken'; return false; } $ts = substr($raw, 0, $dot); $sig = substr($raw, $dot + 1); if (!ctype_digit($ts) || !hash_equals(hash_hmac('sha256', $ts, antispam_secret()), $sig)) { $reason = 'badtoken'; return false; } $age = time() - (int) $ts; if ($age < $minSeconds) { $reason = 'toofast'; return false; } if ($maxSeconds > 0 && $age > $maxSeconds) { $reason = 'stale'; return false; } return true; } /** * Blocklist gerida pelo staff (`system_settings.antispam_blocklist`, uma * entrada por linha). Formas aceites: * alguem@exemplo.com email exacto (sem distinguir maiúsculas) * @exemplo.com qualquer endereço desse domínio * 203.0.113.7 IP exacto * 203.0.113. prefixo de IP (ponto final = prefixo) * Linhas iniciadas por # são comentários. */ function antispam_blocked(string $email, string $ip): bool { // setting() é do stack config/; a silver não o tem. O primitivo do // platform-tools serve os dois. $raw = function_exists('setting') ? (string) setting('antispam_blocklist', '') : (isset($GLOBALS['pdo']) ? (string) platform_settings_get($GLOBALS['pdo'], 'antispam_blocklist', '') : ''); if (trim($raw) === '') { return false; } $email = mb_strtolower(trim($email)); foreach (preg_split('/\R+/', $raw) as $line) { $entry = mb_strtolower(trim($line)); if ($entry === '' || $entry[0] === '#') { continue; } if ($entry[0] === '@') { if ($email !== '' && str_ends_with($email, $entry)) return true; } elseif (str_contains($entry, '@')) { if ($email === $entry) return true; } elseif (str_ends_with($entry, '.')) { if ($ip !== '' && str_starts_with($ip, $entry)) return true; } else { if ($ip !== '' && $ip === $entry) return true; } } return false; } /** * Heurística de texto suspeito. Um pedido de suporte real pode trazer UM link * (o URL da página com problema); dois ou mais, ou marcação BBCode/HTML, é * quase sempre payload de bot. */ function antispam_message_suspect(?string ...$texts): bool { $joined = implode("\n", array_map(fn($t) => (string) $t, $texts)); if ($joined === '') { return false; } if (stripos($joined, '[url') !== false || stripos($joined, '= 2; } /** * Limite deslizante de 24 h sobre a tabela de submissões, por email e por IP. * * A tabela e as colunas vêm por argumento: no hub são os `tickets`, na * fotografia os pedidos de orçamento. Era a única parte deste módulo que não * era genérica. * * Os nomes de tabela e coluna são interpolados (não há placeholders para * identificadores em SQL), por isso passam por uma validação estrita — só * letras, dígitos e underscore. Vêm sempre do código do site, nunca de um * pedido, mas a validação torna isso impossível de contornar por engano. */ function antispam_submission_throttled( PDO $pdo, string $table, string $emailColumn, string $ipColumn, string $email, string $ip, ?string &$reason = null, ?int $maxPerEmail = null, ?int $maxPerIp = null ): bool { foreach ([$table, $emailColumn, $ipColumn] as $ident) { if (!preg_match('/^[A-Za-z0-9_]+$/', $ident)) { error_log('[antispam] identificador inválido: ' . $ident); return false; // falha aberto: não é o antispam que derruba o formulário } } $maxPerEmail ??= ANTISPAM_MAX_PER_EMAIL_24H; $maxPerIp ??= ANTISPAM_MAX_PER_IP_24H; $s = $pdo->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE `{$emailColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)" ); $s->execute([mb_strtolower($email)]); if ((int) $s->fetchColumn() >= $maxPerEmail) { $reason = 'email_rate'; return true; } if ($ip !== '') { $s = $pdo->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE `{$ipColumn}` = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)" ); $s->execute([$ip]); if ((int) $s->fetchColumn() >= $maxPerIp) { $reason = 'ip_rate'; return true; } } return false; } /** * Corre as cinco camadas por ordem e devolve o motivo da recusa, ou null. * * $submissions diz onde contar o limite de 24 h: ['tabela', 'coluna_email', * 'coluna_ip']. A null, a camada 5 não corre — um site sem tabela de * submissões usa na mesma as outras quatro. */ function antispam_screen(PDO $pdo, array $post, string $email, string $ip, ?array $submissions = null, string ...$texts): ?string { $reason = null; if (!antispam_check($post, $reason)) return $reason; if (antispam_blocked($email, $ip)) return 'blocklist'; if (antispam_message_suspect(...$texts)) return 'links'; if ($submissions !== null && antispam_submission_throttled($pdo, $submissions[0], $submissions[1], $submissions[2], $email, $ip, $reason)) { return $reason; } return null; } /** * Regista uma tentativa descartada, para o staff ver o que está a ser filtrado. * * É também o ponto único onde a infracção conta para o bloqueio de IP: todas as * recusas do antispam passam por aqui, portanto pendurar a escalada neste sítio * cobre o formulário de suporte e tudo o que venha a usá-lo, sem ter de andar a * lembrar de o acrescentar em cada um. */ function antispam_log_drop(PDO $pdo, string $reason, string $email, string $ip, string $subject = ''): void { try { $pdo->prepare( 'INSERT INTO antispam_drops (reason, submitter_email, ip_address, subject) VALUES (?, ?, ?, ?)' )->execute([$reason, mb_substr($email, 0, 190), $ip, mb_substr($subject, 0, 190)]); } catch (Throwable $e) { error_log('antispam_log_drop falhou: ' . $e->getMessage()); } // O IP explícito e não o do pedido: quem chama isto já o resolveu, e nos // casos em que a submissão vem de outro caminho é esse que vale. if (function_exists('platform_ip_record_offence')) { platform_ip_record_offence($pdo, 'antispam.' . mb_substr($reason, 0, 28), $subject !== '' ? $subject : $email, $ip); } }