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>
263 lines
10 KiB
PHP
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 [];
|
|
}
|
|
}
|