- 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>
545 lines
19 KiB
PHP
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');
|
|
}
|