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>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -0,0 +1,415 @@
|
||||
<?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');
|
||||
}
|
||||
Reference in new issue
Block a user