- 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>
642 lines
31 KiB
PHP
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 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; }
|