Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// Self-bootstrapping remote-ops endpoint for the id.andmore.pt fleet plane —
|
||||
// config/ stack (fitme3070, fotografia, dsa, moremio, tickets).
|
||||
//
|
||||
// Assumed location: <host>/shared/platform-tools/api/agent.php
|
||||
// dirname(__DIR__, 3) === <host>
|
||||
// The per-platform stub at <host>/api/agent.php is a one-line require of this file.
|
||||
//
|
||||
// Hosts on a different stack (surf: src/ + App\Database) write their own thin
|
||||
// stub that bootstraps their way and calls agent_dispatch() directly — the
|
||||
// action set lives in functions_agent.php so both paths share it.
|
||||
|
||||
$repo_root = dirname(__DIR__, 3);
|
||||
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
|
||||
require_once $repo_root . '/config/functions.php'; // env(), helpers, migrations stub
|
||||
require_once __DIR__ . '/../functions_agent.php';
|
||||
|
||||
$secret = (string) (function_exists('env')
|
||||
? (env('HUB_OPS_SECRET', '') ?: '')
|
||||
: (getenv('HUB_OPS_SECRET') ?: ''));
|
||||
|
||||
agent_dispatch($pdo, $repo_root, $secret);
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// Self-bootstrapping deploy webhook. Point a Gitea (or GitHub) push webhook at
|
||||
// <host>/api/auto_update.php and the platform updates itself on every push to
|
||||
// the branch it is running.
|
||||
//
|
||||
// Assumed location: <host>/shared/platform-tools/api/auto_update.php
|
||||
// dirname(__DIR__, 3) === <host>
|
||||
// The per-platform stub at <host>/api/auto_update.php is a one-line require.
|
||||
//
|
||||
// Answers JSON always, and with a status code the webhook UI can be read from:
|
||||
// 202 accepted (work continues after the reply) / disabled
|
||||
// 200 skipped — push was for another branch
|
||||
// 401 bad signature · 405 wrong method · 503 no secret configured
|
||||
//
|
||||
// A refusal is deliberately a 4xx/5xx: Gitea shows the delivery in red and
|
||||
// keeps the response body, which is the only place a rejected delivery would
|
||||
// ever be noticed before someone opens the platform page.
|
||||
//
|
||||
// Everything that can be answered instantly IS answered instantly, and only the
|
||||
// slow part runs after the reply. Gitea allows a webhook delivery 5 seconds by
|
||||
// default; a git pull alone exceeds that and a pre-update backup exceeds it by
|
||||
// minutes, so a synchronous reply would mark every successful deploy as failed.
|
||||
// The outcome is recorded in auto_update_last_* — the platform page and the
|
||||
// dashboard strip are where you read it.
|
||||
|
||||
$repo_root = dirname(__DIR__, 3);
|
||||
require_once $repo_root . '/config/db.php'; // opens $pdo, starts the session
|
||||
require_once $repo_root . '/config/functions.php'; // env(), helpers
|
||||
require_once __DIR__ . '/../functions_auto_update.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$reply = function (int $code, array $data): never {
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$reply(405, ['ok' => false, 'error' => 'Só POST.']);
|
||||
}
|
||||
|
||||
// Read the body ONCE and verify against those exact bytes — re-reading
|
||||
// php://input after the fact can come back empty and would silently turn the
|
||||
// signature check into a no-op.
|
||||
$body = (string) file_get_contents('php://input');
|
||||
$secret = auto_update_secret();
|
||||
|
||||
if ($secret === '') {
|
||||
$reply(503, ['ok' => false, 'error' => 'AUTO_UPDATE_SECRET não está definido no .env desta plataforma.']);
|
||||
}
|
||||
if (!auto_update_verify($body, $secret)) {
|
||||
$reply(401, ['ok' => false, 'error' => 'Assinatura ausente ou inválida.']);
|
||||
}
|
||||
|
||||
// Signature first, switch second: an unauthenticated caller learns nothing
|
||||
// about this platform, not even whether the feature is on.
|
||||
if (!auto_update_enabled($pdo)) {
|
||||
$reply(202, ['ok' => true, 'status' => 'disabled',
|
||||
'message' => 'Auto-update desligado nesta plataforma.']);
|
||||
}
|
||||
|
||||
// A push to a branch this server is not running is not our business — the
|
||||
// webhook fires for every branch, and pulling one you are not on does nothing
|
||||
// useful at best.
|
||||
$ref = auto_update_payload_branch($body);
|
||||
if ($ref !== null && function_exists('git_run')) {
|
||||
$cur = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
|
||||
$cur = $cur['ok'] ? trim($cur['output']) : '';
|
||||
if ($cur !== '' && $cur !== $ref) {
|
||||
$reply(200, ['ok' => true, 'status' => 'skipped',
|
||||
'message' => "Push para {$ref}; este servidor está em {$cur}."]);
|
||||
}
|
||||
}
|
||||
|
||||
// Server-to-server past this point: drop the session lock so the pull (which
|
||||
// can take a while) does not hold up the operator's own browsing.
|
||||
@session_write_close();
|
||||
|
||||
// Answer Gitea now; do the slow work with the connection already closed.
|
||||
platform_respond_and_continue(202, [
|
||||
'ok' => true,
|
||||
'status' => 'accepted',
|
||||
'message' => 'Atualização iniciada. O resultado fica registado na plataforma.',
|
||||
'ref' => $ref,
|
||||
]);
|
||||
|
||||
auto_update_run($pdo, $repo_root, $ref);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// Self-bootstrapping download handler. Assumed location:
|
||||
// <host>/shared/platform-tools/api/backup_download.php
|
||||
|
||||
$repo_root = dirname(__DIR__, 3);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_once $repo_root . '/config/functions_backup.php';
|
||||
require_sysadmin();
|
||||
|
||||
$filename = $_GET['file'] ?? '';
|
||||
|
||||
if (!backup_filename_is_safe($filename)) {
|
||||
http_response_code(400);
|
||||
exit('Ficheiro inválido.');
|
||||
}
|
||||
|
||||
$dir = backup_dir();
|
||||
$filepath = $dir . DIRECTORY_SEPARATOR . $filename;
|
||||
$real = realpath($filepath);
|
||||
|
||||
if ($real === false || !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
|
||||
http_response_code(403);
|
||||
exit('Acesso negado.');
|
||||
}
|
||||
|
||||
if (!is_file($real)) {
|
||||
http_response_code(404);
|
||||
exit('Ficheiro não encontrado.');
|
||||
}
|
||||
|
||||
$size = filesize($real);
|
||||
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . $size);
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
if (ob_get_level()) ob_end_clean();
|
||||
|
||||
readfile($real);
|
||||
exit;
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// Self-bootstrapping POST handler. Assumed location:
|
||||
// <host>/shared/platform-tools/api/backup_trigger.php
|
||||
// dirname(__DIR__, 3) === <host>
|
||||
|
||||
$repo_root = dirname(__DIR__, 3);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_once $repo_root . '/config/functions_backup.php';
|
||||
require_once __DIR__ . '/../functions_csrf.php';
|
||||
require_once __DIR__ . '/../functions_settings.php'; // platform_admin_url()
|
||||
require_sysadmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: ' . platform_admin_url('backups.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!platform_csrf_ok()) {
|
||||
$_SESSION['backup_flash'] = [
|
||||
'type' => 'danger',
|
||||
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido). Recarrega a página e repete.',
|
||||
];
|
||||
header('Location: ' . platform_admin_url('backups.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$type = $_POST['type'] ?? '';
|
||||
|
||||
if (!in_array($type, ['files', 'db'], true)) {
|
||||
$_SESSION['backup_flash'] = [
|
||||
'type' => 'danger',
|
||||
'message' => 'Tipo de backup inválido.',
|
||||
];
|
||||
header('Location: ' . platform_admin_url('backups.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$count_before = count(backup_list());
|
||||
$ok = backup_trigger($type);
|
||||
|
||||
$_SESSION['backup_flash'] = $ok
|
||||
? [
|
||||
'type' => 'success',
|
||||
'message' => 'Backup iniciado. A lista atualiza automaticamente quando o ficheiro aparecer.',
|
||||
// Lets the backups page poll until a new file shows up.
|
||||
'await_count' => $count_before,
|
||||
]
|
||||
: [
|
||||
'type' => 'danger',
|
||||
'message' => 'Falha ao iniciar o backup. Verifique os logs do servidor.',
|
||||
];
|
||||
|
||||
header('Location: ' . platform_admin_url('backups.php'));
|
||||
exit;
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
// ─── Endpoint da bolha de feedback ───────────────────────────────────────────
|
||||
//
|
||||
// POST multipart/form-data (o que um <form> envia), resposta JSON:
|
||||
// {"ok":true,"ref":"T-1234","track_url":"https://…"}
|
||||
// {"ok":false,"error":"…"}
|
||||
//
|
||||
// Ao contrário dos outros endpoints desta pasta, este NÃO se autentica por
|
||||
// chave: quem chama é o browser de um administrador com sessão aberta neste
|
||||
// site. Precisa por isso da sessão e dos helpers de autenticação do site — e
|
||||
// obtém-nos do mesmo hub_bootstrap.php que os outros, que é quem sabe distinguir
|
||||
// os dois stacks da frota (config/db.php ou src/config.php + App\Database) e
|
||||
// deixa em cima da mesa a sessão aberta, o $pdo e o functions_hub.
|
||||
//
|
||||
// Assim o stub de cada site é uma linha, igual à dos restantes endpoints.
|
||||
//
|
||||
// Sendo autenticado por cookie, é atacável por CSRF: um site qualquer podia
|
||||
// levar o browser de um administrador a abrir tickets em nome dele. Daí o
|
||||
// token do platform_csrf_ok(). Não substitui a verificação do papel — CSRF e
|
||||
// permissão respondem a perguntas diferentes.
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../hub_bootstrap.php';
|
||||
require_once __DIR__ . '/../functions_feedback.php';
|
||||
require_once __DIR__ . '/../functions_csrf.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Nada disto deve ficar em cache nem ser partilhado entre sessões.
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function feedback_out(int $status, array $payload): never
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
feedback_out(405, ['ok' => false, 'error' => 'Usa POST.']);
|
||||
}
|
||||
|
||||
$user = platform_feedback_user();
|
||||
if (!$user || !platform_feedback_is_admin($user)) {
|
||||
// 403 e não 401: quem chega aqui sem sessão não tem como a obter a partir
|
||||
// deste endereço, e um 401 mandaria o browser pedir credenciais.
|
||||
feedback_out(403, ['ok' => false, 'error' => 'Sessão terminada ou sem permissão. Recarrega a página.']);
|
||||
}
|
||||
|
||||
if (!platform_csrf_ok()) {
|
||||
feedback_out(403, ['ok' => false, 'error' => 'Pedido rejeitado (token expirado). Recarrega a página e tenta outra vez.']);
|
||||
}
|
||||
|
||||
$res = platform_feedback_submit([
|
||||
'title' => (string) ($_POST['title'] ?? ''),
|
||||
'description' => (string) ($_POST['description'] ?? ''),
|
||||
'category' => (string) ($_POST['category'] ?? 'problema'),
|
||||
'blocking' => !empty($_POST['blocking']),
|
||||
'page_url' => (string) ($_POST['page_url'] ?? ''),
|
||||
], $user);
|
||||
|
||||
// 200 mesmo quando o hub recusa: o erro já vem escrito para a pessoa ler, e é
|
||||
// o corpo que a bolha mostra. Um 4xx aqui só faria o fetch tratar como avaria
|
||||
// de rede uma coisa que tem explicação.
|
||||
feedback_out(200, [
|
||||
'ok' => (bool) $res['ok'],
|
||||
'error' => (string) ($res['error'] ?? ''),
|
||||
'ref' => (string) ($res['ref'] ?? ''),
|
||||
'track_url' => (string) ($res['track_url'] ?? ''),
|
||||
]);
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
// Estado desta plataforma, para o monitor do hub. Só leitura: nenhum parâmetro
|
||||
// muda comportamento e nenhum dado de negócio sai daqui. As ações vivem noutro
|
||||
// endpoint (api/hub_ops.php) e com outra chave — ver ../functions_hub.php.
|
||||
//
|
||||
// Formato: docs/CONTRATO_AGENTE_SAUDE.md do hub. `git` e `backup` são entradas
|
||||
// DENTRO de `checks`; um check em falta aparece como «não reportado» e um check
|
||||
// a `false` pinta o cartão de amarelo.
|
||||
//
|
||||
// **`ok` de topo não é o AND dos checks.** Segundo o contrato, `ok:false`
|
||||
// significa «responde, mas está inutilizável» — e é isso que o hub mostra como
|
||||
// «Em baixo». Uma mensagem falhada na fila ou uma cópia antiga não põem um site
|
||||
// em baixo: são checks a false, que já dão o aviso amarelo. Aqui `ok` segue a
|
||||
// base de dados, que é o que decide se a plataforma consegue sequer servir.
|
||||
//
|
||||
// Cada verificação corre isolada: uma que rebente reporta-se como falha, em vez
|
||||
// de derrubar a resposta inteira — que apareceria no hub como app inacessível,
|
||||
// que é uma coisa diferente e mais alarmante.
|
||||
//
|
||||
// O stub de cada site (public/api/health.php) é uma linha a incluir isto.
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../hub_bootstrap.php';
|
||||
|
||||
hub_key_verify_or_die();
|
||||
|
||||
$health_check = static function (callable $fn): array {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'detail' => 'erro no check: ' . $e->getMessage()];
|
||||
}
|
||||
};
|
||||
|
||||
$root = $repo_root;
|
||||
$checks = [];
|
||||
|
||||
// ─── Plataformas estáticas ───────────────────────────────────────────────────
|
||||
//
|
||||
// O apex (andmore.pt) não tem base de dados — ver a nota no hub_bootstrap.php.
|
||||
// Com ela caem quatro checks: base de dados, SMTP, fila e migrações. Nenhum se
|
||||
// pode medir, e o contrato diz que um check em falta aparece como «não
|
||||
// reportado», que é exactamente a verdade.
|
||||
//
|
||||
// O que não se faz é deixá-los cair calados: sem uma palavra, o cartão no hub
|
||||
// fica com quatro linhas em branco e ninguém sabe se aquilo é uma montra
|
||||
// estática ou uma app com o arranque partido. O check `storage` diz qual é.
|
||||
if (!$hub_has_db_layer) {
|
||||
$checks['storage'] = ['ok' => true, 'detail' => 'montra estática — sem base de dados'];
|
||||
}
|
||||
|
||||
if ($pdo instanceof PDO) $checks['database'] = $health_check(function () use ($pdo) {
|
||||
$version = (string) $pdo->query('SELECT VERSION()')->fetchColumn();
|
||||
$tables = (int) $pdo->query('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()')->fetchColumn();
|
||||
return ['ok' => true, 'detail' => $version . ', ' . $tables . ' tabelas'];
|
||||
});
|
||||
|
||||
// SMTP: abre a ligação e fecha. NÃO envia nada — isto corre de poucos em poucos
|
||||
// minutos e um envio por sondagem seria spam a si próprio.
|
||||
if ($pdo instanceof PDO) $checks['smtp'] = $health_check(function () use ($pdo) {
|
||||
// O SMTP da frota é configurado no backoffice, não no .env (as chaves do
|
||||
// .env são placeholders em branco de propósito). Ler só o .env dava «sem
|
||||
// SMTP configurado» em sites que o têm a funcionar há meses — daí o
|
||||
// platform_smtp_config(), que é o resolvedor canónico da frota.
|
||||
$cfg = platform_smtp_config($pdo);
|
||||
$host = trim((string) $cfg['host']);
|
||||
if ($host === '') return ['ok' => true, 'detail' => 'sem SMTP configurado'];
|
||||
$port = (int) ($cfg['port'] ?: 587);
|
||||
$e1 = 0; $e2 = '';
|
||||
$sock = @fsockopen($host, $port, $e1, $e2, 5);
|
||||
if (!$sock) return ['ok' => false, 'detail' => "$host:$port — $e2"];
|
||||
$greeting = trim((string) fgets($sock, 512));
|
||||
fclose($sock);
|
||||
return ['ok' => str_starts_with($greeting, '220'), 'detail' => "$host:$port — $greeting"];
|
||||
});
|
||||
|
||||
// Fila de envios. Só as plataformas que a têm reportam: o contrato diz que um
|
||||
// check ausente aparece como «não reportado», que é a verdade, em vez de um
|
||||
// `ok` que não mediu nada.
|
||||
$queue = !($pdo instanceof PDO) ? [] : $health_check(function () use ($pdo) {
|
||||
if (!$pdo->query("SHOW TABLES LIKE 'message_queue'")->fetch()) return [];
|
||||
$rows = $pdo->query("SELECT status, COUNT(*) c FROM message_queue GROUP BY status")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$failed = (int) ($rows['failed'] ?? 0);
|
||||
$pending = (int) ($rows['pending'] ?? 0);
|
||||
return ['ok' => $failed === 0, 'detail' => $pending . ' por enviar, ' . $failed . ' falhadas',
|
||||
'pending' => $pending, 'failed' => $failed];
|
||||
});
|
||||
if ($queue) $checks['queue'] = $queue;
|
||||
|
||||
// Migrações por aplicar — o sinal de que um `git pull` ficou a meio e a app
|
||||
// está a correr contra um esquema antigo.
|
||||
if ($pdo instanceof PDO) $checks['migrations'] = $health_check(function () use ($pdo) {
|
||||
hub_load_migration_helpers();
|
||||
// Não medir não é estar bem: sem runner isto diz-se por medir, e o hub
|
||||
// mostra-o como aviso em vez de um «tudo ok» que esconde migrações à espera.
|
||||
if (!function_exists('migrations_status')) {
|
||||
return ['ok' => false, 'detail' => 'runner indisponível — por medir', 'measured' => false];
|
||||
}
|
||||
$pending = 0;
|
||||
foreach (migrations_status($pdo) as $m) if (empty($m['applied_at'])) $pending++;
|
||||
return ['ok' => $pending === 0, 'detail' => $pending . ' por aplicar', 'pending' => $pending];
|
||||
});
|
||||
|
||||
// Espaço em disco: uma cópia de segurança que não cabe é uma cópia que não há.
|
||||
$checks['disk'] = $health_check(function () use ($root) {
|
||||
$free = @disk_free_space($root);
|
||||
$total = @disk_total_space($root);
|
||||
if ($free === false) return ['ok' => true, 'detail' => 'indisponível'];
|
||||
$used = ($total && $total > 0) ? (int) round(100 * ($total - $free) / $total) : null;
|
||||
return ['ok' => $free > 512 * 1024 * 1024,
|
||||
'detail' => round($free / 1073741824, 2) . ' GB livres',
|
||||
'used_percent' => $used, 'free_bytes' => (int) $free];
|
||||
});
|
||||
|
||||
// Git e cópias entram como checks — não como chaves de topo. Ambos devolvem []
|
||||
// quando não se aplicam (num cPanel sem repositório, por exemplo), e o contrato
|
||||
// diz que um check ausente é «não reportado», não uma falha.
|
||||
$git = $health_check(fn() => hub_git_check($root));
|
||||
if ($git) $checks['git'] = $git;
|
||||
|
||||
// Numa montra estática não se pergunta por cópias: não há base de dados a
|
||||
// despejar e o código está todo no git, que o check acima já reporta. Sem esta
|
||||
// guarda o backup_list() -- que existe sempre, porque vem do platform-tools --
|
||||
// respondia «nenhuma cópia encontrada» e punha o cartão a amarelo para sempre,
|
||||
// por uma cópia que não faria sentido nenhum existir.
|
||||
$backup = $hub_has_db_layer ? $health_check(fn() => hub_backup_check()) : [];
|
||||
if ($backup) $checks['backup'] = $backup;
|
||||
|
||||
hub_json([
|
||||
// Só a base de dados: ver a nota no topo sobre o que significa `ok`. Numa
|
||||
// plataforma estática não há base de dados que decida nada -- o que torna a
|
||||
// plataforma inutilizável é não responder, e nesse caso não se chega aqui.
|
||||
'ok' => $hub_has_db_layer ? !empty($checks['database']['ok']) : true,
|
||||
'app' => hub_app_name(),
|
||||
'version' => hub_version($root, $git),
|
||||
'env' => hub_env('APP_ENV', 'prod'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'checks' => $checks,
|
||||
// Extra fora do contrato — o hub guarda tudo em payload_json. Diz onde
|
||||
// ficam as ações e quais são, para não ter de manter uma lista por
|
||||
// plataforma. Repara que exigem OUTRA chave.
|
||||
//
|
||||
// Com as operações desligadas não se anuncia endpoint nem lista de ações.
|
||||
// O apex nem sequer tem o stub do hub_ops.php — anunciar seis ações e um
|
||||
// caminho que devolve 404 é pior do que não anunciar nada: quem lê a
|
||||
// resposta fica a achar que a plataforma sabe fazer coisas que não sabe.
|
||||
'ops' => hub_ops_key() === '' ? [
|
||||
'endpoint' => null,
|
||||
'actions' => [],
|
||||
'enabled' => false,
|
||||
'auth' => 'X-Hub-Key: <chave de operações>',
|
||||
] : [
|
||||
'endpoint' => hub_base_url('api/hub_ops.php'),
|
||||
'actions' => hub_ops_actions(),
|
||||
'enabled' => true,
|
||||
'auth' => 'X-Hub-Key: <chave de operações>',
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
// Ações remotas do hub sobre esta plataforma: cópias de segurança, atualização
|
||||
// e migrações. Chave DIFERENTE da do api/health.php, e POST — o que muda alguma
|
||||
// coisa não entra por GET, para não ser disparado por um link ou um prefetch.
|
||||
//
|
||||
// POST JSON: {"action": "...", ...}
|
||||
//
|
||||
// **A atualização e as cópias são independentes de propósito.** Num alojamento
|
||||
// cPanel o `git pull` falha ou nem existe (sem shell, sem git, sem permissões
|
||||
// de escrita no tree) — e isso não pode impedir o hub de mandar fazer uma cópia
|
||||
// da base de dados, que é a operação que realmente protege o cliente. Por isso
|
||||
// `git.pull` devolve `ok:false` com a explicação em vez de rebentar, e as ações
|
||||
// de cópia não passam por git nenhum.
|
||||
//
|
||||
// O stub de cada site (public/api/hub_ops.php) é uma linha a incluir isto.
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../hub_bootstrap.php';
|
||||
|
||||
hub_ops_verify_or_die();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
|
||||
}
|
||||
|
||||
$raw = (string) file_get_contents('php://input');
|
||||
$req = json_decode($raw, true);
|
||||
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_POST['action'] ?? '');
|
||||
|
||||
$root = $repo_root;
|
||||
|
||||
// Numa montra estática não há esquema a migrar nem base de dados a despejar
|
||||
// (ver a nota do hub_bootstrap.php). Recusa-se aqui, com a razão escrita, em
|
||||
// vez de deixar as ações chamarem métodos sobre um $pdo a null — um erro de PHP
|
||||
// apareceria no hub como plataforma avariada, que é uma coisa diferente.
|
||||
if (!$pdo instanceof PDO
|
||||
&& in_array($action, ['migrations.list', 'migrations.apply_pending'], true)) {
|
||||
hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400);
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
|
||||
// ── Cópias de segurança ──────────────────────────────────────────────────
|
||||
case 'backup.list':
|
||||
hub_load_backup_helpers();
|
||||
hub_json(['ok' => true, 'backups' => function_exists('backup_list') ? backup_list() : [],
|
||||
'summary' => hub_backup_summary()]);
|
||||
|
||||
case 'backup.trigger':
|
||||
$type = is_array($req) ? (string) ($req['type'] ?? 'db') : 'db';
|
||||
if (!in_array($type, ['db', 'files'], true)) {
|
||||
hub_json(['ok' => false, 'error' => 'tipo inválido (db|files)'], 400);
|
||||
}
|
||||
hub_load_backup_helpers();
|
||||
if (!function_exists('backup_trigger')) {
|
||||
hub_json(['ok' => false, 'error' => 'backups não disponíveis nesta plataforma'], 503);
|
||||
}
|
||||
$ok = backup_trigger($type);
|
||||
// O backup_trigger() é «dispara e esquece»: o ficheiro pode ainda estar
|
||||
// a ser escrito quando isto responde. O resumo devolvido é o de ANTES,
|
||||
// e é o hub que volta a perguntar — dizer o contrário seria mentir
|
||||
// sobre uma cópia que ainda não existe.
|
||||
hub_json([
|
||||
'ok' => $ok,
|
||||
'type' => $type,
|
||||
'started' => $ok,
|
||||
'note' => $ok ? 'cópia iniciada; volta a consultar daqui a instantes' : 'não foi possível iniciar a cópia',
|
||||
'summary' => hub_backup_summary(),
|
||||
], $ok ? 200 : 500);
|
||||
|
||||
// ── Atualização ──────────────────────────────────────────────────────────
|
||||
case 'git.status':
|
||||
case 'git.pull':
|
||||
require_once __DIR__ . '/../functions_git.php';
|
||||
if (!function_exists('git_run') || !is_dir($root . '/.git')) {
|
||||
hub_json(['ok' => false, 'error' => 'esta instalação não é um repositório git (típico em cPanel) — as cópias continuam a funcionar'], 503);
|
||||
}
|
||||
|
||||
if ($action === 'git.status') {
|
||||
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $root);
|
||||
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $root);
|
||||
$dirty = git_run(['status', '--porcelain'], $root);
|
||||
hub_json(['ok' => true,
|
||||
'branch' => $branch['ok'] ? trim($branch['output']) : null,
|
||||
'head' => $head['ok'] ? trim($head['output']) : null,
|
||||
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$pull = git_run(['pull', '--ff-only'], $root);
|
||||
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
|
||||
if ($pull['ok']) {
|
||||
// O ff-only recusa-se a fundir; um repositório divergente falha aqui
|
||||
// em vez de inventar um merge que ninguém pediu.
|
||||
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $root);
|
||||
if (trim($sub['output']) !== '') $output .= "\n\n[submodules]\n" . $sub['output'];
|
||||
if (function_exists('opcache_reset')) @opcache_reset();
|
||||
if (function_exists('settings_cache_clear')) settings_cache_clear();
|
||||
if (function_exists('platform_settings_cache_clear')) platform_settings_cache_clear();
|
||||
$output .= "\n\n(OPcache + cache de definições invalidados)";
|
||||
}
|
||||
hub_json(['ok' => $pull['ok'], 'output' => $output]);
|
||||
|
||||
// ── Migrações ────────────────────────────────────────────────────────────
|
||||
// Um pull sem as migrações aplicadas deixa a app a correr contra um esquema
|
||||
// antigo, portanto o hub tem de conseguir fazer as duas coisas.
|
||||
//
|
||||
// Numa plataforma estática não há esquema nenhum a migrar (ver a nota do
|
||||
// hub_bootstrap.php). Diz-se assim, em vez de rebentar com um erro de PHP a
|
||||
// chamar métodos sobre null — que o hub mostraria como app avariada.
|
||||
case 'migrations.list':
|
||||
hub_load_migration_helpers();
|
||||
if (!function_exists('migrations_status')) hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
|
||||
hub_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
|
||||
|
||||
case 'migrations.apply_pending':
|
||||
hub_load_migration_helpers();
|
||||
if (!function_exists('migrations_status') || !function_exists('migrations_apply_file')) {
|
||||
hub_json(['ok' => false, 'error' => 'runner indisponível'], 503);
|
||||
}
|
||||
$applied = [];
|
||||
$error = null;
|
||||
foreach (migrations_status($pdo) as $row) {
|
||||
if (!empty($row['applied_at'])) continue;
|
||||
$r = migrations_apply_file($pdo, $row['filename'], null);
|
||||
if (!empty($r['ok'])) {
|
||||
$applied[] = $row['filename'];
|
||||
} else {
|
||||
// Pára à primeira falha: a seguinte pode depender desta, e
|
||||
// continuar a aplicar às cegas é como um esquema fica a meio.
|
||||
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
|
||||
hub_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
|
||||
|
||||
default:
|
||||
hub_json(['ok' => false, 'error' => 'ação desconhecida: ' . $action,
|
||||
'actions' => hub_ops_actions()], 400);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// Emparelhamento: o hub chama aqui quando o André aprova esta plataforma na
|
||||
// Frota. É a ÚNICA etapa em que as chaves saem daqui, e saem numa ligação que o
|
||||
// hub iniciou — não numa que esta plataforma abriu para um URL que lhe deram.
|
||||
//
|
||||
// Autenticação: o código de emparelhamento que esta plataforma gerou e enviou
|
||||
// no anúncio. Uso único, com validade. Não há chave de API nesta etapa, porque
|
||||
// é precisamente ela que está a ser entregue.
|
||||
//
|
||||
// O stub de cada site (public/api/hub_pair.php) é uma linha a incluir isto.
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../hub_bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
hub_json(['ok' => false, 'error' => 'usa POST'], 405);
|
||||
}
|
||||
|
||||
$req = json_decode((string) file_get_contents('php://input'), true);
|
||||
$code = is_array($req) ? (string) ($req['pairing_code'] ?? '') : '';
|
||||
$ops = is_array($req) && !empty($req['with_ops']);
|
||||
|
||||
if ($code === '') hub_json(['ok' => false, 'error' => 'código em falta'], 400);
|
||||
|
||||
// O emparelhamento automático guarda o estado e as chaves no system_settings,
|
||||
// portanto precisa mesmo de base de dados. Uma montra estática (ver a nota do
|
||||
// hub_bootstrap.php) inscreve-se à mão: chaves no .env do stack, linha na
|
||||
// tabela `apps` do hub. Diz-se isso, em vez de rebentar sobre um $pdo a null.
|
||||
if (!$pdo instanceof PDO) {
|
||||
hub_json(['ok' => false,
|
||||
'error' => 'plataforma sem base de dados — inscreve-se à mão, com as chaves no .env'], 400);
|
||||
}
|
||||
|
||||
$res = hub_pair_complete($pdo, $code, $ops);
|
||||
if (empty($res['ok'])) {
|
||||
// 403 e não 404: o pedido chegou ao sítio certo, o que falha é a permissão.
|
||||
hub_json(['ok' => false, 'error' => $res['error']], 403);
|
||||
}
|
||||
|
||||
hub_json([
|
||||
'ok' => true,
|
||||
'app' => hub_app_name(),
|
||||
'api_key' => $res['api_key'],
|
||||
'ops_key' => $res['ops_key'], // null quando o hub não pediu operações
|
||||
'health' => hub_base_url('api/health.php'),
|
||||
'ops' => hub_base_url('api/hub_ops.php'),
|
||||
]);
|
||||
Reference in new issue
Block a user