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>
416 lines
14 KiB
PHP
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');
|
|
}
|