Files
fitme3070.pt/shared/platform-tools/functions_agent.php
T
andreandClaude Opus 5 4d40adbf64 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>
2026-09-13 23:06:23 +01:00

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);
}
}