- 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>
664 lines
32 KiB
PHP
664 lines
32 KiB
PHP
<?php
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Integração com o hub — chave de API, verificação, emparelhamento e tickets
|
|
//
|
|
// Nasceu dentro da quack (`config/functions_hub.php`) e vive agora aqui, porque
|
|
// a partir do momento em que a segunda plataforma se liga ao hub o cliente
|
|
// deixa de ser código da quack e passa a ser infraestrutura da frota. Sete
|
|
// cópias divergiriam à primeira correcção — e uma correcção aqui é
|
|
// tipicamente de segurança.
|
|
//
|
|
// O hub precisa de três coisas de cada plataforma: saber se está de pé, ver as
|
|
// últimas cópias de segurança (ficheiros e base de dados), e poder disparar
|
|
// cópias e atualizações. Tudo isso entra por dois endpoints:
|
|
//
|
|
// api/health.php GET — leitura, é o que o monitor do hub sonda
|
|
// api/hub_ops.php POST — ações (cópia, atualização), o que muda alguma coisa
|
|
//
|
|
// e um terceiro só para o emparelhamento:
|
|
//
|
|
// api/hub_pair.php POST — o hub chama aqui ao aprovar; é a única etapa em que
|
|
// as chaves saem desta plataforma
|
|
//
|
|
// **As chaves são geradas AQUI, não no hub.** Quem manda no acesso é quem o
|
|
// concede: a plataforma emite a chave, o hub recebe-a. O contrário obrigava a
|
|
// confiar que o hub nunca a perde, e não há forma de a revogar deste lado.
|
|
//
|
|
// Guardadas cifradas no `system_settings` (AES-256-GCM), porque a plataforma
|
|
// guarda cópias da base de dados e uma chave em texto simples ficaria dentro de
|
|
// todas elas. O `.env` continua a **ganhar** (HUB_HEALTH_KEY / HUB_OPS_KEY): é a
|
|
// configuração mais segura, porque um valor no .env nunca chega a um dump — mas
|
|
// nem toda a instalação tem shell para lá chegar (cPanel), e é por isso que a UI
|
|
// existe.
|
|
//
|
|
// ─── Portabilidade entre stacks ──────────────────────────────────────────────
|
|
//
|
|
// Um site ligado por platform_connect() declara o PDO e o URL; os outros ainda
|
|
// são lidos do stack `config/` (env(), setting(), base_url(), APP_NAME). Nada
|
|
// aqui chama esses helpers directamente — passa tudo pelos acessores
|
|
// hub_env()/hub_setting()/hub_base_url() abaixo, que
|
|
// resolvem em **tempo de chamada** (não de carregamento: este ficheiro é
|
|
// incluído do topo do functions.php, quando metade deles ainda não existe) e
|
|
// caem para os primitivos do platform-tools quando o site não os tem.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
require_once __DIR__ . '/functions_settings.php';
|
|
|
|
const HUB_KEY_SETTING = 'hub_api_key';
|
|
const HUB_KEY_ENV = 'HUB_HEALTH_KEY';
|
|
|
|
// Chave SEPARADA para as ações (cópias, atualização). O contrato do agente de
|
|
// saúde do hub diz, e com razão, que «um agente que sabe fazer coisas é uma
|
|
// porta de entrada com uma chave só a proteger». A leitura corre de 5 em 5
|
|
// minutos e a chave anda por logs, proxies e pela base de dados do hub; a de
|
|
// operações é usada à mão, poucas vezes. Com duas, quem sonda não dispara nada.
|
|
const HUB_OPS_KEY_SETTING = 'hub_ops_key';
|
|
const HUB_OPS_KEY_ENV = 'HUB_OPS_KEY';
|
|
|
|
const HUB_PAIRING_TTL = 86400; // 24 h — dá tempo de aprovar sem deixar aberto
|
|
|
|
// ─── Acessores portáteis ─────────────────────────────────────────────────────
|
|
|
|
/** A raiz do repositório do site — este ficheiro está em shared/platform-tools/. */
|
|
function hub_root(): string {
|
|
return dirname(__DIR__, 2);
|
|
}
|
|
|
|
/**
|
|
* O $pdo do site: o que declarou em platform_connect(), ou o global que o
|
|
* config/db.php abre. Não escreve em $GLOBALS: o site é que manda no seu escopo.
|
|
*/
|
|
function hub_pdo(): ?PDO {
|
|
// Um site ligado por platform_connect() já disse qual é — não se adivinha.
|
|
if (function_exists('platform_pdo') && ($declared = platform_pdo()) !== null) return $declared;
|
|
|
|
$pdo = $GLOBALS['pdo'] ?? null;
|
|
return $pdo instanceof PDO ? $pdo : null;
|
|
}
|
|
|
|
function hub_env(string $key, string $default = ''): string {
|
|
if (function_exists('env')) return (string) env($key, $default);
|
|
$v = $_ENV[$key] ?? (getenv($key) !== false ? getenv($key) : null);
|
|
return $v === null ? $default : (string) $v;
|
|
}
|
|
|
|
/** Leitura simples de uma definição: DB primeiro, como em todo o resto. */
|
|
function hub_setting(string $key, string $default = ''): string {
|
|
if (function_exists('setting')) return (string) setting($key, $default);
|
|
$pdo = hub_pdo();
|
|
return $pdo ? (string) platform_settings_get($pdo, $key, $default) : $default;
|
|
}
|
|
|
|
/**
|
|
* Definição com fallback ao .env — mas ao contrário do que o nome sugere, quem
|
|
* ganha é o .env. Ver a nota do topo: um valor no .env nunca chega a um dump.
|
|
*/
|
|
function hub_setting_or_env(string $key, string $envKey, string $default = ''): string {
|
|
if (function_exists('setting_or_env')) return (string) setting_or_env($key, $envKey, $default);
|
|
$env = trim(hub_env($envKey, ''));
|
|
if ($env !== '') return $env;
|
|
$val = trim(hub_setting($key, ''));
|
|
return $val !== '' ? $val : $default;
|
|
}
|
|
|
|
/** O mesmo, mas o valor guardado está cifrado. */
|
|
function hub_setting_secret(string $key, string $envKey): string {
|
|
if (function_exists('setting_secret')) return (string) setting_secret($key, $envKey, '');
|
|
$env = trim(hub_env($envKey, ''));
|
|
if ($env !== '') return $env;
|
|
return hub_secret_decrypt(hub_setting($key, ''));
|
|
}
|
|
|
|
/**
|
|
* Escrita. Usa o db_update_setting() do site quando existe — é ele que limpa a
|
|
* cache de definições desse site, e uma escrita que não a limpa faz a página
|
|
* mostrar o valor antigo logo a seguir a gravar.
|
|
*
|
|
* Guarda-se sempre já cifrado (ver hub_secret_encrypt); o platform_settings_set()
|
|
* reconhece o prefixo `enc:v1:` e não volta a cifrar por cima.
|
|
*/
|
|
function hub_setting_set(PDO $pdo, string $key, string $value, ?int $userId = null): void {
|
|
if (function_exists('db_update_setting')) { db_update_setting($pdo, $key, $value, $userId); return; }
|
|
platform_settings_set($pdo, $key, $value, $userId);
|
|
if (function_exists('settings_cache_clear')) settings_cache_clear();
|
|
}
|
|
|
|
function hub_secret_encrypt(string $plain): string {
|
|
return function_exists('secret_encrypt') ? secret_encrypt($plain) : platform_secret_encrypt($plain);
|
|
}
|
|
|
|
function hub_secret_decrypt(string $stored): string {
|
|
return function_exists('secret_decrypt') ? secret_decrypt($stored) : platform_secret_decrypt($stored);
|
|
}
|
|
|
|
/** O nome da plataforma como aparece no hub. */
|
|
function hub_app_name(): string {
|
|
if (defined('APP_NAME') && (string) APP_NAME !== '') return (string) APP_NAME;
|
|
$n = hub_env('APP_NAME', '');
|
|
if ($n !== '') return $n;
|
|
return parse_url(hub_base_url(''), PHP_URL_HOST) ?: 'plataforma';
|
|
}
|
|
|
|
/**
|
|
* URL absoluto desta plataforma: o declarado em platform_connect(), o
|
|
* base_url() do site, ou o APP_BASE_URL, que está em todos os .env da frota.
|
|
*/
|
|
function hub_base_url(string $path = ''): string {
|
|
$declared = function_exists('platform_config') ? (string) (platform_config()['base_url'] ?? '') : '';
|
|
if ($declared !== '') return rtrim($declared, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
|
|
if (function_exists('base_url')) return (string) base_url($path);
|
|
$base = hub_env('APP_BASE_URL', '');
|
|
return rtrim($base, '/') . ($path === '' ? '' : '/' . ltrim($path, '/'));
|
|
}
|
|
|
|
// ─── Chaves ──────────────────────────────────────────────────────────────────
|
|
|
|
/** A chave em vigor, venha do .env ou da base de dados. '' = não configurada. */
|
|
function hub_api_key(): string {
|
|
return hub_setting_secret(HUB_KEY_SETTING, HUB_KEY_ENV);
|
|
}
|
|
|
|
/** A chave de operações. '' = ações desligadas nesta plataforma. */
|
|
function hub_ops_key(): string {
|
|
return hub_setting_secret(HUB_OPS_KEY_SETTING, HUB_OPS_KEY_ENV);
|
|
}
|
|
|
|
function hub_ops_key_is_from_env(): bool {
|
|
return trim(hub_env(HUB_OPS_KEY_ENV, '')) !== '';
|
|
}
|
|
|
|
function hub_ops_key_rotate(PDO $pdo, ?int $userId = null): string {
|
|
$key = bin2hex(random_bytes(24));
|
|
hub_setting_set($pdo, HUB_OPS_KEY_SETTING, hub_secret_encrypt($key), $userId);
|
|
return $key;
|
|
}
|
|
|
|
function hub_ops_key_clear(PDO $pdo, ?int $userId = null): void {
|
|
hub_setting_set($pdo, HUB_OPS_KEY_SETTING, '', $userId);
|
|
}
|
|
|
|
/** true quando a chave está fixada no .env — editá-la na UI não faria nada. */
|
|
function hub_api_key_is_from_env(): bool {
|
|
return trim(hub_env(HUB_KEY_ENV, '')) !== '';
|
|
}
|
|
|
|
/** Gera uma chave nova e grava-a cifrada. Devolve a chave em claro, uma vez. */
|
|
function hub_api_key_rotate(PDO $pdo, ?int $userId = null): string {
|
|
$key = bin2hex(random_bytes(24)); // 48 hex — largo o suficiente para não valer a pena adivinhar
|
|
hub_setting_set($pdo, HUB_KEY_SETTING, hub_secret_encrypt($key), $userId);
|
|
return $key;
|
|
}
|
|
|
|
function hub_api_key_clear(PDO $pdo, ?int $userId = null): void {
|
|
hub_setting_set($pdo, HUB_KEY_SETTING, '', $userId);
|
|
}
|
|
|
|
// ─── Resposta e autenticação dos endpoints ───────────────────────────────────
|
|
|
|
function hub_json(array $data, int $code = 200): never {
|
|
http_response_code($code);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Autentica o pedido do hub. Aceita `X-Hub-Key` e, em alternativa,
|
|
* `Authorization: Bearer`.
|
|
*
|
|
* Não é redundância: o proxy à frente dos contentores andmore remove o
|
|
* cabeçalho Authorization antes de o PHP o ver, e cabeçalhos `X-` passam. O
|
|
* Bearer fica suportado para instalações onde passe (cPanel) e porque é a forma
|
|
* convencional.
|
|
*/
|
|
function hub_key_verify_or_die(): void {
|
|
_hub_verify_or_die(hub_api_key(), 'chave de API não configurada nesta plataforma');
|
|
}
|
|
|
|
/**
|
|
* Ações. Aceita SÓ a chave de operações — a de leitura, que anda pelo monitor de
|
|
* 5 em 5 minutos, não dispara cópias nem atualizações.
|
|
*/
|
|
function hub_ops_verify_or_die(): void {
|
|
_hub_verify_or_die(hub_ops_key(), 'operações remotas desligadas nesta plataforma (sem chave de operações)');
|
|
}
|
|
|
|
function _hub_verify_or_die(string $expected, string $missingMessage): void {
|
|
$given = (string) ($_SERVER['HTTP_X_HUB_KEY'] ?? '');
|
|
if ($given === '') {
|
|
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
|
|
$given = str_starts_with($header, 'Bearer ') ? substr($header, 7) : '';
|
|
}
|
|
if ($expected === '') {
|
|
hub_json(['ok' => false, 'error' => $missingMessage], 503);
|
|
}
|
|
// hash_equals: uma comparação normal sai ao primeiro byte diferente e deixa
|
|
// medir a chave pelo tempo de resposta.
|
|
if (!hash_equals($expected, $given)) {
|
|
hub_json(['ok' => false, 'error' => 'unauthorized'], 401);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* As ações que o api/hub_ops.php aceita. Anunciadas no health para o hub não
|
|
* ter de manter uma lista por plataforma nem adivinhar o que cada uma sabe
|
|
* fazer — uma instalação sem git responde `git.pull` com um erro explicado, e o
|
|
* hub mostra isso em vez de um botão que não faz nada.
|
|
*/
|
|
function hub_ops_actions(): array {
|
|
return ['backup.list', 'backup.trigger', 'git.status', 'git.pull', 'update.run', 'update.status',
|
|
'migrations.list', 'migrations.apply_pending',
|
|
'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'];
|
|
}
|
|
|
|
// ─── Carregamento tardio dos helpers do site ─────────────────────────────────
|
|
|
|
/**
|
|
* Carrega os helpers de cópias se ainda não estiverem em memória. Nem todas as
|
|
* plataformas os incluem no bootstrap (a fotografia não), e um endpoint que
|
|
* respondesse «sem cópias» só porque o ficheiro não tinha sido carregado seria
|
|
* pior do que não responder nada.
|
|
*/
|
|
function hub_load_backup_helpers(): void {
|
|
if (function_exists('backup_list')) return;
|
|
// O wrapper do site PRIMEIRO: é lá que se define o BACKUP_TRIGGER_URL_PREFIX
|
|
// antes de incluir o partilhado (a fotografia manda a cópia para api/, porque
|
|
// sob o docroot public/ o shared/ não se alcança por HTTP). Carregar o
|
|
// partilhado primeiro saltava essa definição, e a cópia disparada pelo hub
|
|
// batia num 404 que o curl dá como sucesso.
|
|
foreach ([hub_root() . '/config/functions_backup.php',
|
|
__DIR__ . '/functions_backup.php'] as $candidate) {
|
|
if (is_file($candidate)) { require_once $candidate; return; }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* O mesmo para o runner de migrações. O stub da app tem-no comentado de
|
|
* propósito (não vale a pena carregá-lo em cada pedido), portanto sem isto o
|
|
* check respondia «runner indisponível» com ok:true — um check que não sabe
|
|
* medir a dizer que está tudo bem, exactamente enquanto duas migrações estavam
|
|
* por aplicar. Um check que mente é pior do que não existir.
|
|
*/
|
|
function hub_load_migration_helpers(): void {
|
|
if (function_exists('migrations_status')) return;
|
|
foreach ([__DIR__ . '/functions_migrations.php',
|
|
hub_root() . '/config/functions_migrations.php'] as $candidate) {
|
|
if (is_file($candidate)) { require_once $candidate; }
|
|
if (function_exists('migrations_status')) return;
|
|
}
|
|
}
|
|
|
|
// ─── Cópias de segurança ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Últimas cópias de segurança, por tipo. É a pergunta que o hub faz — «esta
|
|
* plataforma tem cópia de ontem?» — e não «lista-me tudo».
|
|
*/
|
|
function hub_backup_summary(): array {
|
|
hub_load_backup_helpers();
|
|
if (!function_exists('backup_list')) {
|
|
return ['available' => false, 'reason' => 'backups não disponíveis nesta plataforma'];
|
|
}
|
|
$out = ['available' => true, 'db' => null, 'files' => null, 'count' => 0, 'total_bytes' => 0];
|
|
foreach (backup_list() as $b) {
|
|
$out['count']++;
|
|
$out['total_bytes'] += (int) ($b['size_bytes'] ?? 0);
|
|
$type = ($b['type'] ?? '') === 'db' ? 'db' : 'files';
|
|
$created = (int) ($b['created_at'] ?? 0);
|
|
// backup_list() vem do mais recente para o mais antigo, mas não se
|
|
// depende disso: compara-se a data e fica a maior.
|
|
if ($out[$type] === null || $created > (int) $out[$type]['created_at']) {
|
|
$out[$type] = [
|
|
'filename' => (string) ($b['filename'] ?? ''),
|
|
'created_at' => $created,
|
|
'created_iso'=> $created ? gmdate('c', $created) : null,
|
|
'size_bytes' => (int) ($b['size_bytes'] ?? 0),
|
|
'size_human' => (string) ($b['size_human'] ?? ''),
|
|
// A idade é o que responde à pergunta real («há cópia recente?»)
|
|
// sem o hub ter de saber o fuso horário desta máquina.
|
|
'age_hours' => $created ? round((time() - $created) / 3600, 1) : null,
|
|
];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// ─── Checks no formato do contrato do hub ────────────────────────────────────
|
|
// docs/CONTRATO_AGENTE_SAUDE.md do hub: `git` e `backup` são entradas DENTRO de
|
|
// `checks`, não chaves de topo — foi por as mandar à parte que o painel os
|
|
// mostrava como «não reportado». Os nomes dos campos com coluna própria no hub
|
|
// (git.branch/commit/behind/dirty, backup.last_at, disk.used_percent) têm de ser
|
|
// exactamente estes, senão ficam só no payload_json e não aparecem.
|
|
|
|
/** `checks.backup` — a pergunta é «há cópia recente?», não «lista tudo». */
|
|
function hub_backup_check(int $staleHours = 48): array {
|
|
$s = hub_backup_summary();
|
|
if (empty($s['available'])) {
|
|
// Ausente ≠ falha: o contrato diz que o que não vier aparece como «não
|
|
// reportado». Uma plataforma sem cópias automáticas não manda o check.
|
|
return [];
|
|
}
|
|
$newest = null;
|
|
foreach (['db', 'files'] as $t) {
|
|
if ($s[$t] && ($newest === null || $s[$t]['created_at'] > $newest['created_at'])) $newest = $s[$t];
|
|
}
|
|
if (!$newest) {
|
|
return ['ok' => false, 'detail' => 'nenhuma cópia encontrada', 'last_at' => null,
|
|
'db' => null, 'files' => null];
|
|
}
|
|
return [
|
|
'ok' => $newest['age_hours'] !== null && $newest['age_hours'] <= $staleHours,
|
|
'last_at' => date('Y-m-d H:i', $newest['created_at']),
|
|
'detail' => 'há ' . $newest['age_hours'] . ' h · ' . $newest['size_human'],
|
|
// Extra: fica no payload_json do hub mesmo sem coluna própria.
|
|
'db' => $s['db'] ? ['last_at' => date('Y-m-d H:i', $s['db']['created_at']), 'age_hours' => $s['db']['age_hours'], 'size' => $s['db']['size_human']] : null,
|
|
'files' => $s['files'] ? ['last_at' => date('Y-m-d H:i', $s['files']['created_at']), 'age_hours' => $s['files']['age_hours'], 'size' => $s['files']['size_human']] : null,
|
|
'count' => $s['count'],
|
|
];
|
|
}
|
|
|
|
/** `checks.git` — leitura offline; `behind` é o último ref conhecido. */
|
|
function hub_git_check(string $root): array {
|
|
if (!is_dir($root . '/.git')) return []; // não reportado (cPanel, cópia sem repo)
|
|
require_once __DIR__ . '/functions_git.php';
|
|
if (!function_exists('git_run')) return [];
|
|
|
|
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $root);
|
|
|
|
// Ter um .git não chega: é preciso um git que corra. O contentor do apex
|
|
// não traz o binário — de propósito, porque é uma montra estática montada
|
|
// read-only e o deploy corre git no HOST, como o deploy, nunca lá dentro.
|
|
//
|
|
// Sem esta saída, o check reportava ok:false com tudo a null e o cartão
|
|
// ficava amarelo para sempre por uma medição que nunca foi possível fazer.
|
|
// Não conseguir medir é «não reportado» -- a mesma resposta que este check
|
|
// já dá a um cPanel sem repositório --, e não uma avaria.
|
|
//
|
|
// `code === -1` é o sinal, e não a mensagem: é o que o git_run() devolve
|
|
// quando não chegou a correr nada -- binário ausente, ou proc_open()
|
|
// desativado no php.ini, que é o caso de muito alojamento partilhado. Um
|
|
// git que corra e falhe devolve o código de saída DELE, e esse é um
|
|
// resultado a reportar.
|
|
if (!$branch['ok'] && (int) $branch['code'] === -1) {
|
|
return [];
|
|
}
|
|
$commit = git_run(['rev-parse', '--short', 'HEAD'], $root);
|
|
$dirty = git_run(['status', '--porcelain'], $root);
|
|
$count = git_run(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], $root);
|
|
|
|
$behind = null;
|
|
if ($count['ok'] && preg_match('/^(\d+)\s+(\d+)/', $count['output'], $m)) $behind = (int) $m[2];
|
|
|
|
return [
|
|
// Estar atrasado do remoto não é uma avaria — é o cartão a ficar amarelo,
|
|
// que é o que o hub faz com behind > 0. Aqui `ok` é só «consegui ler».
|
|
'ok' => $branch['ok'],
|
|
'branch' => $branch['ok'] ? trim($branch['output']) : null,
|
|
'commit' => $commit['ok'] ? trim($commit['output']) : null,
|
|
'behind' => $behind,
|
|
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
|
|
];
|
|
}
|
|
|
|
/** Versão mostrada no hub: o commit curto, ou a data do ficheiro mais recente. */
|
|
function hub_version(string $root, array $gitCheck): string {
|
|
if (!empty($gitCheck['commit'])) return (string) $gitCheck['commit'];
|
|
foreach (['/config/functions.php', '/public/index.php'] as $rel) {
|
|
if (is_file($root . $rel)) return date('Y.m.d', (int) filemtime($root . $rel));
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ─── Emparelhamento automático (quick setup) ─────────────────────────────────
|
|
//
|
|
// Fluxo, e a razão de ser desta ordem:
|
|
// 1. A plataforma ANUNCIA-SE ao hub: URL, slug e um código de emparelhamento.
|
|
// **Nenhuma chave sai nesta etapa.**
|
|
// 2. O hub guarda-a como pendente. Nada é sondado até o André aprovar.
|
|
// 3. Ao aprovar, é o HUB que chama a plataforma (api/hub_pair.php) com o
|
|
// código e o âmbito pedido; só então as chaves são geradas e entregues.
|
|
//
|
|
// A inversão da etapa 3 é o ponto todo: as chaves viajam numa ligação que o hub
|
|
// iniciou, para um endpoint que ele já conhece — e não numa que a plataforma
|
|
// abriu para um URL que lhe disseram. Um URL de hub errado no anúncio revela um
|
|
// código de emparelhamento inútil sozinho, não uma credencial.
|
|
//
|
|
// O código é de uso único e tem validade: um anúncio esquecido não fica a valer
|
|
// como convite aberto para sempre.
|
|
|
|
/**
|
|
* As chaves e o URL do hub vivem no `system_settings`. Nem toda a plataforma da
|
|
* frota tem essa tabela criada (a dsa tem o helper mas a migração 017 estava por
|
|
* aplicar, 2026-08-13), e sem ela qualquer gravação rebenta com um PDOException
|
|
* em cima do utilizador. A página pergunta primeiro e explica-se.
|
|
*/
|
|
function hub_storage_ready(PDO $pdo): bool {
|
|
static $ready = null;
|
|
if ($ready !== null) return $ready;
|
|
try {
|
|
$ready = (bool) $pdo->query("SHOW TABLES LIKE 'system_settings'")->fetch();
|
|
} catch (Throwable $e) {
|
|
$ready = false;
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
function hub_url(): string {
|
|
return rtrim(trim(hub_setting_or_env('hub_url', 'HUB_URL', '')), '/');
|
|
}
|
|
|
|
/** Estado do emparelhamento, para a UI. */
|
|
function hub_pairing_state(): array {
|
|
return [
|
|
'secret' => hub_setting('hub_pairing_secret', ''),
|
|
'state' => hub_setting('hub_pairing_state', ''), // '' | announced | paired
|
|
'hub' => hub_setting('hub_pairing_hub', ''),
|
|
'at' => hub_setting('hub_pairing_at', ''),
|
|
'expired' => (int) hub_setting('hub_pairing_at', '0') > 0
|
|
&& (time() - (int) hub_setting('hub_pairing_at', '0')) > HUB_PAIRING_TTL,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Anuncia esta plataforma ao hub. Devolve ['ok'=>bool,'error'=>string].
|
|
* Não envia chaves — ver a nota acima.
|
|
*/
|
|
function hub_announce(PDO $pdo, ?int $userId = null): array {
|
|
if (!hub_storage_ready($pdo)) {
|
|
return ['ok' => false, 'error' => 'esta plataforma não tem a tabela system_settings — falta aplicar as migrações antes de emparelhar'];
|
|
}
|
|
$hub = hub_url();
|
|
if ($hub === '') return ['ok' => false, 'error' => 'URL do hub não configurado (HUB_URL no .env ou nas definições).'];
|
|
if (!str_starts_with($hub, 'https://')) {
|
|
// Uma credencial vai acabar por atravessar este caminho na etapa 3.
|
|
return ['ok' => false, 'error' => 'O URL do hub tem de ser https.'];
|
|
}
|
|
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível nesta instalação.'];
|
|
|
|
$secret = bin2hex(random_bytes(24));
|
|
$body = json_encode([
|
|
'action' => 'announce',
|
|
'slug' => hub_platform_slug(),
|
|
'name' => hub_app_name(),
|
|
'base_url' => rtrim(hub_base_url(''), '/'),
|
|
'health_path' => 'api/health.php',
|
|
'ops_path' => 'api/hub_ops.php',
|
|
'pair_path' => 'api/hub_pair.php',
|
|
'pairing_code'=> $secret,
|
|
'env' => hub_env('APP_ENV', 'prod'),
|
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
$ch = curl_init($hub . '/api/enroll.php');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
|
|
CURLOPT_USERAGENT => 'andmore-platform-announce/1.0',
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($raw === false || $err !== '') return ['ok' => false, 'error' => 'Sem resposta do hub: ' . ($err ?: 'erro desconhecido')];
|
|
$res = json_decode((string) $raw, true);
|
|
if (!is_array($res)) return ['ok' => false, 'error' => 'Resposta do hub não é JSON (HTTP ' . $http . ').'];
|
|
if (empty($res['ok'])) return ['ok' => false, 'error' => (string) ($res['error'] ?? 'O hub recusou o anúncio.')];
|
|
|
|
// Só se guarda o código DEPOIS de o hub o aceitar — de outra forma ficava cá
|
|
// um segredo válido para um emparelhamento que nunca existiu.
|
|
hub_setting_set($pdo, 'hub_pairing_secret', hub_secret_encrypt($secret), $userId);
|
|
hub_setting_set($pdo, 'hub_pairing_state', 'announced', $userId);
|
|
hub_setting_set($pdo, 'hub_pairing_hub', $hub, $userId);
|
|
hub_setting_set($pdo, 'hub_pairing_at', (string) time(), $userId);
|
|
return ['ok' => true, 'error' => '', 'hub' => $hub, 'status' => (string) ($res['status'] ?? 'pending')];
|
|
}
|
|
|
|
/**
|
|
* Slug estável desta plataforma, derivado do **URL completo** — host E caminho.
|
|
*
|
|
* Só o host não chega: em desenvolvimento a frota inteira vive no mesmo host
|
|
* (`development.andmore.pt/design.andmore.pt`, `.../fotografia.andmore.pt`), e
|
|
* um slug por host fazia a segunda plataforma a anunciar-se passar por cima da
|
|
* primeira no hub. Em produção, onde cada uma tem o seu domínio, o caminho é
|
|
* vazio e o resultado é o mesmo de antes.
|
|
*/
|
|
function hub_platform_slug(): string {
|
|
$parts = parse_url(rtrim(hub_base_url(''), '/')) ?: [];
|
|
$raw = ($parts['host'] ?? 'plataforma') . ($parts['path'] ?? '');
|
|
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $raw));
|
|
return trim($slug, '-') ?: 'plataforma';
|
|
}
|
|
|
|
/**
|
|
* Consome o código de emparelhamento (uso único) e devolve as chaves pedidas.
|
|
* Chamado por api/hub_pair.php, que é quem o hub contacta ao aprovar.
|
|
*/
|
|
function hub_pair_complete(PDO $pdo, string $code, bool $withOps): array {
|
|
$st = hub_pairing_state();
|
|
$stored = $st['secret'] !== '' ? hub_secret_decrypt($st['secret']) : '';
|
|
if ($stored === '' || $st['state'] !== 'announced') {
|
|
return ['ok' => false, 'error' => 'não há emparelhamento pendente'];
|
|
}
|
|
if ($st['expired']) {
|
|
return ['ok' => false, 'error' => 'o código de emparelhamento expirou — anuncia outra vez'];
|
|
}
|
|
if (!hash_equals($stored, $code)) {
|
|
return ['ok' => false, 'error' => 'código de emparelhamento inválido'];
|
|
}
|
|
|
|
$out = ['ok' => true, 'api_key' => hub_api_key_rotate($pdo, null), 'ops_key' => null];
|
|
// A de operações só sai se o hub a pedir: limita o estrago de uma aprovação
|
|
// dada por engano ao que se vê, em vez do que se dispara.
|
|
if ($withOps) $out['ops_key'] = hub_ops_key_rotate($pdo, null);
|
|
|
|
// Uso único: o código morre aqui, tenha corrido bem ou não volte a ser preciso.
|
|
hub_setting_set($pdo, 'hub_pairing_secret', '', null);
|
|
hub_setting_set($pdo, 'hub_pairing_state', 'paired', null);
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* ─── Abrir um ticket no balcão do hub ────────────────────────────────────────
|
|
*
|
|
* O intake do hub (`/api/tickets.php`) existe precisamente para uma plataforma
|
|
* que prefira ter o SEU formulário em vez de despachar o utilizador para o
|
|
* /suporte/ de outro site. Era isso que a quack fazia: um link para fora, que
|
|
* mandava um aluno a meio de uma aula para um domínio que ele não conhece,
|
|
* autenticar-se outra vez, para dizer que um botão não funcionava.
|
|
*
|
|
* A chave é a MESMA que o hub usa para sondar esta plataforma — é ela que
|
|
* identifica a origem, e por isso o ticket já chega ao hub com a app certa e o
|
|
* `site_origin` preenchido, sem o aluno ter de dizer de onde vem.
|
|
*
|
|
* Devolve ['ok'=>bool, 'ref'=>string, 'track_url'=>string, 'error'=>string].
|
|
* Os erros vêm em português e prontos a mostrar: quem chama isto é um
|
|
* formulário, não outro programa.
|
|
*/
|
|
function hub_ticket_create(array $d): array
|
|
{
|
|
$falha = static fn(string $msg): array => ['ok' => false, 'error' => $msg, 'ref' => '', 'track_url' => ''];
|
|
|
|
$hub = hub_url();
|
|
$key = hub_api_key();
|
|
if ($hub === '') return $falha('O balcão de apoio ainda não está ligado a esta plataforma.');
|
|
if ($key === '') return $falha('Esta plataforma ainda não está emparelhada com o balcão de apoio.');
|
|
if (!function_exists('curl_init')) return $falha('cURL indisponível nesta instalação.');
|
|
|
|
$body = json_encode([
|
|
'submitter_email' => (string) ($d['submitter_email'] ?? ''),
|
|
'submitter_name' => (string) ($d['submitter_name'] ?? ''),
|
|
'title' => (string) ($d['title'] ?? ''),
|
|
'description' => (string) ($d['description'] ?? ''),
|
|
'category' => (string) ($d['category'] ?? 'outro'),
|
|
'priority' => (int) ($d['priority'] ?? 2),
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
$ch = curl_init($hub . '/api/tickets.php');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
// X-Hub-Key e não Authorization: o proxy à frente destes contentores
|
|
// come o cabeçalho Authorization antes de chegar ao PHP. O intake aceita
|
|
// os dois e lê este primeiro, por esta mesma razão.
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
|
|
'X-Hub-Key: ' . $key],
|
|
CURLOPT_USERAGENT => 'andmore-platform-ticket/1.0',
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($raw === false || $err !== '') {
|
|
error_log('hub_ticket_create: ' . ($err ?: 'sem resposta'));
|
|
return $falha('Não foi possível falar com o balcão de apoio. Tenta daqui a bocado.');
|
|
}
|
|
$res = json_decode((string) $raw, true);
|
|
if (!is_array($res)) {
|
|
error_log('hub_ticket_create: resposta não-JSON (HTTP ' . $http . '): ' . substr((string) $raw, 0, 300));
|
|
return $falha('O balcão de apoio respondeu de forma inesperada (HTTP ' . $http . ').');
|
|
}
|
|
|
|
if (empty($res['ok'])) {
|
|
// Traduzir os códigos do intake. Mostrar "invalid_email" a um utilizador
|
|
// não lhe diz o que fazer a seguir, e é ele que tem de agir.
|
|
$motivos = [
|
|
'invalid_email' => 'O e-mail da tua conta não é válido — fala com um administrador.',
|
|
'title_or_description_too_short' => 'Escreve um assunto com pelo menos 5 letras e uma descrição com pelo menos 10.',
|
|
'rate_limited' => 'Já foram abertos muitos pedidos há pouco. Tenta daqui a uma hora.',
|
|
'invalid_key' => 'Esta plataforma perdeu a ligação ao balcão de apoio — fala com um administrador.',
|
|
'missing_key' => 'Esta plataforma perdeu a ligação ao balcão de apoio — fala com um administrador.',
|
|
];
|
|
$codigo = (string) ($res['error'] ?? '');
|
|
error_log('hub_ticket_create: o hub recusou (' . $codigo . ')');
|
|
return $falha($motivos[$codigo] ?? 'O balcão de apoio recusou o pedido (' . $codigo . ').');
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'error' => '',
|
|
'ref' => (string) ($res['ref'] ?? ''),
|
|
'track_url' => (string) ($res['track_url'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Há balcão de apoio ligado? (URL do hub + chave emparelhada.)
|
|
*
|
|
* Serve para o painel decidir se mostra o formulário ou um aviso. Sem isto, uma
|
|
* instalação por emparelhar mostrava o formulário na mesma e só dizia que não
|
|
* havia ligação DEPOIS de o utilizador escrever tudo.
|
|
*/
|
|
function hub_ticket_ready(): bool
|
|
{
|
|
return hub_url() !== '' && hub_api_key() !== '';
|
|
}
|