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>
170 lines
6.1 KiB
PHP
170 lines
6.1 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
// Compila o índice de país por endereço, para a analítica de visitas.
|
|
//
|
|
// php bin/geo-build-index.php
|
|
//
|
|
// O bloqueio por país (functions_geo.php) só precisa das listas dos países
|
|
// permitidos, que são pequenas e ficam versionadas. Isto é outra coisa: para
|
|
// dizer DE ONDE veio uma visita é preciso o mundo inteiro -- 240 países e
|
|
// ~263 mil ranges. Em texto são 4,7 MB e a compilar dá ~2,6 MB.
|
|
//
|
|
// Por isso o índice NÃO vai para o repositório: um binário desse tamanho
|
|
// refrescado todos os meses inchava o histórico sem nada em troca. Gera-se aqui
|
|
// e, se faltar, a analítica regista o país como desconhecido -- que é um
|
|
// degradar aceitável, ao contrário do bloqueio, onde dados em falta seriam uma
|
|
// decisão de segurança.
|
|
//
|
|
// Formato, escolhido para se poder pesquisar sem carregar nada para memória:
|
|
// registos de largura fixa, ordenados, e busca binária por fseek(). São
|
|
// centenas de milhares de ranges — lê-los todos a cada visita custaria mais do
|
|
// que a própria página.
|
|
//
|
|
// .v4 → 10 bytes: início(4, big-endian) + fim(4) + código do país(2)
|
|
// .v6 → 34 bytes: início(16) + fim(16) + código do país(2)
|
|
//
|
|
// Os bytes vêm de inet_pton(), por isso a ordem lexicográfica é a ordem
|
|
// numérica e o strcmp() serve de comparador nas duas famílias.
|
|
|
|
const V4_URL = 'https://www.ipdeny.com/ipblocks/data/countries/all-zones.tar.gz';
|
|
const V6_URL = 'https://www.ipdeny.com/ipv6/ipaddresses/blocks/ipv6-all-zones.tar.gz';
|
|
|
|
$dataDir = dirname(__DIR__) . '/data/geo';
|
|
@mkdir($dataDir, 0775, true);
|
|
|
|
require_once dirname(__DIR__) . '/functions_geo.php'; // geo_cidr_to_range()
|
|
|
|
$tmp = sys_get_temp_dir() . '/geo-index-' . getmypid();
|
|
@mkdir($tmp, 0700, true);
|
|
register_shutdown_function(static function () use ($tmp) {
|
|
foreach (glob($tmp . '/*') ?: [] as $f) { @unlink($f); }
|
|
@rmdir($tmp);
|
|
});
|
|
|
|
$status = 0;
|
|
|
|
foreach ([['v4', V4_URL, 4], ['v6', V6_URL, 16]] as [$fam, $url, $addrLen]) {
|
|
fwrite(STDERR, "$fam: a descarregar...\n");
|
|
|
|
$tar = "$tmp/$fam.tar.gz";
|
|
if (!download($url, $tar)) {
|
|
fwrite(STDERR, " falhou o download -- o índice anterior mantém-se\n");
|
|
$status = 1;
|
|
continue;
|
|
}
|
|
|
|
$work = "$tmp/$fam";
|
|
@mkdir($work, 0700, true);
|
|
exec('tar xzf ' . escapeshellarg($tar) . ' -C ' . escapeshellarg($work) . ' 2>&1', $out, $code);
|
|
if ($code !== 0) {
|
|
fwrite(STDERR, " o arquivo não abriu -- ignorado\n");
|
|
$status = 1;
|
|
continue;
|
|
}
|
|
|
|
// Nem toda a zona do ipdeny é um país. O zz.zone é um apanha-tudo de blocos
|
|
// /8 inteiros (1.0.0.0/8, 8.0.0.0/8…) que se sobrepõem aos países reais, e
|
|
// o eu.zone é um agregado continental. Deixá-los entrar fazia o 8.8.8.8 sair
|
|
// como "ZZ" em vez de US, porque a busca binária cai no range que apanhar --
|
|
// e com sobreposições não há garantia nenhuma de ser o mais específico.
|
|
// Fora estes dois, sobram 2 sobreposições em 262 mil.
|
|
$notCountries = ['ZZ', 'EU', 'AP'];
|
|
|
|
$ranges = [];
|
|
foreach (glob("$work/*.zone") ?: [] as $file) {
|
|
// ipdeny nomeia cada ficheiro pelo código ISO: pt.zone, br.zone…
|
|
$cc = strtoupper(basename($file, '.zone'));
|
|
if (!preg_match('/^[A-Z]{2}$/', $cc) || in_array($cc, $notCountries, true)) {
|
|
continue;
|
|
}
|
|
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);
|
|
// Um ficheiro v4 não pode contaminar o índice v6 nem o contrário:
|
|
// os registos são de largura fixa e um intruso desalinhava tudo a
|
|
// partir dali, o que a busca binária não tem como detectar.
|
|
if ($r === null || strlen($r[0]) !== $addrLen) {
|
|
continue;
|
|
}
|
|
$ranges[] = [$r[0], $r[1], $cc];
|
|
}
|
|
}
|
|
|
|
if (count($ranges) < 1000) {
|
|
fwrite(STDERR, ' só ' . count($ranges) . " ranges — recusado, fica o índice anterior\n");
|
|
$status = 1;
|
|
continue;
|
|
}
|
|
|
|
usort($ranges, static fn($a, $b) => strcmp($a[0], $b[0]));
|
|
|
|
// As poucas sobreposições que restam são descartadas, ficando a primeira.
|
|
// A busca binária pressupõe ranges disjuntos: com sobreposições devolveria
|
|
// um resultado que depende de onde a bissecção calhou aterrar, o que é pior
|
|
// do que não responder -- daria países diferentes para o mesmo endereço
|
|
// consoante o tamanho do índice.
|
|
$clean = [];
|
|
$lastEnd = null;
|
|
$dropped = 0;
|
|
foreach ($ranges as $r) {
|
|
if ($lastEnd !== null && strcmp($r[0], $lastEnd) <= 0) {
|
|
$dropped++;
|
|
continue;
|
|
}
|
|
$clean[] = $r;
|
|
$lastEnd = $r[1];
|
|
}
|
|
$ranges = $clean;
|
|
if ($dropped > 0) {
|
|
fwrite(STDERR, " $dropped ranges sobrepostos descartados\n");
|
|
}
|
|
|
|
// Escrita para ficheiro temporário e rename: o rename é atómico, por isso
|
|
// um processo a ler nunca apanha o índice a meio de ser escrito.
|
|
$outFile = "$dataDir/country-index.$fam";
|
|
$tmpOut = $outFile . '.tmp';
|
|
$fh = fopen($tmpOut, 'wb');
|
|
if ($fh === false) {
|
|
fwrite(STDERR, " não consegui escrever em $tmpOut\n");
|
|
$status = 1;
|
|
continue;
|
|
}
|
|
foreach ($ranges as [$start, $end, $cc]) {
|
|
fwrite($fh, $start . $end . $cc);
|
|
}
|
|
fclose($fh);
|
|
@chmod($tmpOut, 0664);
|
|
rename($tmpOut, $outFile);
|
|
|
|
printf(
|
|
" %s: %d ranges, %.1f MB\n",
|
|
$fam,
|
|
count($ranges),
|
|
filesize($outFile) / 1048576
|
|
);
|
|
}
|
|
|
|
exit($status);
|
|
|
|
function download(string $url, string $dest): bool
|
|
{
|
|
$fh = fopen($dest, 'wb');
|
|
if ($fh === false) {
|
|
return false;
|
|
}
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_FILE => $fh,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 180,
|
|
CURLOPT_FAILONERROR => true,
|
|
]);
|
|
$ok = curl_exec($ch) !== false;
|
|
curl_close($ch);
|
|
fclose($fh);
|
|
return $ok && filesize($dest) > 1024;
|
|
}
|