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

642 lines
31 KiB
PHP

<?php
// ─── Página de configuração do bloqueio por país ────────────────────────────
//
// O módulo (functions_geo.php) nasceu configurado só por .env, o que obrigava a
// recriar o contentor para mudar seja o que for. Para um interruptor que se
// mexe quando um cliente legítimo fica de fora, isso é demasiado atrito -- e
// não era coerente com o SMTP, que é igualmente infraestrutura e tem página.
//
// A página grava em system_settings, que ganha ao .env (ver geo_env()). O .env
// continua a valer como valor de arranque: um site que nunca abra esta página
// comporta-se exactamente como antes.
//
// Carregada por um stub em views/sysadmin/geo.php, como as outras partilhadas.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('geo.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_geo.php';
// O token do platform-tools, carregado aqui e não presumido: numa plataforma que
// não o tivesse carregado por outro caminho (a silver), o formulário saía sem
// token e todas as gravações davam «Token inválido» (2026-09-23).
require_once __DIR__ . '/functions_csrf.php';
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
// Pelo front controller a página é /platform?tool=geo: sem o `tool`, o redirect
// depois de gravar ia parar ao índice do Sistema em vez de voltar aqui.
if (isset($_GET['tool'])) $selfUrl .= '?tool=' . rawurlencode((string) $_GET['tool']);
$user = function_exists('current_user') ? current_user() : null;
$uid = isset($user['id']) ? (int) $user['id'] : null;
$probe = trim((string) ($_GET['probe'] ?? ''));
$probeResult = null;
// Gravar uma definição. Pela função do site quando existe — a fotografia e o
// dance limpam a sua cache de definições nela —, senão pela do platform-tools,
// que é de onde o geo_env() lê. Até 2026-09-23 chamava sempre a do site, e numa
// plataforma sem ela (a silver) gravar dava um erro fatal.
$saveSetting = function (string $key, string $value) use ($pdo, $uid): void {
if (function_exists('db_update_setting')) {
db_update_setting($pdo, $key, $value, $uid);
} else {
require_once __DIR__ . '/functions_settings.php';
platform_settings_set($pdo, $key, $value, $uid);
}
};
// ─── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfOk = function_exists('csrf_verify')
? csrf_verify($_POST['csrf_token'] ?? '')
: (function_exists('platform_csrf_ok') && platform_csrf_ok());
if (!$csrfOk) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' => 'Token inválido.'];
header('Location: ' . $selfUrl);
exit;
}
if (($_POST['action'] ?? 'save') === 'refresh') {
// Corre o mesmo script que o cron corre. Não se duplica a lógica de
// validação aqui: é ela que impede um download mau de substituir uma
// lista boa, e duas cópias divergiriam.
$countries = geo_allowed_countries();
if (!$countries) {
$_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.'];
} else {
$args = ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1';
$out = [];
$code = 1;
exec(escapeshellcmd(__DIR__ . '/bin/geo-update.sh') . $args, $out, $code);
// O Relay corre a seguir e em separado: são duas fontes
// independentes, e a da Apple estar em baixo não pode impedir a
// actualização das listas de país (nem o contrário).
$relayOut = [];
$relayCode = 1;
exec(escapeshellcmd(__DIR__ . '/bin/geo-relay-update.sh') . $args, $relayOut, $relayCode);
$msg = implode(' · ', array_slice(array_merge($out, $relayOut), 0, 8));
$_SESSION['flash'] = ($code === 0 && $relayCode === 0)
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . $msg]
: ['type' => ($code === 0 ? 'warning' : 'danger'), 'msg' =>
($code === 0
? 'Países actualizados; o iCloud Private Relay falhou (lista anterior mantida). '
: 'A actualização falhou (as listas anteriores mantêm-se). ') . $msg];
}
header('Location: ' . $selfUrl);
exit;
}
// O botão do "acrescentar o meu endereço" grava só a lista de endereços e
// devolve a página. Parte do texto que veio no formulário, e não do que está
// gravado, para não deitar fora o que a pessoa tenha escrito antes de
// carregar -- que é a maneira mais rápida de alguém perder confiança numa
// página de configuração.
if (($_POST['action'] ?? 'save') === 'add_my_ip') {
$ip = platform_client_ip() ?? '';
$lista = preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($ip !== '' && !in_array($ip, $lista, true)) {
$lista[] = $ip;
}
$saveSetting('geo_allow_ips', implode(',', $lista));
$_SESSION['flash'] = ['type' => 'success', 'msg' => 'O endereço ' . $ip . ' passa sempre a partir de agora.'];
header('Location: ' . $selfUrl);
exit;
}
$mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off')));
if (!in_array($mode, ['off', 'forms', 'site'], true)) {
$mode = 'off';
}
// As caixas mais o campo de escape, tratados da mesma maneira. Um código que
// não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido: um país mal
// escrito na lista é indistinguível de "ninguém passa".
//
// O campo de escape existe porque a lista de caixas tem 72 países e não 249:
// ver o cabeçalho de data/countries-pt.php.
$rejected = [];
$valid = [];
$submetidos = array_merge(
array_map('strval', (array) ($_POST['geo_countries'] ?? [])),
preg_split('/[\s,;]+/', (string) ($_POST['geo_countries_extra'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []
);
foreach ($submetidos as $cc) {
$up = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $up)) {
$valid[$up] = true;
} else {
$rejected[] = $cc;
}
}
$countries = implode(',', array_keys($valid));
$failClosed = !empty($_POST['geo_fail_closed']) ? 'true' : 'false';
// Guarda-rede: ligar o modo `site` sem dados é tirar a plataforma do ar
// para toda a gente. Com fail-open não chega a acontecer, mas a combinação
// `site` + fail-closed + sem listas é irrecuperável pelo browser.
$haveData = false;
foreach (array_keys($valid) as $cc) {
if (geo_ranges($cc, false) || geo_ranges($cc, true)) {
$haveData = true;
break;
}
}
if ($mode !== 'off' && $failClosed === 'true' && !$haveData) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' =>
'Recusado: com "falhar fechado" e sem listas descarregadas, ninguém '
. 'conseguiria entrar — incluindo o utilizador. Actualize as listas primeiro.'];
header('Location: ' . $selfUrl);
exit;
}
// Endereços que passam sempre. Validados aqui pela mesma função que o
// módulo usa para os ler, para que uma entrada inválida seja recusada com
// aviso em vez de ficar gravada a não fazer nada -- que é indistinguível de
// "guardei e continua bloqueado".
$badIps = [];
$validIps = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_allow_ips'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
if (geo_cidr_to_range($entry) !== null) {
$validIps[$entry] = true;
} else {
$badIps[] = $entry;
}
}
$allowIps = implode(',', array_keys($validIps));
$trustRelay = !empty($_POST['geo_trust_relay']) ? 'true' : 'false';
$saveSetting('geo_mode', $mode);
$saveSetting('geo_allow_countries', $countries);
$saveSetting('geo_fail_closed', $failClosed);
$saveSetting('geo_allow_ips', $allowIps);
$saveSetting('geo_trust_relay', $trustRelay);
if (function_exists('platform_audit')) {
platform_audit($pdo, 'settings.geo', ['target_type' => 'settings', 'summary' => 'Bloqueio por país guardado',
'changes' => ['geo_mode' => $mode, 'geo_allow_countries' => $countries, 'geo_fail_closed' => $failClosed,
'geo_allow_ips' => $allowIps, 'geo_trust_relay' => $trustRelay]]);
}
$ignorado = array_merge(
$rejected ? ['códigos de país: ' . implode(', ', $rejected)] : [],
$badIps ? ['endereços: ' . implode(', ', $badIps)] : []
);
$_SESSION['flash'] = $ignorado
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não é válido e foi ignorado — ' . implode(' · ', $ignorado)]
: ['type' => 'success', 'msg' => 'Configuração guardada.'];
header('Location: ' . $selfUrl);
exit;
}
// ─── Estado actual ───────────────────────────────────────────────────────────
$mode = geo_mode();
$countries = geo_allowed_countries();
$failClosed = geo_fail_closed();
$allowIps = preg_split('/[\s,;]+/', (string) geo_env('GEO_ALLOW_IPS', ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$trustRelay = geo_trust_relay();
// A lista de caixas, e os códigos já guardados que não constam dela -- esses
// aparecem à mesma, num grupo à parte: uma página que perde silenciosamente um
// valor gravado é pior do que uma página feia.
$grupos = require __DIR__ . '/data/countries-pt.php';
$conhecidos = array_merge(...array_map('array_keys', array_values($grupos)));
$foraDaLista = array_values(array_diff($countries, $conhecidos));
/** Há alguma lista de endereços para este país? (não abre os ficheiros) */
$temDados = function (string $cc): bool {
$base = GEO_DATA_DIR . '/' . strtolower($cc);
return is_file($base . '.v4') || is_file($base . '.v6');
};
$relayOk = geo_relay_available($countries);
// De onde veio cada valor — é o que explica um "guardei e não mudou nada".
$origin = function (string $key) use ($pdo): string {
$db = trim((string) platform_settings_get($pdo, strtolower($key), ''));
if ($db !== '') return 'base de dados';
return (function_exists('env') && (string) env($key, '') !== '') ? '.env' : 'omissão';
};
$dataFiles = [];
foreach ($countries as $cc) {
// Duas fontes por país: a ipdeny (alocações nacionais) e a Apple (saídas do
// Private Relay ao serviço desse país). Lado a lado na mesma tabela porque
// a pergunta do operador é uma só -- "tenho dados para PT?".
foreach ([['', 'ipdeny'], ['relay-', 'iCloud Relay']] as [$prefix, $fonte]) {
foreach (['v4', 'v6'] as $fam) {
$key = $prefix . strtolower($cc);
$f = GEO_DATA_DIR . '/' . $key . '.' . $fam;
$dataFiles[] = [
'cc' => $cc,
'fonte' => $fonte,
'fam' => $fam,
'exists' => is_file($f),
'ranges' => is_file($f) ? count(geo_ranges($key, $fam === 'v6')) : 0,
'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—',
];
}
}
}
if ($probe !== '') {
$probeResult = filter_var($probe, FILTER_VALIDATE_IP)
? ['ip' => $probe, 'reason' => geo_block_reason($probe), 'internal' => geo_ip_is_internal($probe)]
: ['ip' => $probe, 'invalid' => true];
}
$myIp = platform_client_ip() ?? '';
// O stub pode dizer onde fica o hub e a página de submissões bloqueadas; nem
// todos os sites os têm no mesmo caminho (o design usa views/admin/).
$hubUrl = $platform_dashboard_url
?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$blockedUrl = $platform_blocked_url
?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null);
$pageTitle = 'Bloqueio por país';
// O layout do site, como nas outras páginas partilhadas (o router passa-o a
// partir do platform_connect()). Até 2026-09-23 esta página fixava
// includes/header.php e ignorava-o: num site com layout noutro sítio saía sem
// cabeçalho nenhum.
$layout_header = $platform_layout_header ?? (dirname(__DIR__, 2) . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? (dirname(__DIR__, 2) . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? (dirname(__DIR__, 2) . '/includes/footer.php');
if (is_file($layout_header)) { include $layout_header; }
if (is_file($layout_navbar)) { include $layout_navbar; }
$csrfField = function_exists('csrf_field') ? csrf_field()
: (function_exists('platform_csrf_field') ? platform_csrf_field() : '');
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:920px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-globe-americas me-2"></i>Bloqueio por país</h3>
<p class="text-muted mb-0">Recusa submissões — ou o site inteiro — a quem chega de fora da lista.</p>
</div>
<a href="<?= $h($hubUrl) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Sistema
</a>
</div>
<?php if (!empty($_SESSION['flash'])): $f = $_SESSION['flash']; unset($_SESSION['flash']); ?>
<div class="alert alert-<?= $h($f['type']) ?> py-2"><?= $h($f['msg']) ?></div>
<?php endif; ?>
<?php
// Uma frase que diz o que está a acontecer neste momento. É a pergunta com
// que se abre esta página, e antes só se respondia lendo três campos e
// sabendo de cor o que cada um faz.
$paisesTxt = $countries ? implode('/', $countries) : '(nenhum)';
[$cor, $frase] = match ($mode) {
'forms' => ['primary', 'Os formulários públicos só aceitam envios de <strong>' . $h($paisesTxt)
. '</strong>. Ler o site continua aberto a toda a gente.'],
'site' => ['danger', 'O site inteiro responde <strong>403</strong> a quem vier de fora de <strong>'
. $h($paisesTxt) . '</strong>.'],
default => ['secondary', 'O filtro está <strong>desligado</strong>. Ninguém é recusado por causa do país.'],
};
$extras = [];
if ($mode !== 'off' && $trustRelay && $relayOk) {
$extras[] = 'O iCloud Private Relay é atribuído ao país que serve.';
}
if ($allowIps) {
$extras[] = count($allowIps) . ' endereço' . (count($allowIps) === 1 ? '' : 's')
. ' na lista de excepções, que passam sempre.';
}
?>
<div class="alert alert-<?= $cor ?> py-2">
<?= $frase ?>
<?php foreach ($extras as $e): ?><span class="d-block small mt-1"><?= $h($e) ?></span><?php endforeach; ?>
</div>
<?php if ($mode !== 'off' && !$countries): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem países.</strong> Sem lista, o módulo fica inerte de propósito —
escolha pelo menos um país abaixo.
</div>
<?php elseif ($mode !== 'off' && !$dataFiles): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem listas.</strong> Nada é bloqueado — o módulo falha aberto de
propósito. Carregue em "Actualizar listas" abaixo.
</div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<form method="POST">
<?= $csrfField ?>
<div class="mb-4">
<label class="form-label fw-semibold">Modo</label>
<?php foreach ([
'off' => ['Desligado', 'O módulo não faz nada.'],
'forms' => ['Só os formulários', 'Registo, contacto e aula experimental. Ler o site continua aberto a toda a gente — motores de busca e pré-visualizações de links incluídos.'],
'site' => ['O site inteiro', 'Responde 403 a quem vier de fora. Também esconde o site de qualquer crawler alojado no estrangeiro, o que raramente é o que se quer.'],
] as $val => [$label, $desc]): ?>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="geo_mode" id="mode-<?= $val ?>"
value="<?= $val ?>" <?= $mode === $val ? 'checked' : '' ?>>
<label class="form-check-label" for="mode-<?= $val ?>">
<strong><?= $h($label) ?></strong>
<span class="d-block text-muted small"><?= $h($desc) ?></span>
</label>
</div>
<?php endforeach; ?>
<div class="form-text">Valor activo vem de: <strong><?= $h($origin('GEO_MODE')) ?></strong>.</div>
</div>
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<label class="form-label fw-semibold mb-0">Países permitidos</label>
<span class="badge rounded-pill text-bg-light border">
<?= count($countries) ?> seleccionado<?= count($countries) === 1 ? '' : 's' ?>
</span>
</div>
<!-- Começa escondido e é o JavaScript que o mostra: um campo de
procura que não procura nada é pior do que não haver campo. -->
<input type="search" id="geo-filtro" class="form-control form-control-sm mb-3 d-none"
placeholder="Procurar país…" autocomplete="off" aria-label="Procurar país">
<?php foreach ($grupos as $grupo => $lista): ?>
<?php
// O grupo abre quando tem algum país escolhido lá dentro. Sem
// isto, a lista guardada podia estar toda dentro de secções
// fechadas e a página parecia vazia.
$temEscolha = (bool) array_intersect(array_keys($lista), $countries);
?>
<details class="mb-2 geo-grupo" <?= ($temEscolha || $grupo === 'Mais usados') ? 'open' : '' ?>>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
<?= $h($grupo) ?>
<span class="text-body-tertiary fw-normal text-lowercase">(<?= count($lista) ?>)</span>
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($lista as $cc => $nome): ?>
<div class="col-6 col-md-4 geo-pais" data-nome="<?= $h(mb_strtolower($nome . ' ' . $cc, 'UTF-8')) ?>">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>"
<?= in_array($cc, $countries, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<?= $h($nome) ?>
<span class="text-body-tertiary small"><?= $h($cc) ?></span>
<?php if (in_array($cc, $countries, true) && !$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
<?php if ($foraDaLista): ?>
<details class="mb-2" open>
<summary class="fw-semibold small text-uppercase text-muted py-1" style="cursor:pointer;">
Guardados, fora da lista acima
</summary>
<div class="row g-1 ps-1 pt-1">
<?php foreach ($foraDaLista as $cc): ?>
<div class="col-6 col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="geo_countries[]"
value="<?= $h($cc) ?>" id="cc-<?= $h($cc) ?>" checked>
<label class="form-check-label" for="cc-<?= $h($cc) ?>">
<code><?= $h($cc) ?></code>
<?php if (!$temDados($cc)): ?>
<span class="badge text-bg-warning ms-1">sem listas</span>
<?php endif; ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endif; ?>
<div class="row g-2 align-items-end mt-2">
<div class="col-sm-5">
<label for="geo_countries_extra" class="form-label small text-muted mb-1">
Outro país (código ISO)
</label>
<input type="text" class="form-control form-control-sm" id="geo_countries_extra"
name="geo_countries_extra" placeholder="IE, SN" value="">
</div>
<div class="col-sm-7">
<div class="form-text mt-0">
Para um país que não esteja nas caixas. Fica guardado e passa a aparecer aqui.
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
</div>
</div>
</div>
</div>
<div class="mb-4 p-3 rounded border bg-body-tertiary">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_trust_relay"
name="geo_trust_relay" value="1" <?= $trustRelay ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_trust_relay">
<strong>Aceitar o iCloud Private Relay</strong>
<span class="d-block text-muted small">
O Safari com Private Relay sai por endereços da Apple, registados onde a Apple os
registou e não onde está a pessoa — quem está em Aveiro aparece-nos como
estrangeiro e é recusado. A Apple publica o país que cada gama serve, e com esta
opção é esse país que conta. <strong>Não é uma excepção ao filtro:</strong> uma
saída ao serviço de outro país continua a ser recusada como qualquer endereço
estrangeiro.
</span>
</label>
</div>
<div class="small mt-2 ps-4">
<?php if (!$countries): ?>
<span class="text-muted">Defina primeiro os países.</span>
<?php elseif ($relayOk): ?>
<span class="text-success"><i class="bi bi-check-circle me-1"></i>Listas do Relay instaladas.</span>
<?php else: ?>
<span class="text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1"></i>
Ainda sem listas do Relay — carregue em <strong>Actualizar listas</strong>, mais abaixo.
Até lá esta opção não muda nada.
</span>
<?php endif; ?>
</div>
</div>
<div class="mb-4">
<label for="geo_allow_ips" class="form-label fw-semibold">Endereços que passam sempre</label>
<textarea class="form-control font-monospace" id="geo_allow_ips" name="geo_allow_ips"
rows="3" spellcheck="false"
placeholder="85.240.0.0/16&#10;2001:8a0::/32"><?= $h(implode("\n", $allowIps)) ?></textarea>
<div class="form-text">
Um por linha, endereço solto ou gama em CIDR. Passam antes de qualquer outra
verificação — incluindo quando as listas de país estão em falta, que é o caso em que
isto é a única defesa que ainda funciona. Para o cliente que ficou de fora hoje, para
a VPN do escritório, para um serviço de monitorização.
<?php if ($allowIps): ?>
Activos: <strong><?= count($allowIps) ?></strong>, de
<strong><?= $h($origin('GEO_ALLOW_IPS')) ?></strong>.
<?php endif; ?>
</div>
<?php if ($myIp !== '' && !geo_ip_allowlisted($myIp)): ?>
<button type="submit" name="action" value="add_my_ip" class="btn btn-outline-secondary btn-sm mt-2">
<i class="bi bi-plus-lg me-1"></i>Acrescentar o meu endereço (<?= $h($myIp) ?>)
</button>
<?php endif; ?>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_fail_closed"
name="geo_fail_closed" value="1" <?= $failClosed ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_fail_closed">
<strong>Falhar fechado</strong>
<span class="d-block text-muted small">
Sem esta opção (recomendado), um ficheiro de listas em falta deixa passar toda a
gente e regista o problema no log. Com ela, recusa. Ligá-la transforma um ficheiro
velho numa indisponibilidade total — é por isso que vem desligada.
</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
</form>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h5 class="mb-0">Listas de endereços</h5>
<form method="POST" class="m-0">
<?= $csrfField ?>
<input type="hidden" name="action" value="refresh">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Actualizar listas
</button>
</form>
</div>
<?php if (!$dataFiles): ?>
<p class="text-muted mb-0">Sem países definidos.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>País</th><th>Origem</th><th>Família</th><th class="text-end">Gamas</th><th>Actualizado</th></tr></thead>
<tbody>
<?php foreach ($dataFiles as $d): ?>
<tr>
<td><?= $h($d['cc']) ?></td>
<td class="small"><?= $h($d['fonte']) ?></td>
<td><code><?= $h($d['fam']) ?></code></td>
<td class="text-end">
<?php if ($d['exists']): ?><?= (int) $d['ranges'] ?>
<?php else: ?><span class="badge bg-warning text-dark">em falta</span><?php endif; ?>
</td>
<td class="text-muted small"><?= $h($d['mtime']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
Alocações nacionais da ipdeny.com (mensal chega — movem-se devagar) e saídas do iCloud
Private Relay publicadas pela Apple (semanal, que é o ritmo a que aparecem gamas novas).
As duas são independentes: uma em baixo não impede a outra de actualizar, e um download
curto ou inválido é recusado com a lista anterior a manter-se.
</p>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="mb-3">Testar um endereço</h5>
<form method="GET" class="row g-2 align-items-end">
<div class="col-sm-7">
<label for="probe" class="form-label small text-muted mb-1">IP a testar</label>
<input type="text" class="form-control" id="probe" name="probe"
value="<?= $h($probe) ?>" placeholder="<?= $h($myIp ?: '2.80.0.1') ?>">
</div>
<div class="col-sm-5">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-search me-1"></i>Verificar
</button>
</div>
</form>
<?php if ($probeResult !== null): ?>
<div class="mt-3">
<?php if (!empty($probeResult['invalid'])): ?>
<div class="alert alert-warning py-2 mb-0"><code><?= $h($probeResult['ip']) ?></code> não é um endereço IP válido.</div>
<?php elseif ($probeResult['reason'] === null): ?>
<div class="alert alert-success py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> passa
<?= !empty($probeResult['internal']) ? ' — é um endereço interno, e esses passam sempre (cron e health checks do próprio contentor).' : '.' ?>
</div>
<?php else: ?>
<div class="alert alert-danger py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> seria recusado: <?= $h($probeResult['reason']) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($myIp !== ''): ?>
<p class="form-text mt-3 mb-0">
O seu endereço agora é <code><?= $h($myIp) ?></code>
<?php $mine = geo_block_reason($myIp); ?>
— <?= $mine === null ? 'passa' : '<strong class="text-danger">seria recusado: ' . $h($mine) . '</strong>' ?>.
</p>
<?php endif; ?>
</div>
</div>
<p class="text-muted small mt-4 mb-0">
As recusas ficam registadas com o motivo e o IP em
<a href="<?= $h($blockedUrl ?? '#') ?>">Submissões bloqueadas</a>.
</p>
</div>
<script>
// Procura entre as caixas de país. É enfeite: sem JavaScript o campo nem
// aparece (nasce com d-none) e a lista fica toda visível, que é o
// comportamento correcto -- a regra 10 do PHILOSOPHY.md diz que sem JS a
// página dá mais informação, não menos.
(function () {
var campo = document.getElementById('geo-filtro');
if (!campo) { return; }
campo.classList.remove('d-none');
campo.addEventListener('input', function () {
var termo = campo.value.trim().toLowerCase();
document.querySelectorAll('.geo-grupo').forEach(function (grupo) {
var visiveis = 0;
grupo.querySelectorAll('.geo-pais').forEach(function (pais) {
var bate = termo === '' || (pais.dataset.nome || '').indexOf(termo) !== -1;
pais.hidden = !bate;
if (bate) { visiveis++; }
});
// Abre o que tem resultados e esconde o grupo que ficou vazio, para
// não ficarem secções fechadas a esconder aquilo que se procurou.
grupo.hidden = visiveis === 0;
if (termo !== '' && visiveis > 0) { grupo.open = true; }
});
});
})();
</script>
<?php
if (is_file($layout_footer)) { include $layout_footer; }