Files
fitme3070.pt/shared/platform-tools/hub_integration.php
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- 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>
2026-09-25 07:28:13 +01:00

372 lines
21 KiB
PHP

<?php
// ─── Integração com o hub (página de sysadmin partilhada) ────────────────────
//
// Gerar e rodar as chaves que o hub usa para ler o estado desta plataforma e
// para disparar cópias e atualizações, e fazer o anúncio que arranca o
// emparelhamento.
//
// Sysadmin: são credenciais de operações, não definições de conteúdo.
//
// Carregada por um stub em views/sysadmin/hub_integration.php (views/admin/ no
// design), como as outras páginas partilhadas — é o stub que abre a ligação,
// exige o sysadmin e diz onde estão o header e o footer do site.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('hub_integration.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_hub.php';
require_once __DIR__ . '/functions_csrf.php';
$repo_root = $repo_root ?? dirname(__DIR__, 2);
// O próprio caminho, e não um views/sysadmin/... cravado: o design põe as
// páginas partilhadas em views/admin/, e um caminho fixo mandava os links desta
// página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$me = function_exists('current_user') ? (current_user() ?: []) : [];
$uid = (int) ($me['id'] ?? 0) ?: null;
// As chaves em claro só existem nesta resposta, logo a seguir a serem geradas.
$fresh = null;
$freshKind = null;
$storageReady = hub_storage_ready($pdo);
// O que acabámos de gravar nesta mesma resposta. O settings_cache_load() memoiza
// num `static`, portanto um valor escrito neste request é invisível ao
// setting() a seguir — a página mostraria o valor antigo e pareceria não ter
// gravado. (Mesma armadilha do settings_claim_daily() na fotografia.)
$hubUrlJustSaved = null;
$justAnnounced = false;
$notice = null; // nem todos os sites da frota têm flash helpers
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$storageReady) {
$notice = ['Falta a tabela system_settings nesta instalação — aplica as migrações primeiro.', 'danger'];
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
// $csrfField below picks csrf_field() (config/ stack: field `_csrf`,
// session key `csrf_token`) whenever it exists, falling back to this
// file's own platform_csrf_field() (field `_pt_csrf`, session key
// `platform_tools_csrf`) otherwise. Validation must check the SAME pair
// it rendered. It used to check for a `csrf_verify()` that no host's
// config/functions_csrf.php actually defines, so that branch never ran —
// every POST fell through to platform_csrf_ok(), which reads `_pt_csrf`
// and never matched the `_csrf` field the page actually renders on any
// config/-stack site. Result: every button here 403'd with "Pedido
// rejeitado" (found on beatriz.andmore.pt, 2026-09-13; likely affects
// every other config/-stack site's hub_integration.php too).
$csrfOk = function_exists('csrf_field')
? (($_POST['_csrf'] ?? '') !== '' && ($_SESSION['csrf_token'] ?? '') !== ''
&& hash_equals((string) $_SESSION['csrf_token'], (string) $_POST['_csrf']))
: platform_csrf_ok();
if (!$csrfOk) {
http_response_code(403);
exit('Pedido rejeitado (token CSRF ausente ou inválido). Recarrega a página.');
}
$act = (string) ($_POST['action'] ?? '');
$isOps = str_ends_with($act, '_ops');
$fromEnvNow = $isOps ? hub_ops_key_is_from_env() : hub_api_key_is_from_env();
if ($fromEnvNow && $act !== 'set_hub_url' && $act !== 'announce') {
$notice = ['Essa chave está fixada no .env desta instalação — mexer aqui não teria efeito.', 'warning'];
} elseif (str_starts_with($act, 'rotate')) {
$fresh = $isOps ? hub_ops_key_rotate($pdo, $uid) : hub_api_key_rotate($pdo, $uid);
$freshKind = $isOps ? 'operações' : 'leitura';
$notice = ['Chave gerada. Copia-a agora — não volta a ser mostrada.', 'success'];
} elseif ($act === 'set_hub_url') {
// Sem shell não há como mexer no .env; daí o URL poder ser definido
// aqui. O .env continua a ganhar quando existe.
$url = rtrim(trim((string) ($_POST['hub_url'] ?? '')), '/');
if ($url !== '' && !str_starts_with($url, 'https://')) {
$notice = ['O URL do hub tem de ser https — uma credencial vai atravessar este caminho.', 'danger'];
} else {
hub_setting_set($pdo, 'hub_url', $url, $uid);
$hubUrlJustSaved = $url;
$notice = [$url === '' ? 'URL do hub removido.' : 'URL do hub guardado.', 'success'];
}
} elseif ($act === 'announce') {
$r = hub_announce($pdo, $uid);
$justAnnounced = !empty($r['ok']);
$notice = [$r['ok']
? 'Plataforma anunciada a ' . $r['hub'] . '. Aprova-a na Frota do hub para concluir.'
: 'Não foi possível anunciar: ' . $r['error'], $r['ok'] ? 'success' : 'danger'];
} elseif (str_starts_with($act, 'clear')) {
if ($isOps) {
hub_ops_key_clear($pdo, $uid);
$notice = ['Chave de operações removida — o hub deixa de poder disparar cópias ou atualizações.', 'warning'];
} else {
hub_api_key_clear($pdo, $uid);
$notice = ['Chave de leitura removida — o hub deixa de conseguir ver esta plataforma.', 'warning'];
}
}
// As chaves nunca entram no registo — só que foram mexidas, por quem e quando.
if ($act !== '' && isset($notice) && ($notice[1] ?? '') !== 'danger' && function_exists('platform_audit')) {
platform_audit($pdo instanceof PDO ? $pdo : null, 'hub.' . $act, ['target_type' => 'hub', 'summary' => (string) $notice[0]]);
}
}
$key = hub_api_key();
$fromEnv = hub_api_key_is_from_env();
$opsKey = hub_ops_key();
$opsFromEnv = hub_ops_key_is_from_env();
$backups = hub_backup_summary();
$hubUrl = $hubUrlJustSaved ?? hub_url();
$pairing = hub_pairing_state();
if ($justAnnounced) {
// Idem: o estado do emparelhamento acabou de mudar neste request.
$pairing['state'] = 'announced';
$pairing['hub'] = $hubUrl;
$pairing['expired'] = false;
}
$backupsUrl = function_exists('platform_admin_url') ? platform_admin_url('backups.php') : null;
$page_title = 'Integração com o hub';
$pageTitle = $page_title; // os headers da frota não usam todos o mesmo nome
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
if (is_file($layout_header)) include $layout_header;
$csrfField = function_exists('csrf_field') ? csrf_field() : platform_csrf_field();
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Integração com o hub</h1>
<?php if ($backupsUrl): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= $h($backupsUrl) ?>"><i class="bi bi-archive me-1"></i>Cópias de segurança</a>
<?php endif; ?>
</div>
<?php if ($notice): ?>
<div class="alert alert-<?= $h($notice[1]) ?> alert-dismissible fade show">
<?= $h($notice[0]) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if (!$storageReady): ?>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Esta instalação não tem a tabela <code>system_settings</code>.</strong>
É onde as chaves e o URL do hub são guardados — sem ela não há emparelhamento nem
geração de chaves. Aplica as migrações pendentes em <em>Sistema → Atualizar plataforma</em>
e volta aqui.
</div>
<?php endif; ?>
<div class="row g-4">
<div class="col-lg-7">
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">Chave de API</h6>
<?php if ($fresh !== null): ?>
<div class="alert alert-success">
<div class="fw-semibold mb-1"><i class="bi bi-key me-1"></i>Chave de <?= $h($freshKind) ?> — copia-a agora</div>
<div class="input-group input-group-sm">
<input class="form-control font-monospace" id="freshKey" readonly value="<?= $h($fresh) ?>">
<button class="btn btn-outline-secondary" type="button" id="copyKey"><i class="bi bi-clipboard"></i></button>
</div>
<div class="small mt-2 mb-0">
Fica guardada cifrada. Esta é a única vez que aparece em claro — se a perderes, gera outra.
</div>
</div>
<?php endif; ?>
<?php
// O mesmo bloco serve as duas chaves: só mudam os rótulos e o
// name da ação. Duas cópias divergiriam à primeira alteração.
$_render_key = function (string $kind, string $key, bool $fromEnv, string $envName, string $help) use ($csrfField, $h) {
$suffix = $kind === 'ops' ? '_ops' : '';
?>
<?php if ($fromEnv): ?>
<p class="mb-2"><span class="badge text-bg-secondary">Fixada no .env</span></p>
<p class="small text-muted">
Esta instalação define <code><?= $h($envName) ?></code> no <code>.env</code>, e o
<code>.env</code> ganha. É a configuração mais segura — um valor no <code>.env</code> nunca entra
numa cópia da base de dados. Para gerir por aqui, remove-a de lá.
</p>
<?php elseif ($key !== ''): ?>
<p class="mb-2"><span class="badge text-bg-success">Configurada</span>
<span class="text-muted small">guardada cifrada na base de dados</span></p>
<?php else: ?>
<p class="mb-2"><span class="badge text-bg-warning text-dark">Por configurar</span></p>
<p class="small text-muted"><?= $h($help) ?></p>
<?php endif; ?>
<?php if (!$fromEnv): ?>
<div class="d-flex gap-2 flex-wrap">
<form method="POST" onsubmit="return confirm('<?= $key !== '' ? 'Gerar uma chave nova invalida a atual — terás de a colar no hub. Continuar?' : 'Gerar chave?' ?>');"><?= $csrfField ?>
<input type="hidden" name="action" value="rotate<?= $suffix ?>">
<button class="btn btn-sm btn-primary"><i class="bi bi-arrow-repeat me-1"></i><?= $key !== '' ? 'Gerar nova' : 'Gerar chave' ?></button>
</form>
<?php if ($key !== ''): ?>
<form method="POST" onsubmit="return confirm('Remover esta chave?');"><?= $csrfField ?>
<input type="hidden" name="action" value="clear<?= $suffix ?>">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-x-circle me-1"></i>Remover</button>
</form>
<?php endif; ?>
</div>
<?php endif;
};
?>
<p class="small text-muted">
Duas chaves de propósito: a de <strong>leitura</strong> é sondada de poucos em poucos minutos e só
devolve estado; a de <strong>operações</strong> dispara cópias e atualizações e usa-se à mão.
Assim, quem sonda não consegue disparar nada.
</p>
<div class="mb-3">
<div class="fw-semibold small text-uppercase text-muted mb-1">Leitura (estado)</div>
<?php $_render_key('read', $key, $fromEnv, 'HUB_HEALTH_KEY', 'Sem esta chave o api/health.php responde 503 e o hub não vê nada.'); ?>
</div>
<hr>
<div>
<div class="fw-semibold small text-uppercase text-muted mb-1">Operações (cópias e atualizações)</div>
<?php $_render_key('ops', $opsKey, $opsFromEnv, 'HUB_OPS_KEY', 'Sem esta chave as operações remotas ficam desligadas — o estado continua a ser lido na mesma.'); ?>
</div>
</div></div>
<?php // ── Ligação rápida ────────────────────────────────────────
// O emparelhamento arranca aqui mas fecha no hub: esta
// plataforma anuncia-se (sem chaves) e fica à espera de
// aprovação. Ver a nota em functions_hub.php. ?>
<div class="card mb-3 border-primary"><div class="card-body">
<h6 class="text-muted small text-uppercase">Ligação rápida ao hub</h6>
<?php $hubFromEnv = trim(hub_env('HUB_URL', '')) !== ''; ?>
<form method="POST" class="mb-3"><?= $csrfField ?>
<input type="hidden" name="action" value="set_hub_url">
<label class="form-label small text-muted mb-1" for="hub-url">URL do hub</label>
<div class="input-group input-group-sm">
<input class="form-control" id="hub-url" name="hub_url" type="url"
placeholder="https://hub.andmore.pt"
<?= $hubFromEnv ? 'disabled' : '' ?>
value="<?= $h($hubUrl) ?>">
<button class="btn btn-outline-secondary" <?= $hubFromEnv ? 'disabled' : '' ?>>Guardar</button>
</div>
<div class="form-text">
<?= $hubFromEnv
? 'Fixado em <code>HUB_URL</code> no .env desta instalação.'
: 'Em desenvolvimento aponta para o hub de desenvolvimento; em produção, para o de produção.' ?>
</div>
</form>
<?php if ($hubUrl === ''): ?>
<p class="small text-muted mb-0">Define o URL acima para poderes anunciar esta plataforma.</p>
<?php else: ?>
<?php if ($pairing['state'] === 'announced' && !$pairing['expired']): ?>
<div class="alert alert-info py-2 small mb-2">
<i class="bi bi-hourglass-split me-1"></i>
Anunciada a <code><?= $h($pairing['hub']) ?></code> — falta aprovar na
<strong>Frota</strong> do hub. As chaves só são geradas nesse momento.
</div>
<?php elseif ($pairing['state'] === 'paired'): ?>
<div class="alert alert-success py-2 small mb-2">
<i class="bi bi-check-circle me-1"></i>Emparelhada com <code><?= $h($pairing['hub']) ?></code>.
</div>
<?php elseif ($pairing['state'] === 'announced' && $pairing['expired']): ?>
<div class="alert alert-warning py-2 small mb-2">
O anúncio anterior expirou sem ser aprovado. Anuncia outra vez.
</div>
<?php endif; ?>
<form method="POST"><?= $csrfField ?>
<input type="hidden" name="action" value="announce">
<button class="btn btn-sm btn-primary">
<i class="bi bi-broadcast me-1"></i><?= $pairing['state'] ? 'Anunciar outra vez' : 'Anunciar ao hub' ?>
</button>
</form>
<p class="small text-muted mt-2 mb-0">
O anúncio leva só o URL e um código de emparelhamento — <strong>nenhuma chave sai daqui</strong>.
Voltar a anunciar gera um código novo e invalida o anterior.
</p>
<?php endif; ?>
</div></div>
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que dar ao hub</h6>
<p class="small text-muted mb-2">No hub, em Frota → esta app, cola o URL base e a chave.</p>
<dl class="row small mb-0">
<dt class="col-sm-4">Estado (leitura)</dt>
<dd class="col-sm-8"><code>GET <?= $h(hub_base_url('api/health.php')) ?></code></dd>
<dt class="col-sm-4">Operações</dt>
<dd class="col-sm-8"><code>POST <?= $h(hub_base_url('api/hub_ops.php')) ?></code></dd>
<dt class="col-sm-4">Cabeçalho</dt>
<dd class="col-sm-8"><code>X-Hub-Key: &lt;chave&gt;</code> — a de leitura no estado, a de operações no resto</dd>
</dl>
<p class="small text-muted mt-2 mb-0">
O <code>Authorization: Bearer</code> também é aceite, mas o proxy à frente dos contentores
remove esse cabeçalho — daí o <code>X-Hub-Key</code> ser o caminho principal.
</p>
</div></div>
</div>
<div class="col-lg-5">
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai ver</h6>
<ul class="list-unstyled small mb-0">
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-activity me-1"></i>Base de dados, SMTP, fila de envios, migrações por aplicar, espaço em disco</li>
<li class="py-1 border-bottom border-opacity-10"><i class="bi bi-archive me-1"></i>Última cópia de ficheiros e da base de dados, com a idade</li>
<li class="py-1"><i class="bi bi-git me-1"></i>Ramo e último commit, quando esta instalação é um repositório git</li>
</ul>
</div></div>
<div class="card mb-3"><div class="card-body">
<h6 class="text-muted small text-uppercase">O que o hub vai poder disparar</h6>
<ul class="list-unstyled small mb-2">
<li class="py-1"><code>backup.trigger</code> — cópia de ficheiros ou da base de dados</li>
<li class="py-1"><code>git.pull</code> + <code>migrations.apply_pending</code> — atualização da plataforma</li>
</ul>
<p class="small text-muted mb-0">
Num alojamento cPanel o <code>git.pull</code> costuma não estar disponível. Isso está previsto:
a resposta explica-o e <strong>as cópias continuam a funcionar</strong>, que é a parte que
protege o cliente.
</p>
</div></div>
<div class="card"><div class="card-body">
<h6 class="text-muted small text-uppercase">Cópias agora</h6>
<?php if (empty($backups['available'])): ?>
<p class="small text-muted mb-0">Cópias não disponíveis nesta instalação.</p>
<?php else: ?>
<dl class="row small mb-0">
<dt class="col-5">Base de dados</dt>
<dd class="col-7"><?= $backups['db']
? $h($backups['db']['size_human']) . ' · há ' . $h((string) $backups['db']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Ficheiros</dt>
<dd class="col-7"><?= $backups['files']
? $h($backups['files']['size_human']) . ' · há ' . $h((string) $backups['files']['age_hours']) . ' h'
: '<span class="text-warning">nenhuma</span>' ?></dd>
<dt class="col-5">Total</dt>
<dd class="col-7"><?= (int) $backups['count'] ?> ficheiro(s)</dd>
</dl>
<?php endif; ?>
</div></div>
</div>
</div>
</div>
<script>
document.getElementById('copyKey')?.addEventListener('click', function () {
var i = document.getElementById('freshKey');
i.select();
navigator.clipboard.writeText(i.value).then(function () {
if (window.AdminUX) AdminUX.toast('Chave copiada.');
}).catch(function () {
if (window.AdminUX) AdminUX.toast('Copia com Ctrl+C.', 'warning');
});
});
</script>
<?php
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
if (is_file($layout_footer)) include $layout_footer;