- 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>
229 lines
11 KiB
PHP
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; ?>
|