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', 'update.run', 'update.status', 'ip_bans.list', 'ip_bans.ban', 'ip_bans.unban', 'tracking.summary'], true)) { hub_json(['ok' => false, 'error' => 'plataforma sem base de dados — não há migrações a aplicar'], 400); } // Auditoria: o que o hub mandou fazer aqui fica escrito, com o hub como autor. // Só as ações que mudam alguma coisa; as leituras (list, status) não. if ($pdo instanceof PDO && function_exists('platform_audit') && in_array($action, ['backup.trigger', 'update.run', 'git.pull', 'migrations.apply_pending', 'ip_bans.ban', 'ip_bans.unban'], true)) { $params = is_array($req) ? $req : $_POST; unset($params['action']); platform_audit($pdo, 'hub.' . $action, ['actor' => ['id' => null, 'name' => 'hub', 'role' => 'hub'], 'target_type' => 'platform', 'changes' => $params ?: null]); } 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. Os submódulos vão // pelo git_sync_submodules(), que respeita os que seguem o branch. $sub = git_sync_submodules($root); $output .= "\n\n[submodules]\n" . implode("\n", $sub['log']); 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]); // ── Atualização completa ───────────────────────────────────────────────── // O mesmo caminho do webhook de deploy (auto_update_run): cópia antes de // atualizar se estiver ligada, pull que pára em trabalho local, migrações // pendentes e caches. É o que o botão do hub deve fazer — o git.pull acima // fica para quem quer só o código. // // Responde já e trabalha depois de fechar a ligação: a cópia prévia pode // demorar minutos e o hub só espera MONITOR_OPS_TIMEOUT_SECONDS. O // resultado lê-se a seguir com update.status. case 'update.run': require_once __DIR__ . '/../functions_auto_update.php'; hub_load_backup_helpers(); 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); } @session_write_close(); platform_respond_and_continue(202, [ 'ok' => true, 'status' => 'accepted', 'note' => 'Atualização iniciada. O resultado fica em update.status daqui a instantes.', ]); auto_update_run($pdo, $root, null); exit; case 'update.status': require_once __DIR__ . '/../functions_auto_update.php'; hub_json(['ok' => true, 'last' => auto_update_last($pdo)]); // ── Bloqueio de IP ─────────────────────────────────────────────────────── // A mesma lista e as mesmas acções da página ip_bans.php deste site, para o // hub mostrar numa aba. Um banimento feito daqui fica com source='hub' e, // como qualquer outro, segue para a frota. case 'ip_bans.list': require_once __DIR__ . '/../functions_ip_ban.php'; hub_json(['ok' => true, 'bans' => platform_ip_ban_list($pdo, 200), 'offenders' => platform_ip_offenders($pdo, 7, 30)]); case 'ip_bans.ban': case 'ip_bans.unban': require_once __DIR__ . '/../functions_ip_ban.php'; $ip = trim((string) (is_array($req) ? ($req['ip'] ?? '') : '')); if (!filter_var($ip, FILTER_VALIDATE_IP)) { hub_json(['ok' => false, 'error' => 'endereço IP inválido'], 400); } if ($action === 'ip_bans.unban') { hub_json(['ok' => platform_ip_unban($pdo, $ip), 'ip' => $ip]); } if (platform_ip_ban_exempt($ip)) { hub_json(['ok' => false, 'error' => 'endereços internos não podem ser banidos'], 400); } // `minutes: null` é permanente; ausente é um dia. $minutes = array_key_exists('minutes', $req) && $req['minutes'] === null ? null : max(1, (int) ($req['minutes'] ?? 1440)); $reason = trim((string) ($req['reason'] ?? '')) ?: 'banido a partir do hub'; $ok = platform_ip_ban($pdo, $ip, $reason, $minutes, 'hub', null); hub_json(['ok' => $ok, 'ip' => $ip, 'minutes' => $minutes], $ok ? 200 : 500); // ── Origem das visitas ─────────────────────────────────────────────────── // Só agregados — origens, formulários, países, dispositivos. Nenhuma visita // individual sai daqui, nem o hash do IP. case 'tracking.summary': require_once __DIR__ . '/../functions_tracking.php'; $days = (int) (is_array($req) ? ($req['days'] ?? 30) : 30); if (!in_array($days, [7, 30, 90, 365], true)) $days = 30; platform_tracking_ensure_tables($pdo); hub_json([ 'ok' => true, 'days' => $days, 'sources' => array_slice(platform_tracking_report($pdo, $days), 0, 25), 'forms' => platform_tracking_conversions_by_form($pdo, $days), 'countries' => platform_tracking_breakdown($pdo, 'country', $days, 10), 'devices' => platform_tracking_breakdown($pdo, 'device_type', $days, 6), ]); // ── 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); }