Files
fitme3070.pt/shared/platform-tools/ip_bans.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

229 lines
11 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 arranque do site (config/db.php) 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);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.unban', ['target_type' => 'ip', 'target_label' => $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);
if (function_exists('platform_audit')) platform_audit($pdo, 'ip.ban', ['target_type' => 'ip', 'target_label' => $ip,
'summary' => $reason, 'changes' => ['minutes' => $mins]]);
$_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>
<span class="d-block mt-1 text-muted">
Com o site emparelhado com o hub, os banimentos daqui passam aos outros sites da frota e os
deles chegam cá com a origem <strong>frota</strong>. Levantar um banimento retira as cópias de
todos os sites; o site onde ele nasceu mantém o seu até expirar ou ser levantado lá.
</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-<?= match ($b['source']) { 'manual' => 'primary', 'fleet' => 'info', default => 'secondary' } ?>"
<?= $b['source'] === 'fleet' ? 'title="Banido noutro site da frota. Levantar aqui retira as cópias de todos os sites."' : '' ?>><?= $h($b['source'] === 'fleet' ? 'frota' : $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; ?>