Files
fitme3070.pt/shared/platform-tools/geo.php
T
andreandClaude Opus 5 4d40adbf64 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>
2026-09-13 23:06:23 +01:00

337 lines
15 KiB
PHP

<?php
// ─── Página de configuração do bloqueio por país ────────────────────────────
//
// O módulo (functions_geo.php) nasceu configurado só por .env, o que obrigava a
// recriar o contentor para mudar seja o que for. Para um interruptor que se
// mexe quando um cliente legítimo fica de fora, isso é demasiado atrito -- e
// não era coerente com o SMTP, que é igualmente infraestrutura e tem página.
//
// A página grava em system_settings, que ganha ao .env (ver geo_env()). O .env
// continua a valer como valor de arranque: um site que nunca abra esta página
// comporta-se exactamente como antes.
//
// Carregada por um stub em views/sysadmin/geo.php, como as outras partilhadas.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('geo.php: sem PDO — o stub tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_geo.php';
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$user = function_exists('current_user') ? current_user() : null;
$uid = isset($user['id']) ? (int) $user['id'] : null;
$probe = trim((string) ($_GET['probe'] ?? ''));
$probeResult = null;
// ─── POST ────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfOk = function_exists('csrf_verify')
? csrf_verify($_POST['csrf_token'] ?? '')
: (function_exists('platform_csrf_ok') && platform_csrf_ok());
if (!$csrfOk) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' => 'Token inválido.'];
header('Location: ' . $selfUrl);
exit;
}
if (($_POST['action'] ?? 'save') === 'refresh') {
// Corre o mesmo script que o cron corre. Não se duplica a lógica de
// validação aqui: é ela que impede um download mau de substituir uma
// lista boa, e duas cópias divergiriam.
$countries = geo_allowed_countries();
if (!$countries) {
$_SESSION['flash'] = ['type' => 'warning', 'msg' => 'Defina primeiro os países permitidos.'];
} else {
$script = __DIR__ . '/bin/geo-update.sh';
$out = [];
$code = 1;
exec(escapeshellcmd($script) . ' ' . implode(' ', array_map('escapeshellarg', $countries)) . ' 2>&1', $out, $code);
$_SESSION['flash'] = $code === 0
? ['type' => 'success', 'msg' => 'Listas actualizadas. ' . implode(' · ', array_slice($out, 0, 6))]
: ['type' => 'danger', 'msg' => 'A actualização falhou (as listas anteriores mantêm-se). ' . implode(' · ', array_slice($out, 0, 6))];
}
header('Location: ' . $selfUrl);
exit;
}
$mode = strtolower(trim((string) ($_POST['geo_mode'] ?? 'off')));
if (!in_array($mode, ['off', 'forms', 'site'], true)) {
$mode = 'off';
}
// Aceita "PT, ES" ou "pt es"; guarda normalizado e sem repetições. Um
// código que não seja ISO-3166 alfa-2 é recusado com aviso, e não engolido:
// um país mal escrito na lista é indistinguível de "ninguém passa".
$rejected = [];
$valid = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['geo_countries'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $cc) {
$up = strtoupper($cc);
if (preg_match('/^[A-Z]{2}$/', $up)) {
$valid[$up] = true;
} else {
$rejected[] = $cc;
}
}
$countries = implode(',', array_keys($valid));
$failClosed = !empty($_POST['geo_fail_closed']) ? 'true' : 'false';
// Guarda-rede: ligar o modo `site` sem dados é tirar a plataforma do ar
// para toda a gente. Com fail-open não chega a acontecer, mas a combinação
// `site` + fail-closed + sem listas é irrecuperável pelo browser.
$haveData = false;
foreach (array_keys($valid) as $cc) {
if (geo_ranges($cc, false) || geo_ranges($cc, true)) {
$haveData = true;
break;
}
}
if ($mode !== 'off' && $failClosed === 'true' && !$haveData) {
$_SESSION['flash'] = ['type' => 'danger', 'msg' =>
'Recusado: com "falhar fechado" e sem listas descarregadas, ninguém '
. 'conseguiria entrar — incluindo o utilizador. Actualize as listas primeiro.'];
header('Location: ' . $selfUrl);
exit;
}
db_update_setting($pdo, 'geo_mode', $mode, $uid);
db_update_setting($pdo, 'geo_allow_countries', $countries, $uid);
db_update_setting($pdo, 'geo_fail_closed', $failClosed, $uid);
$_SESSION['flash'] = $rejected
? ['type' => 'warning', 'msg' => 'Guardado, mas isto não são códigos de país válidos e foi ignorado: ' . implode(', ', $rejected)]
: ['type' => 'success', 'msg' => 'Configuração guardada.'];
header('Location: ' . $selfUrl);
exit;
}
// ─── Estado actual ───────────────────────────────────────────────────────────
$mode = geo_mode();
$countries = geo_allowed_countries();
$failClosed = geo_fail_closed();
// De onde veio cada valor — é o que explica um "guardei e não mudou nada".
$origin = function (string $key) use ($pdo): string {
$db = trim((string) platform_settings_get($pdo, strtolower($key), ''));
if ($db !== '') return 'base de dados';
return (function_exists('env') && (string) env($key, '') !== '') ? '.env' : 'omissão';
};
$dataFiles = [];
foreach ($countries as $cc) {
foreach (['v4', 'v6'] as $fam) {
$f = GEO_DATA_DIR . '/' . strtolower($cc) . '.' . $fam;
$dataFiles[] = [
'cc' => $cc,
'fam' => $fam,
'exists' => is_file($f),
'ranges' => is_file($f) ? count(geo_ranges($cc, $fam === 'v6')) : 0,
'mtime' => is_file($f) ? date('Y-m-d H:i', filemtime($f)) : '—',
];
}
}
if ($probe !== '') {
$probeResult = filter_var($probe, FILTER_VALIDATE_IP)
? ['ip' => $probe, 'reason' => geo_block_reason($probe), 'internal' => geo_ip_is_internal($probe)]
: ['ip' => $probe, 'invalid' => true];
}
$myIp = platform_client_ip() ?? '';
// O stub pode dizer onde fica o hub e a página de submissões bloqueadas; nem
// todos os sites os têm no mesmo caminho (o design usa views/admin/).
$hubUrl = $platform_dashboard_url
?? (function_exists('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$blockedUrl = $platform_blocked_url
?? (function_exists('base_url') ? base_url('views/admin/blocked_submissions.php') : null);
$pageTitle = 'Bloqueio por país';
$hdr = dirname(__DIR__, 2) . '/includes/header.php';
if (is_file($hdr)) { include $hdr; }
$csrfField = function_exists('csrf_field') ? csrf_field()
: (function_exists('platform_csrf_field') ? platform_csrf_field() : '');
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4" style="max-width:920px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-globe-americas me-2"></i>Bloqueio por país</h3>
<p class="text-muted mb-0">Recusa submissões — ou o site inteiro — a quem chega de fora da lista.</p>
</div>
<a href="<?= $h($hubUrl) ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Sistema
</a>
</div>
<?php if (!empty($_SESSION['flash'])): $f = $_SESSION['flash']; unset($_SESSION['flash']); ?>
<div class="alert alert-<?= $h($f['type']) ?> py-2"><?= $h($f['msg']) ?></div>
<?php endif; ?>
<?php if ($mode !== 'off' && !$dataFiles): ?>
<div class="alert alert-warning py-2">
<strong>Filtro ligado sem listas.</strong> Nada é bloqueado — o módulo falha aberto de
propósito. Carregue em "Actualizar listas" abaixo.
</div>
<?php endif; ?>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<form method="POST">
<?= $csrfField ?>
<div class="mb-4">
<label class="form-label fw-semibold">Modo</label>
<?php foreach ([
'off' => ['Desligado', 'O módulo não faz nada.'],
'forms' => ['Só os formulários', 'Registo, contacto e aula experimental. Ler o site continua aberto a toda a gente — motores de busca e pré-visualizações de links incluídos.'],
'site' => ['O site inteiro', 'Responde 403 a quem vier de fora. Também esconde o site de qualquer crawler alojado no estrangeiro, o que raramente é o que se quer.'],
] as $val => [$label, $desc]): ?>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="geo_mode" id="mode-<?= $val ?>"
value="<?= $val ?>" <?= $mode === $val ? 'checked' : '' ?>>
<label class="form-check-label" for="mode-<?= $val ?>">
<strong><?= $h($label) ?></strong>
<span class="d-block text-muted small"><?= $h($desc) ?></span>
</label>
</div>
<?php endforeach; ?>
<div class="form-text">Valor activo vem de: <strong><?= $h($origin('GEO_MODE')) ?></strong>.</div>
</div>
<div class="mb-4">
<label for="geo_countries" class="form-label fw-semibold">Países permitidos</label>
<input type="text" class="form-control" id="geo_countries" name="geo_countries"
value="<?= $h(implode(', ', $countries)) ?>" placeholder="PT">
<div class="form-text">
Códigos ISO de duas letras, separados por vírgula (<code>PT</code>, <code>PT, ES</code>).
Valor activo vem de: <strong><?= $h($origin('GEO_ALLOW_COUNTRIES')) ?></strong>.
</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geo_fail_closed"
name="geo_fail_closed" value="1" <?= $failClosed ? 'checked' : '' ?>>
<label class="form-check-label" for="geo_fail_closed">
<strong>Falhar fechado</strong>
<span class="d-block text-muted small">
Sem esta opção (recomendado), um ficheiro de listas em falta deixa passar toda a
gente e regista o problema no log. Com ela, recusa. Ligá-la transforma um ficheiro
velho numa indisponibilidade total — é por isso que vem desligada.
</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Guardar
</button>
</form>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h5 class="mb-0">Listas de endereços</h5>
<form method="POST" class="m-0">
<?= $csrfField ?>
<input type="hidden" name="action" value="refresh">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Actualizar listas
</button>
</form>
</div>
<?php if (!$dataFiles): ?>
<p class="text-muted mb-0">Sem países definidos.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>País</th><th>Família</th><th class="text-end">Ranges</th><th>Actualizado</th></tr></thead>
<tbody>
<?php foreach ($dataFiles as $d): ?>
<tr>
<td><?= $h($d['cc']) ?></td>
<td><code><?= $h($d['fam']) ?></code></td>
<td class="text-end">
<?php if ($d['exists']): ?><?= (int) $d['ranges'] ?>
<?php else: ?><span class="badge bg-warning text-dark">em falta</span><?php endif; ?>
</td>
<td class="text-muted small"><?= $h($d['mtime']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
Origem: ipdeny.com. O cron refresca-as mensalmente; as alocações movem-se devagar.
Um download curto ou inválido é recusado e a lista anterior mantém-se.
</p>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="mb-3">Testar um endereço</h5>
<form method="GET" class="row g-2 align-items-end">
<div class="col-sm-7">
<label for="probe" class="form-label small text-muted mb-1">IP a testar</label>
<input type="text" class="form-control" id="probe" name="probe"
value="<?= $h($probe) ?>" placeholder="<?= $h($myIp ?: '2.80.0.1') ?>">
</div>
<div class="col-sm-5">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-search me-1"></i>Verificar
</button>
</div>
</form>
<?php if ($probeResult !== null): ?>
<div class="mt-3">
<?php if (!empty($probeResult['invalid'])): ?>
<div class="alert alert-warning py-2 mb-0"><code><?= $h($probeResult['ip']) ?></code> não é um endereço IP válido.</div>
<?php elseif ($probeResult['reason'] === null): ?>
<div class="alert alert-success py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> passa
<?= !empty($probeResult['internal']) ? ' — é um endereço interno, e esses passam sempre (cron e health checks do próprio contentor).' : '.' ?>
</div>
<?php else: ?>
<div class="alert alert-danger py-2 mb-0">
<code><?= $h($probeResult['ip']) ?></code> seria recusado: <?= $h($probeResult['reason']) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($myIp !== ''): ?>
<p class="form-text mt-3 mb-0">
O seu endereço agora é <code><?= $h($myIp) ?></code>
<?php $mine = geo_block_reason($myIp); ?>
— <?= $mine === null ? 'passa' : '<strong class="text-danger">seria recusado: ' . $h($mine) . '</strong>' ?>.
</p>
<?php endif; ?>
</div>
</div>
<p class="text-muted small mt-4 mb-0">
As recusas ficam registadas com o motivo e o IP em
<a href="<?= $h($blockedUrl ?? '#') ?>">Submissões bloqueadas</a>.
</p>
</div>
<?php
$ftr = dirname(__DIR__, 2) . '/includes/footer.php';
if (is_file($ftr)) { include $ftr; }