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>
275 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|