.v4 and .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 ); } /** * 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; } // 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 '' . 'Indisponível na sua região' . '
' . '

Indisponível na sua região

' . '

Este site serve apenas visitantes em Portugal. Se acha que se trata de ' . 'um engano, contacte-nos por email.

'; 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'); }