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>
337 lines
15 KiB
PHP
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; }
|