- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de segurança da frota (o gancho que o login já tinha passa a funcionar) e a correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele. - config/functions_audit.php: audit() sobre platform_audit(); tabela audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação. - Registado: login, falha de login, bloqueio por excesso de tentativas, logout, pedidos de recuperação, mudança/definição de palavra-passe, registo e criação de utilizadores, convites, perfil (antes/depois), todas as definições (db_update_setting, com valor anterior), cada passo da lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens. - Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
12 KiB
PHP
227 lines
12 KiB
PHP
<?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', '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);
|
|
}
|