Files
fitme3070.pt/shared/platform-tools/functions_antispam.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
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>
2026-09-13 23:06:23 +01:00

275 lines
11 KiB
PHP

<?php
require_once __DIR__ . '/functions_settings.php';
// ─── Anti-spam de formulários públicos (partilhado) ─────────────────────────
//
// Sem captcha e sem serviço externo — o objectivo é travar bots automáticos,
// não pessoas.
//
// Nasceu na fotografia (pedidos de orçamento), foi refinado no hub (tickets) e
// vive agora aqui, porque a terceira cópia seria a que ninguém se lembraria de
// corrigir. O que era específico do hub — o limite de 24 h contado sobre a
// tabela `tickets` — passou a receber a tabela e as colunas como argumentos.
//
// O que NÃO vem daqui: a escalada de bloqueio de IP, que vive no
// functions_ip_ban.php. São coisas diferentes e de propósito: isto decide se
// esta submissão passa, aquilo lembra-se de quem falha sempre.
//
// Cinco camadas independentes, aplicadas por esta ordem:
// 1. honeypot — campo escondido que humanos deixam vazio
// 2. time-trap assinado — rápido demais ou velho demais == bot
// 3. blocklist — email/domínio/IP geridos pelo staff
// 4. heurística de texto — links a mais / BBCode / HTML
// 5. rate limit deslizante 24 h por email e por IP
//
// Cada site pode redefinir os limites antes de incluir este ficheiro: as
// constantes só são declaradas se ainda não existirem.
if (!defined('ANTISPAM_HONEYPOT')) define('ANTISPAM_HONEYPOT', 'website');
if (!defined('ANTISPAM_MIN_SECONDS')) define('ANTISPAM_MIN_SECONDS', 4);
if (!defined('ANTISPAM_MAX_SECONDS')) define('ANTISPAM_MAX_SECONDS', 7200);
if (!defined('ANTISPAM_MAX_PER_EMAIL_24H')) define('ANTISPAM_MAX_PER_EMAIL_24H', 3);
if (!defined('ANTISPAM_MAX_PER_IP_24H')) define('ANTISPAM_MAX_PER_IP_24H', 6);
/**
* Chave HMAC do time-trap. Prefere um segredo só do servidor; cai para o
* APP_BASE_URL para o formulário nunca deixar de funcionar num install novo.
*/
function antispam_secret(): string
{
foreach (['ANTISPAM_SECRET', 'APP_SECRET_KEY', 'CRON_TOKEN'] as $key) {
$v = (string) env($key, '');
if ($v !== '') {
return $v;
}
}
$base = (string) env('APP_BASE_URL', '');
return $base !== '' ? $base : 'andmore-antispam';
}
/** "<timestamp>.<hmac>" 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 '<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">'
. '<label>Não preencher<input type="text" name="' . $hp . '"'
. ' tabindex="-1" autocomplete="off" value=""></label></div>'
. '<input type="hidden" name="antispam_token" value="' . $token . '">';
}
/**
* 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, '<a href') !== false) {
return true;
}
return preg_match_all('#https?://|\bwww\.#i', $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);
}
}