Files
fitme3070.pt/shared/platform-tools/functions_ip_ban.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

263 lines
10 KiB
PHP

<?php
// ─── Bloqueio de IP, com memória ────────────────────────────────────────────
//
// A quinta tinha três defesas por IP e nenhuma bloqueava:
//
// throttle de login temporário, por identificador+IP, esquece em 15 min
// contador do antispam por hora e por formulário, recomeça do zero à hora
// portão de país recusa por país, não por comportamento
//
// Nenhuma delas tem memória. Um IP que insista todos os dias às 3 da manhã é,
// para as três, um visitante novo de cada vez. Isto acrescenta a peça que
// faltava: as infracções ficam registadas, e a partir de um limiar o endereço
// passa a ser recusado à porta, antes de chegar ao formulário.
//
// A escalada é deliberadamente lenta e o primeiro banimento é curto. Um IP é
// muitas vezes partilhado -- uma escola, um escritório, o CGNAT de um operador
// móvel -- e banir alguém para sempre à terceira tentativa apanha pessoas a
// mais. Quem insiste é que sobe:
//
// 3 infracções em 24h → 1 hora
// 5 → 24 horas
// 8 → 7 dias
// 12 → permanente
//
// Nada disto se aplica a endereços internos: atrás do NPM o contentor fala
// consigo próprio para cron e health checks, e auto-banir-se seria uma avaria
// difícil de diagnosticar.
require_once __DIR__ . '/functions_client_ip.php';
/** [infracções em 24h => minutos de banimento]. null = permanente. */
const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null];
function platform_ip_ban_ensure_tables(PDO $pdo): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_bans` (
`ip` VARCHAR(45) NOT NULL PRIMARY KEY,
`reason` VARCHAR(190) NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'auto',
`expires_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` INT NULL,
`hits` INT NOT NULL DEFAULT 0,
KEY `idx_ip_bans_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
// As infracções vivem à parte do banimento: é a contagem delas que decide
// o degrau, e apagar um banimento não deve apagar o historico que o
// justificou -- senão um unban devolve a pessoa ao degrau zero e a escalada
// recomeça do princípio.
$pdo->exec("
CREATE TABLE IF NOT EXISTS `ip_offences` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`ip` VARCHAR(45) NOT NULL,
`kind` VARCHAR(40) NOT NULL,
`detail` VARCHAR(190) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_ip_offences` (`ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** Endereços que nunca são banidos: loopback, privados, link-local. */
function platform_ip_ban_exempt(?string $ip): bool
{
if ($ip === null || $ip === '') {
return true;
}
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
/**
* O banimento em vigor para este endereço, ou null.
*
* Um banimento expirado não é apagado -- fica como histórico, e a consulta
* ignora-o. Apagar linhas aqui perderia a razão pela qual alguém foi banido da
* primeira vez.
*/
function platform_ip_banned(PDO $pdo, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT * FROM ip_bans WHERE ip = ? AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1'
);
$st->execute([$ip]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
// Falha ABERTO: sem base de dados não se tranca ninguém à porta. Uma
// avaria de infraestrutura não deve transformar-se em site inacessível.
error_log('[ip-ban] consulta falhou, a deixar passar: ' . $e->getMessage());
return null;
}
}
/** Bane um endereço. $minutes null = permanente. */
function platform_ip_ban(PDO $pdo, string $ip, string $reason, ?int $minutes = 60,
string $source = 'auto', ?int $userId = null): bool
{
if (platform_ip_ban_exempt($ip)) {
return false;
}
try {
platform_ip_ban_ensure_tables($pdo);
$expires = $minutes === null ? null : date('Y-m-d H:i:s', time() + $minutes * 60);
$pdo->prepare(
'INSERT INTO ip_bans (ip, reason, source, expires_at, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
reason = VALUES(reason), source = VALUES(source),
expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)'
)->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]);
error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente'));
return true;
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage());
return false;
}
}
function platform_ip_unban(PDO $pdo, string $ip): bool
{
try {
platform_ip_ban_ensure_tables($pdo);
return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]);
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage());
return false;
}
}
/**
* Regista uma infracção e, se o limiar for atingido, bane.
*
* É este o ponto de ligação para as defesas que já existem: o antispam chama-o
* quando recusa, o throttle de login quando esgota as tentativas, o portão de
* país quando bloqueia. Cada uma continua a fazer o que fazia -- isto só lhes
* acrescenta a memória que nenhuma tinha.
*
* Devolve o banimento aplicado, ou null se ainda não chegou lá.
*/
function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = null, ?string $ip = null): ?array
{
$ip ??= platform_client_ip();
if (platform_ip_ban_exempt($ip)) {
return null;
}
try {
platform_ip_ban_ensure_tables($pdo);
$pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)')
->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]);
$st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY');
$st->execute([$ip]);
$n = (int) $st->fetchColumn();
// O degrau mais alto que este número atinge.
$minutes = false;
foreach (IP_BAN_LADDER as $threshold => $mins) {
if ($n >= $threshold) {
$minutes = $mins;
}
}
if ($minutes === false) {
return null;
}
platform_ip_ban($pdo, $ip, "$n infracções em 24h (última: $kind)", $minutes, 'auto');
return ['ip' => $ip, 'offences' => $n, 'minutes' => $minutes];
} catch (Throwable $e) {
error_log('[ip-ban] não registou a infracção: ' . $e->getMessage());
return null;
}
}
/**
* Corta o pedido se o endereço estiver banido. Chamar cedo, do bootstrap.
*
* 403 e não 404: um bot não lê nenhum dos dois, e para uma pessoa apanhada por
* engano um 403 com um email de contacto é a diferença entre perceber o que se
* passa e achar que o site está avariado.
*/
function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void
{
$ban = platform_ip_banned($pdo);
if ($ban === null) {
return;
}
try {
$pdo->prepare('UPDATE ip_bans SET hits = hits + 1 WHERE ip = ?')->execute([$ban['ip']]);
} catch (Throwable) { /* contar é acessório */ }
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
header('Retry-After: ' . ($ban['expires_at'] ? max(60, strtotime($ban['expires_at']) - time()) : 86400));
$mail = $contactEmail !== null
? '<p>Se acha que se trata de um engano, escreva para <a href="mailto:'
. htmlspecialchars($contactEmail) . '">' . htmlspecialchars($contactEmail) . '</a>.</p>'
: '';
echo '<!doctype html><meta charset="utf-8"><title>Acesso bloqueado</title>'
. '<div style="font:16px/1.6 system-ui,sans-serif;max-width:34em;margin:18vh auto;padding:0 1.5rem">'
. '<h1 style="font-size:1.3rem">Acesso bloqueado</h1>'
. '<p>O seu endereço foi bloqueado por atividade automatizada repetida.</p>'
. $mail . '</div>';
exit;
}
// ─── Consultas para o backoffice ────────────────────────────────────────────
function platform_ip_ban_list(PDO $pdo, int $limit = 200): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT b.*,
(SELECT COUNT(*) FROM ip_offences o WHERE o.ip = b.ip) AS offences,
(b.expires_at IS NOT NULL AND b.expires_at <= NOW()) AS expired
FROM ip_bans b
ORDER BY (b.expires_at IS NULL OR b.expires_at > NOW()) DESC, b.created_at DESC
LIMIT ' . max(1, min(500, $limit))
);
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}
/** Endereços com infracções mas ainda sem banimento — quem está a subir. */
function platform_ip_offenders(PDO $pdo, int $days = 7, int $limit = 30): array
{
try {
platform_ip_ban_ensure_tables($pdo);
$st = $pdo->prepare(
'SELECT o.ip, COUNT(*) AS n, MAX(o.created_at) AS last_seen,
GROUP_CONCAT(DISTINCT o.kind ORDER BY o.kind SEPARATOR ", ") AS kinds
FROM ip_offences o
LEFT JOIN ip_bans b ON b.ip = o.ip AND (b.expires_at IS NULL OR b.expires_at > NOW())
WHERE o.created_at >= NOW() - INTERVAL ? DAY AND b.ip IS NULL
GROUP BY o.ip
ORDER BY n DESC, last_seen DESC
LIMIT ' . max(1, min(200, $limit))
);
$st->execute([max(1, $days)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
}