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:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

@@ -0,0 +1,169 @@
#!/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;
}