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