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>
227 lines
10 KiB
PHP
227 lines
10 KiB
PHP
<?php
|
|
// ─── Remote-ops agent (shared) ───────────────────────────────────────────────
|
|
//
|
|
// Server-to-server endpoint the id.andmore.pt fleet control plane drives to read
|
|
// status and (later phases) pull git / apply migrations / take backups remotely.
|
|
// No session, no CSRF — authenticated purely by HMAC over the shared secret.
|
|
//
|
|
// The hub signs each request with this platform's HUB_OPS_SECRET (.env; generated
|
|
// on id.andmore.pt → Plataformas → <app> → "Gerar segredo de operações").
|
|
// Canonical signing string binds the FULL request (this channel mutates, unlike
|
|
// the read-only content pull which signs only date.path):
|
|
//
|
|
// signing = method "\n" path "\n" sha256(body) "\n" date
|
|
// X-Hub-Date : ISO-8601 UTC timestamp (±5 min tolerance)
|
|
// X-Hub-Token : hash_hmac('sha256', signing, HUB_OPS_SECRET)
|
|
//
|
|
// Everything here is best-effort and guarded with function_exists(), so a host
|
|
// that wires only some of the platform-tools helpers (e.g. surf, no backups)
|
|
// still answers a partial, well-formed status.
|
|
|
|
function agent_json(array $data, int $code = 200): void {
|
|
http_response_code($code);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Verify the request HMAC. On success returns the raw request body (already read
|
|
* from php://input, so callers must use the returned string, not re-read it).
|
|
* On any failure emits 401/503 and exits.
|
|
*/
|
|
function agent_verify_or_die(string $secret): string {
|
|
if ($secret === '') {
|
|
agent_json(['error' => 'HUB_OPS_SECRET não configurado nesta plataforma.'], 503);
|
|
}
|
|
|
|
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
|
$date = (string) ($_SERVER['HTTP_X_HUB_DATE'] ?? '');
|
|
$token = (string) ($_SERVER['HTTP_X_HUB_TOKEN'] ?? '');
|
|
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
|
|
$body = (string) file_get_contents('php://input');
|
|
$ts = $date !== '' ? strtotime($date) : false;
|
|
|
|
if ($token === '' || $ts === false || abs(time() - $ts) > 300) {
|
|
agent_json(['error' => 'Assinatura ausente ou expirada.'], 401);
|
|
}
|
|
|
|
$signing = $method . "\n" . $path . "\n" . hash('sha256', $body) . "\n" . $date;
|
|
$expected = hash_hmac('sha256', $signing, $secret);
|
|
if (!hash_equals($expected, $token)) {
|
|
agent_json(['error' => 'Assinatura inválida.'], 401);
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
/**
|
|
* Git facts read OFFLINE (no network) — `behind` reflects the last-known remote
|
|
* tracking ref. Refresh it with the git.fetch action before trusting it.
|
|
* Returns null when git is unavailable.
|
|
*/
|
|
function agent_git_info(string $repo_root): ?array {
|
|
if (!function_exists('git_run')) return null;
|
|
|
|
$branch = git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root);
|
|
$head = git_run(['log', '-1', '--format=%h|%cd|%s', '--date=short'], $repo_root);
|
|
$dirty = git_run(['status', '--porcelain'], $repo_root);
|
|
$count = git_run(['rev-list', '--left-right', '--count', 'HEAD...@{u}'], $repo_root);
|
|
|
|
$ahead = $behind = null;
|
|
if ($count['ok'] && preg_match('/^(\d+)\s+(\d+)/', $count['output'], $m)) {
|
|
$ahead = (int) $m[1]; // local commits not pushed
|
|
$behind = (int) $m[2]; // remote commits not pulled → "atrasado"
|
|
}
|
|
return [
|
|
'branch' => $branch['ok'] ? trim($branch['output']) : null,
|
|
'head' => $head['ok'] ? trim($head['output']) : null,
|
|
'dirty' => $dirty['ok'] ? (trim($dirty['output']) !== '') : null,
|
|
'ahead' => $ahead,
|
|
'behind' => $behind,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Assemble the platform health snapshot. Each section is independently guarded:
|
|
* a missing helper or a failing query yields a null field, never a blank reply.
|
|
*/
|
|
function agent_status(PDO $pdo, string $repo_root): array {
|
|
$out = ['ok' => true, 'checked_at' => gmdate('c'), 'php_version' => PHP_VERSION];
|
|
|
|
$backup_dir = function_exists('backup_dir') ? backup_dir() : ($repo_root . '/backups');
|
|
$free = @disk_free_space($backup_dir);
|
|
$out['disk_free_bytes'] = ($free !== false) ? (int) $free : null;
|
|
|
|
$git = agent_git_info($repo_root);
|
|
if ($git !== null) $out['git'] = $git;
|
|
|
|
// ── Migrations pending ───────────────────────────────────────────────────
|
|
if (function_exists('migrations_status')) {
|
|
try {
|
|
$pending = [];
|
|
foreach (migrations_status($pdo) as $r) {
|
|
if (($r['applied_at'] ?? null) === null) {
|
|
$pending[] = $r['filename'];
|
|
}
|
|
}
|
|
$out['migrations_pending'] = count($pending);
|
|
$out['migrations_pending_files'] = $pending;
|
|
} catch (Throwable $e) {
|
|
$out['migrations_pending'] = null;
|
|
}
|
|
}
|
|
|
|
// ── Schema drift (committed schema.sql vs a fresh build) ─────────────────
|
|
if (function_exists('schema_build') && function_exists('schema_dump_path')) {
|
|
try {
|
|
$live = schema_build($pdo);
|
|
$file = @file_get_contents(schema_dump_path());
|
|
$out['schema_drift'] = ($file === false) ? null : (trim($live) !== trim($file));
|
|
} catch (Throwable $e) {
|
|
$out['schema_drift'] = null;
|
|
}
|
|
}
|
|
|
|
// ── Last backup ──────────────────────────────────────────────────────────
|
|
if (function_exists('backup_list')) {
|
|
try {
|
|
$list = backup_list();
|
|
$out['backup_count'] = count($list);
|
|
$out['last_backup'] = $list ? [
|
|
'filename' => $list[0]['filename'],
|
|
'type' => $list[0]['type'],
|
|
'created_at' => gmdate('c', $list[0]['created_at']),
|
|
'size_human' => $list[0]['size_human'],
|
|
] : null;
|
|
} catch (Throwable $e) {
|
|
$out['last_backup'] = null;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Verify + dispatch a remote-ops request. The calling stub bootstraps the host
|
|
* its own way (config/ stack vs surf's src/ stack), opens $pdo, sets $repo_root
|
|
* and reads HUB_OPS_SECRET, then hands off here. Keeping the dispatch out of
|
|
* api/agent.php lets a non-config/ host (surf) reuse the exact same action set.
|
|
*
|
|
* Loads the sibling helpers it needs (git/migrations/schema/backup) from the
|
|
* submodule root, so the stub doesn't have to. Never returns — every branch
|
|
* ends in agent_json(), which exits.
|
|
*/
|
|
function agent_dispatch(PDO $pdo, string $repo_root, string $secret): void {
|
|
require_once __DIR__ . '/functions_git.php';
|
|
require_once __DIR__ . '/functions_migrations.php';
|
|
require_once __DIR__ . '/functions_schema.php';
|
|
if (is_file(__DIR__ . '/functions_backup.php')) {
|
|
require_once __DIR__ . '/functions_backup.php';
|
|
}
|
|
|
|
$body = agent_verify_or_die($secret);
|
|
@session_write_close(); // server-to-server; release the session lock past auth
|
|
|
|
$req = json_decode($body, true);
|
|
$action = is_array($req) ? (string) ($req['action'] ?? '') : (string) ($_GET['action'] ?? '');
|
|
|
|
switch ($action) {
|
|
case 'status':
|
|
agent_json(agent_status($pdo, $repo_root));
|
|
// no break — agent_json() exits
|
|
|
|
case 'git.fetch':
|
|
$res = git_run(['fetch', '--all', '--prune'], $repo_root);
|
|
agent_json([
|
|
'ok' => $res['ok'],
|
|
'output' => $res['output'] !== '' ? $res['output'] : '(sem alterações remotas)',
|
|
'git' => agent_git_info($repo_root),
|
|
]);
|
|
|
|
case 'git.pull':
|
|
$pull = git_run(['pull', '--ff-only'], $repo_root);
|
|
$ok = $pull['ok'];
|
|
$output = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
|
|
if ($ok) {
|
|
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root);
|
|
if (trim($sub['output']) !== '') $output .= "\n\n[submodule update]\n" . $sub['output'];
|
|
if (function_exists('opcache_reset')) @opcache_reset();
|
|
if (function_exists('settings_cache_clear')) settings_cache_clear();
|
|
$output .= "\n\n(OPcache + settings cache invalidados)";
|
|
}
|
|
agent_json(['ok' => $ok, 'output' => $output, 'git' => agent_git_info($repo_root)]);
|
|
|
|
case 'migrations.list':
|
|
agent_json(['ok' => true, 'migrations' => migrations_status($pdo)]);
|
|
|
|
case 'migrations.apply':
|
|
// migrations_apply_file() enforces the allowlist: only an existing
|
|
// database/migration_*.sql in this repo (no raw SQL — locked decision).
|
|
$file = is_array($req) ? (string) ($req['filename'] ?? '') : '';
|
|
$res = migrations_apply_file($pdo, $file, null);
|
|
if (!empty($res['ok']) && function_exists('settings_cache_clear')) settings_cache_clear();
|
|
agent_json(['ok' => !empty($res['ok'])] + $res);
|
|
|
|
case 'migrations.apply_pending':
|
|
$applied = [];
|
|
$error = null;
|
|
foreach (migrations_status($pdo) as $row) {
|
|
if ($row['applied_at']) continue;
|
|
$r = migrations_apply_file($pdo, $row['filename'], null);
|
|
if (!empty($r['ok'])) {
|
|
$applied[] = $row['filename'];
|
|
} else {
|
|
$error = $row['filename'] . ': ' . ($r['error'] ?? 'erro');
|
|
break; // stop on first failure — manual intervention needed
|
|
}
|
|
}
|
|
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
|
|
agent_json(['ok' => $error === null, 'applied' => $applied, 'error' => $error]);
|
|
|
|
// Fase 3: 'backup.trigger', 'backup.list', 'backup.fetch' (signed download URL)
|
|
default:
|
|
agent_json(['error' => 'Ação desconhecida: ' . $action], 400);
|
|
}
|
|
}
|