Files
fitme3070.pt/shared/platform-tools/functions_hub.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

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() !== '';
}