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>
220 lines
10 KiB
PHP
220 lines
10 KiB
PHP
<?php
|
|
// ─── Página de bloqueio de IP ───────────────────────────────────────────────
|
|
//
|
|
// Contrato do stub, como as outras partilhadas: $pdo, guarda de sysadmin,
|
|
// $repo_root.
|
|
//
|
|
// Duas listas, e a segunda é a que interessa vigiar: os banidos dizem o que já
|
|
// aconteceu, os "a subir" dizem o que está a acontecer agora. Um endereço com
|
|
// duas infracções ainda não foi banido -- ver a lista antes de ele lá chegar é
|
|
// a diferença entre perceber um padrão e descobri-lo depois.
|
|
//
|
|
// Levantar um banimento não apaga as infracções: se apagasse, o endereço voltava
|
|
// ao degrau zero e a escalada recomeçava do princípio, o que faria de cada
|
|
// engano de um administrador um convite a repetir tudo.
|
|
|
|
if (!isset($pdo) || !$pdo instanceof PDO) {
|
|
http_response_code(500);
|
|
exit('ip_bans.php: sem PDO — o stub tem de abrir a ligação primeiro.');
|
|
}
|
|
|
|
require_once __DIR__ . '/functions_ip_ban.php';
|
|
require_once __DIR__ . '/functions_csrf.php';
|
|
|
|
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
if (!platform_csrf_ok()) {
|
|
$_SESSION['ipban_flash'] = ['type' => 'danger', 'msg' => 'Pedido rejeitado (token CSRF inválido).'];
|
|
header('Location: ' . $selfUrl, true, 303);
|
|
exit;
|
|
}
|
|
$uid = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null;
|
|
$action = $_POST['action'] ?? '';
|
|
$ip = trim((string) ($_POST['ip'] ?? ''));
|
|
|
|
if ($action === 'unban' && $ip !== '') {
|
|
platform_ip_unban($pdo, $ip);
|
|
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "Banimento de $ip levantado. O histórico de infracções mantém-se."];
|
|
} elseif ($action === 'ban') {
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
$_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' => 'Endereço IP inválido.'];
|
|
} elseif (platform_ip_ban_exempt($ip)) {
|
|
$_SESSION['ipban_flash'] = ['type' => 'warning', 'msg' =>
|
|
'Endereços internos não podem ser banidos — o contentor fala consigo próprio para o cron e os health checks.'];
|
|
} else {
|
|
$dur = $_POST['duration'] ?? '60';
|
|
$mins = $dur === 'perm' ? null : max(1, (int) $dur);
|
|
$reason = trim((string) ($_POST['reason'] ?? '')) ?: 'banido manualmente';
|
|
platform_ip_ban($pdo, $ip, $reason, $mins, 'manual', $uid);
|
|
$_SESSION['ipban_flash'] = ['type' => 'success', 'msg' => "$ip banido."];
|
|
}
|
|
}
|
|
header('Location: ' . $selfUrl, true, 303);
|
|
exit;
|
|
}
|
|
|
|
$flash = $_SESSION['ipban_flash'] ?? null;
|
|
unset($_SESSION['ipban_flash']);
|
|
|
|
$bans = platform_ip_ban_list($pdo);
|
|
$offenders = platform_ip_offenders($pdo, 7);
|
|
$activos = array_values(array_filter($bans, fn($b) => empty($b['expired'])));
|
|
|
|
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
|
|
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
|
|
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
|
|
|
|
$pageTitle = 'Bloqueio de IP';
|
|
if (is_file($layout_header)) include $layout_header;
|
|
if (is_file($layout_navbar)) include $layout_navbar;
|
|
|
|
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
?>
|
|
<div class="container py-4" style="max-width:960px;">
|
|
|
|
<div class="mb-4">
|
|
<h3 class="fw-bold mb-1"><i class="bi bi-shield-slash me-2"></i>Bloqueio de IP</h3>
|
|
<p class="text-muted mb-0">
|
|
Endereços recusados à porta por atividade automatizada repetida.
|
|
</p>
|
|
</div>
|
|
|
|
<?php if ($flash): ?>
|
|
<div class="alert alert-<?= $h($flash['type']) ?> py-2"><?= $h($flash['msg']) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<div class="alert alert-light border small">
|
|
<strong>Como a escalada funciona.</strong> Cada recusa do antispam, do portão de
|
|
país ou do throttle de login conta como uma infracção. A partir de
|
|
<strong>3 infracções em 24 horas</strong> o endereço é banido 1 hora; às 5, um dia;
|
|
às 8, uma semana; às 12, permanentemente.
|
|
<span class="d-block mt-1 text-muted">
|
|
A subida é lenta de propósito: um IP é muitas vezes partilhado — uma escola, um
|
|
escritório, o CGNAT de um operador móvel — e banir para sempre à terceira
|
|
tentativa apanha pessoas a mais.
|
|
</span>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header fw-semibold">
|
|
<i class="bi bi-slash-circle me-1"></i>Banidos
|
|
<span class="badge text-bg-secondary ms-1"><?= count($activos) ?> activo<?= count($activos) === 1 ? '' : 's' ?></span>
|
|
</div>
|
|
<div class="card-body">
|
|
<?php if (!$bans): ?>
|
|
<p class="text-muted mb-0">Nenhum endereço banido até agora.</p>
|
|
<?php else: ?>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead><tr><th>IP</th><th>Motivo</th><th>Origem</th><th>Até</th><th class="text-end">Infr.</th><th class="text-end">Tentat.</th><th></th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($bans as $b): $activo = empty($b['expired']); ?>
|
|
<tr class="<?= $activo ? '' : 'text-muted' ?>">
|
|
<td><code><?= $h($b['ip']) ?></code></td>
|
|
<td class="small"><?= $h($b['reason']) ?></td>
|
|
<td><span class="badge text-bg-<?= $b['source'] === 'manual' ? 'primary' : 'secondary' ?>"><?= $h($b['source']) ?></span></td>
|
|
<td class="small">
|
|
<?php if ($b['expires_at'] === null): ?>
|
|
<span class="badge text-bg-danger">permanente</span>
|
|
<?php elseif ($activo): ?>
|
|
<?= $h(substr((string) $b['expires_at'], 0, 16)) ?>
|
|
<?php else: ?>
|
|
expirou
|
|
<?php endif; ?>
|
|
</td>
|
|
<td class="text-end"><?= (int) $b['offences'] ?></td>
|
|
<td class="text-end"><?= (int) $b['hits'] ?></td>
|
|
<td class="text-end">
|
|
<?php if ($activo): ?>
|
|
<form method="post" class="m-0 d-inline">
|
|
<?= platform_csrf_field() ?>
|
|
<input type="hidden" name="action" value="unban">
|
|
<input type="hidden" name="ip" value="<?= $h($b['ip']) ?>">
|
|
<button class="btn btn-outline-secondary btn-sm">Levantar</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="form-text mb-0 mt-2">
|
|
<strong>Tentat.</strong> é quantas vezes o endereço voltou depois de banido —
|
|
um número alto é o sinal mais claro de que não é uma pessoa.
|
|
</p>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header fw-semibold"><i class="bi bi-graph-up me-1"></i>A subir — infracções sem banimento (7 dias)</div>
|
|
<div class="card-body">
|
|
<?php if (!$offenders): ?>
|
|
<p class="text-muted mb-0">Nenhuma infracção registada no período.</p>
|
|
<?php else: ?>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead><tr><th>IP</th><th class="text-end">Infracções</th><th>Tipos</th><th>Última</th><th></th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($offenders as $o): ?>
|
|
<tr>
|
|
<td><code><?= $h($o['ip']) ?></code></td>
|
|
<td class="text-end fw-semibold"><?= (int) $o['n'] ?></td>
|
|
<td class="small text-muted"><?= $h($o['kinds']) ?></td>
|
|
<td class="small text-muted"><?= $h(substr((string) $o['last_seen'], 0, 16)) ?></td>
|
|
<td class="text-end">
|
|
<form method="post" class="m-0 d-inline">
|
|
<?= platform_csrf_field() ?>
|
|
<input type="hidden" name="action" value="ban">
|
|
<input type="hidden" name="ip" value="<?= $h($o['ip']) ?>">
|
|
<input type="hidden" name="duration" value="1440">
|
|
<input type="hidden" name="reason" value="banido a partir da lista de infracções">
|
|
<button class="btn btn-outline-danger btn-sm">Banir 24h</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm">
|
|
<div class="card-header fw-semibold"><i class="bi bi-plus-circle me-1"></i>Banir à mão</div>
|
|
<div class="card-body">
|
|
<form method="post" class="row g-2 align-items-end">
|
|
<?= platform_csrf_field() ?>
|
|
<input type="hidden" name="action" value="ban">
|
|
<div class="col-md-4">
|
|
<label class="form-label small" for="banIp">Endereço IP</label>
|
|
<input type="text" class="form-control form-control-sm font-monospace" id="banIp" name="ip"
|
|
placeholder="203.0.113.7" autocomplete="off">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small" for="banDur">Duração</label>
|
|
<select class="form-select form-select-sm" id="banDur" name="duration">
|
|
<option value="60">1 hora</option>
|
|
<option value="1440" selected>24 horas</option>
|
|
<option value="10080">7 dias</option>
|
|
<option value="perm">Permanente</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small" for="banReason">Motivo</label>
|
|
<input type="text" class="form-control form-control-sm" id="banReason" name="reason"
|
|
placeholder="porquê">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button class="btn btn-danger btn-sm w-100">Banir</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|