getMessage()); } } return 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. A silver não tem base_url(); tem getBaseUrl(). * O APP_BASE_URL é o último recurso e está em todos os .env da frota. */ function hub_base_url(string $path = ''): string { if (function_exists('base_url')) return (string) base_url($path); $base = function_exists('getBaseUrl') ? (string) getBaseUrl() : 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', 'migrations.list', 'migrations.apply_pending']; } // ─── 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; foreach ([__DIR__ . '/functions_backup.php', hub_root() . '/config/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']; // A silver não tem config/functions.php; tem src/config.php. Sem isto a // versão vinha vazia justamente na plataforma que mais precisa de a mostrar. foreach (['/config/functions.php', '/src/config.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() !== ''; }