Files
fitme3070.pt/shared/platform-tools/functions_geo.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

416 lines
14 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
);
}
/**
* 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 '<!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');
}