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

545 lines
19 KiB
PHP

<?php
// ─── Country gating ─────────────────────────────────────────────────────────
//
// Some platforms in the estate only ever serve one country. dance is the clear
// case: an academy in Aveiro takes students who can walk into the building, so
// a registration from outside Portugal is spam by definition, not a lost sale.
// Filtering on content alone was losing that argument — the bots write valid
// Portuguese-looking names and use real mailboxes.
//
// Two levers, because they carry very different risk:
//
// GEO_MODE=forms — public POSTs (register, contact, trial) are refused from
// outside the allow-list. Reading the site stays open, so
// a search engine, a preview crawler or an emigrant reading
// the timetable is unaffected. This is the safe default.
// GEO_MODE=site — the whole platform is refused. Only sensible when nobody
// abroad has any business here at all; it also hides the
// site from crawlers hosted outside the country, which is
// usually not what anyone wants.
// GEO_MODE=off — module inert (the default when the env is unset).
//
// The data is the per-country CIDR list from ipdeny.com, refreshed by
// bin/geo-update.sh into data/geo/<cc>.v4 and <cc>.v6. It is committed to the
// repo so a deploy never depends on that host being up, and a cron refresh
// keeps it current — allocations move slowly, monthly is plenty.
//
// Failure posture is fail-OPEN, deliberately. A missing or corrupt data file
// means "allow and log loudly", never "lock everyone out". Getting this
// backwards turns a stale file into a total outage; the worst case of failing
// open is that the spam gets through the way it already does today. Set
// GEO_FAIL_CLOSED=true to invert it, if a platform would rather refuse.
require_once __DIR__ . '/functions_client_ip.php'; // platform_client_ip()
const GEO_DATA_DIR = __DIR__ . '/data/geo';
/** off | forms | site */
function geo_mode(): string
{
$m = strtolower(trim((string) geo_env('GEO_MODE', 'off')));
return in_array($m, ['forms', 'site'], true) ? $m : 'off';
}
/** Country codes that may reach us, upper-case. Empty means "no gate". */
function geo_allowed_countries(): array
{
$raw = (string) geo_env('GEO_ALLOW_COUNTRIES', '');
$out = [];
foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cc) {
$cc = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $cc)) {
$out[] = $cc;
}
}
return array_values(array_unique($out));
}
function geo_fail_closed(): bool
{
return filter_var(geo_env('GEO_FAIL_CLOSED', 'false'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Config value: primeiro a base de dados, depois o .env, depois o default.
*
* A base ganha para a página do backoffice servir para alguma coisa -- mudar o
* modo no .env obriga a recriar o contentor, o que não é coisa que se peça a
* quem só quer desligar o filtro porque um cliente ficou de fora.
*
* Uma linha vazia conta como "não definido" e cai para o .env. É de propósito:
* em `platform_settings_get()` uma string vazia gravada silenciosamente ganha a
* um .env preenchido, e essa foi das armadilhas mais caras desta plataforma.
* Aqui vazio quer mesmo dizer vazio.
*
* O $pdo é apanhado do global porque este ficheiro é carregado por caminhos que
* ainda não têm ligação (o guard do site corre cedo). Sem base, usa o .env.
*
* A cache é por pedido. A página de configuração grava e redirige, por isso o
* pedido seguinte já lê o valor novo -- não é preciso invalidá-la à mão.
*/
function geo_env(string $key, string $default = ''): string
{
static $dbCache = [];
if (!array_key_exists($key, $dbCache)) {
global $pdo;
// So se guarda em cache o que a base de dados chegou mesmo a responder.
//
// Guardar tambem o falhanco parecia inofensivo e nao era: o guard do
// site corre cedo no bootstrap, antes de o functions_settings.php estar
// carregado, e a primeira pergunta -- GEO_MODE -- apanhava sempre o
// caminho sem base. O vazio ficava colado ao pedido inteiro, por isso o
// modo lia-se do .env (logo "off") enquanto a lista de paises, pedida
// mais tarde, ja vinha certa da base. O interruptor da pagina gravava e
// nao acendia nada, e a assimetria entre os dois valores tornava a
// avaria dificil de ver.
$consultada = false;
$valor = '';
if ($pdo instanceof PDO && function_exists('platform_settings_get')) {
try {
$valor = trim((string) platform_settings_get($pdo, strtolower($key), ''));
$consultada = true;
} catch (Throwable) {
$consultada = false;
}
}
if (!$consultada) {
// Sem base ainda: responde-se pelo .env desta vez e tenta-se de novo
// na proxima chamada, que pode ja ter ligacao.
return geo_env_fallback($key, $default);
}
$dbCache[$key] = $valor;
}
if ($dbCache[$key] !== '') {
return $dbCache[$key];
}
return geo_env_fallback($key, $default);
}
/** O valor do .env (ou do ambiente), quando a base nao tem nada a dizer. */
function geo_env_fallback(string $key, string $default = ''): string
{
if (function_exists('env')) {
$v = (string) env($key, '');
if ($v !== '') {
return $v;
}
return $default;
}
$v = getenv($key);
if ($v === false || $v === '') {
$v = $_ENV[$key] ?? $default;
}
return (string) $v;
}
/**
* Ranges for one country, as [startBinary, endBinary] pairs sorted ascending.
*
* Addresses are compared as the packed bytes inet_pton() produces, so the same
* strcmp() works for v4 (4 bytes) and v6 (16) without any integer arithmetic —
* and without the signed-32-bit trap ip2long() sets for anything above
* 127.255.255.255.
*/
function geo_ranges(string $cc, bool $v6): array
{
static $cache = [];
$key = $cc . ($v6 ? '.v6' : '.v4');
if (isset($cache[$key])) {
return $cache[$key];
}
$file = GEO_DATA_DIR . '/' . strtolower($cc) . ($v6 ? '.v6' : '.v4');
if (!is_file($file)) {
return $cache[$key] = [];
}
$ranges = [];
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
$r = geo_cidr_to_range($line);
if ($r !== null) {
$ranges[] = $r;
}
}
usort($ranges, fn($a, $b) => strcmp($a[0], $b[0]));
return $cache[$key] = $ranges;
}
/** "2.80.0.0/14" → [packed first address, packed last address], or null. */
function geo_cidr_to_range(string $cidr): ?array
{
$parts = explode('/', $cidr, 2);
$net = $parts[0];
$packed = @inet_pton($net);
if ($packed === false) {
return null;
}
$bits = strlen($packed) * 8;
$prefix = isset($parts[1]) ? (int) $parts[1] : $bits;
if ($prefix < 0 || $prefix > $bits) {
return null;
}
// Set every host bit to get the top of the range; the network address as
// given is the bottom. Done byte by byte so v4 and v6 share the code.
$end = $packed;
$host = $bits - $prefix;
for ($i = strlen($end) - 1; $i >= 0 && $host > 0; $i--) {
$take = min(8, $host);
$mask = (1 << $take) - 1;
$end[$i] = chr(ord($end[$i]) | $mask);
$host -= $take;
}
return [$packed, $end];
}
/** Is this address inside one of the country's ranges? */
function geo_ip_in_country(string $ip, string $cc): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
$ranges = geo_ranges($cc, strlen($packed) === 16);
if (!$ranges) {
return false;
}
$lo = 0;
$hi = count($ranges) - 1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
if (strcmp($packed, $ranges[$mid][0]) < 0) {
$hi = $mid - 1;
} elseif (strcmp($packed, $ranges[$mid][1]) > 0) {
$lo = $mid + 1;
} else {
return true;
}
}
return false;
}
/**
* Loopback, private and link-local addresses always pass.
*
* Behind NPM the container talks to itself for health checks and cron, and a
* platform whose own cron is geo-blocked fails in ways that take a long time to
* understand.
*/
function geo_ip_is_internal(string $ip): bool
{
if ($ip === '') {
return true;
}
return !filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
}
// ─── Duas maneiras de não bloquear quem não devia ser bloqueado ─────────────
//
// O portão por país compara o endereço com as alocações da ipdeny, e isso
// pressupõe uma coisa que já não é verdade para toda a gente: que o endereço de
// saída fica no país de quem navega. Dois casos em que não fica, e ambos
// custaram leads reais:
//
// 1. **iCloud Private Relay.** O tráfego do Safari sai por endereços da Apple
// (e dos parceiros dela), registados onde a Apple os registou e não onde
// está a pessoa. Alguém em Aveiro aparece-nos como estrangeiro, é recusado
// em silêncio, e fica à espera de uma resposta que nunca vem.
// 2. **VPNs de empresa, o escritório do cliente, um serviço de monitorização.**
// Casos que ninguém adivinha de antemão e que aparecem um de cada vez.
//
// Daí duas listas, com propósitos diferentes:
//
// GEO_ALLOW_IPS à mão, para o caso concreto que apareceu hoje.
// data/geo/icloud-relay.* automática, e com o país lá dentro.
//
// A diferença da segunda é que **não é uma whitelist**: a Apple publica, para
// cada gama de saída, o país que ela serve. Um utilizador de Relay em Portugal
// passa a contar como PT; um em França continua a contar como FR e o portão
// recusa-o na mesma. Uma whitelist cega às gamas do Relay abria o portão a
// qualquer subscritor do iCloud+ no mundo inteiro, que é precisamente o que o
// portão existe para não fazer.
/** Gamas que passam sempre, escritas à mão. Vazio = não há lista. */
function geo_allow_ips(): array
{
$raw = (string) geo_env('GEO_ALLOW_IPS', '');
$out = [];
foreach (preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $entry) {
$r = geo_cidr_to_range($entry);
if ($r !== null) {
$out[] = $r;
} else {
error_log('[geo] GEO_ALLOW_IPS: entrada inválida ignorada: ' . $entry);
}
}
return $out;
}
/**
* Está na lista escrita à mão?
*
* Percorre tudo em vez de procurar por bisseção, ao contrário das listas de
* país: isto tem meia dúzia de linhas escritas por uma pessoa, e ordenar um
* array de cinco elementos para poupar três comparações seria código a mais
* para quem vier a seguir.
*/
function geo_ip_allowlisted(?string $ip): bool
{
if ($ip === null || $ip === '') {
return false;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
foreach (geo_allow_ips() as [$start, $end]) {
if (strlen($start) === strlen($packed)
&& strcmp($packed, $start) >= 0 && strcmp($packed, $end) <= 0) {
return true;
}
}
return false;
}
/** O Relay é para ter em conta? Desligar faz o portão voltar a vê-lo como estrangeiro. */
function geo_trust_relay(): bool
{
return filter_var(geo_env('GEO_TRUST_RELAY', 'true'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Este endereço é uma saída do Private Relay ao serviço deste país?
*
* As gamas vivem em `relay-<cc>.v4|.v6`, no mesmo formato e lidas pelo mesmo
* código que as listas da ipdeny — daí o `geo_ip_in_country()` com um prefixo
* em vez de um leitor novo. Foram filtradas por país à nascença: a lista que a
* Apple publica traz 42 mil gamas IPv4 e 246 mil IPv6 (855 KB e 6,7 MB), e
* carregar isso a cada pedido para responder a uma pergunta sobre Portugal —
* 611 gamas — não se justifica.
*
* Sem ficheiro devolve false, e o portão comporta-se exactamente como antes de
* isto existir. É de propósito: uma lista por descarregar não pode ser motivo
* para deixar entrar alguém que o operador quis barrar.
*/
function geo_relay_serves(string $ip, string $cc): bool
{
return geo_ip_in_country($ip, 'relay-' . strtolower($cc));
}
/** Há listas do Relay instaladas para estes países? A página usa isto para avisar. */
function geo_relay_available(array $countries): bool
{
foreach ($countries as $cc) {
if (geo_ranges('relay-' . strtolower($cc), false) || geo_ranges('relay-' . strtolower($cc), true)) {
return true;
}
}
return false;
}
/**
* Why this address should be refused, or null when it may pass.
*
* Returns the reason rather than a bool so the caller can log it next to the
* antispam reasons, which read the same way.
*/
function geo_block_reason(?string $ip = null): ?string
{
if (geo_mode() === 'off') {
return null;
}
$allowed = geo_allowed_countries();
if (!$allowed) {
return null; // gate configured but no list: inert
}
$ip ??= platform_client_ip() ?? '';
if (geo_ip_is_internal($ip)) {
return null;
}
// A lista escrita à mão manda em tudo o resto, incluindo no fail-closed
// abaixo: é a única defesa que continua a funcionar quando os dados de país
// faltam, e é para isso que serve — desbloquear alguém agora, sem esperar
// por um download.
if (geo_ip_allowlisted($ip)) {
return null;
}
// Saída do Private Relay ao serviço de um país permitido. Corre antes das
// listas da ipdeny porque para uma gama da Apple a resposta delas é onde a
// Apple registou o endereço, que não diz nada sobre quem está a navegar.
//
// Uma saída que sirva outro país não é tratada à parte: cai no ciclo abaixo
// e é recusada como qualquer endereço estrangeiro. Isto **não** é uma
// whitelist do Relay — abrir todas as gamas dele deixava entrar qualquer
// subscritor do iCloud+ no mundo inteiro, que é precisamente o que o portão
// existe para não fazer.
if (geo_trust_relay()) {
foreach ($allowed as $cc) {
if (geo_relay_serves($ip, $cc)) {
return null;
}
}
}
// No usable data for any allowed country is a deployment fault, not a
// visitor's. Say so in the log and let them through.
$haveData = false;
foreach ($allowed as $cc) {
if (geo_ranges($cc, false) || geo_ranges($cc, true)) {
$haveData = true;
break;
}
}
if (!$haveData) {
error_log('[geo] sem dados para ' . implode(',', $allowed)
. ' em ' . GEO_DATA_DIR . ' -- correr bin/geo-update.sh');
return geo_fail_closed() ? 'geo: dados indisponíveis' : null;
}
foreach ($allowed as $cc) {
if (geo_ip_in_country($ip, $cc)) {
return null;
}
}
return 'origem fora de ' . implode('/', $allowed);
}
/**
* Gate for a public form POST. Returns the reason, or null to carry on.
*
* Only bites under GEO_MODE=forms or site — the caller decides what to do with
* the reason, exactly as it does with antispam_check().
*/
function geo_form_block_reason(?string $ip = null): ?string
{
return geo_mode() === 'off' ? null : geo_block_reason($ip);
}
/**
* Gate for the whole platform. Call once, early, from the bootstrap.
*
* Sends 403 and stops. Under GEO_MODE=forms this does nothing, so it is safe to
* wire in permanently and switch modes from the env alone.
*/
function geo_guard_site(): void
{
if (geo_mode() !== 'site') {
return;
}
$reason = geo_block_reason();
if ($reason === null) {
return;
}
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
// Tell caches this is about who asked, not about the URL.
header('Vary: X-Forwarded-For');
echo '<!doctype html><meta charset="utf-8">'
. '<title>Indisponível na sua região</title>'
. '<div style="font:16px/1.6 system-ui,sans-serif;max-width:34em;margin:18vh auto;padding:0 1.5rem">'
. '<h1 style="font-size:1.3rem">Indisponível na sua região</h1>'
. '<p>Este site serve apenas visitantes em Portugal. Se acha que se trata de '
. 'um engano, contacte-nos por email.</p></div>';
exit;
}
// ─── Atribuição de país, para a analítica ───────────────────────────────────
//
// O bloqueio acima responde a "este endereço está na lista?" e só precisa dos
// países permitidos. A analítica faz a pergunta ao contrário -- "de que país
// veio isto?" -- e para isso é preciso o mundo inteiro.
//
// O índice é gerado por bin/geo-build-index.php e não é versionado (ver o
// cabeçalho desse ficheiro). Sem ele, isto devolve null e quem chama regista o
// país como desconhecido: para uma estatística é um degradar aceitável, ao
// contrário do bloqueio, onde dados em falta seriam uma decisão de segurança.
/**
* Código ISO do país deste endereço, ou null se não se souber.
*
* Busca binária por fseek() sobre registos de largura fixa. São centenas de
* milhares de ranges: carregá-los para memória a cada visita custaria mais do
* que a página que se está a servir.
*/
function geo_ip_country(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
$packed = @inet_pton($ip);
if ($packed === false) {
return null;
}
$len = strlen($packed); // 4 (v4) ou 16 (v6)
$file = GEO_DATA_DIR . '/country-index.' . ($len === 16 ? 'v6' : 'v4');
$rec = $len * 2 + 2; // início + fim + código
static $handles = [];
if (!array_key_exists($file, $handles)) {
$handles[$file] = is_file($file) ? (fopen($file, 'rb') ?: null) : null;
}
$fh = $handles[$file];
if ($fh === null) {
return null;
}
$size = fstat($fh)['size'] ?? 0;
$n = intdiv((int) $size, $rec);
if ($n === 0) {
return null;
}
$lo = 0;
$hi = $n - 1;
while ($lo <= $hi) {
$mid = intdiv($lo + $hi, 2);
if (fseek($fh, $mid * $rec) !== 0) {
return null;
}
$row = fread($fh, $rec);
if ($row === false || strlen($row) !== $rec) {
return null;
}
$start = substr($row, 0, $len);
$end = substr($row, $len, $len);
if (strcmp($packed, $start) < 0) {
$hi = $mid - 1;
} elseif (strcmp($packed, $end) > 0) {
$lo = $mid + 1;
} else {
return substr($row, $len * 2, 2);
}
}
return null;
}
/** Há índice de países instalado? A página de analítica usa isto para avisar. */
function geo_country_index_available(): bool
{
return is_file(GEO_DATA_DIR . '/country-index.v4');
}