Files
fitme3070.pt/shared/platform-tools/platform_update.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- 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>
2026-09-25 07:28:13 +01:00

2210 lines
125 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ─── Shared platform_update view ─────────────────────────────────────────────
//
// This file is loaded via include from each platform's
// views/sysadmin/platform_update.php stub. The stub is responsible for:
// 1. Loading the host's DB bootstrap (so $pdo is in scope)
// 2. Running the auth guard (require_sysadmin() or equivalent)
// 3. Setting $repo_root (absolute path to the host repo)
// 4. Optionally setting these override variables BEFORE the include:
// $platform_layout_header — absolute path to header include (default: $repo_root/includes/header.php)
// $platform_layout_navbar — absolute path to navbar include (default: $repo_root/includes/navbar.php)
// $platform_layout_footer — absolute path to footer include (default: $repo_root/includes/footer.php)
// $platform_dashboard_url — URL for the "Voltar" button (default: base_url('views/admin/dashboard.php') if available)
// $platform_error_log_file — absolute path to PHP error JSONL log (default: ERROR_LOG_FILE constant if defined, else null → card hidden)
// $GLOBALS['MIGRATIONS_DIR'] — override migrations dir (default: $repo_root/database)
//
// Optional integrations the page picks up if defined in the host:
// settings_cache_clear() — called after pull/migrations to invalidate settings cache
// base_url($path) — used to build the dashboard link
// env($key) — used by functions_git.php for HTTP credentials
require_once __DIR__ . '/functions_git.php';
require_once __DIR__ . '/functions_migrations.php';
require_once __DIR__ . '/functions_schema.php';
require_once __DIR__ . '/functions_composer.php';
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_changelog.php';
require_once __DIR__ . '/functions_auto_update.php';
/**
* Read .gitmodules and probe each submodule's state.
*
* Returns array of:
* name — submodule name from [submodule "..."]
* path — relative to repo root
* url — remote URL
* branch — branch tracked for `submodule update --remote`
* initialized — bool; false if working tree is empty
* pinned_sha — SHA recorded in the parent's index (gitlink)
* wt_sha — SHA currently checked out inside the submodule
* remote_sha — SHA of origin/<branch> after the last fetch (empty if unfetched)
* pinned_info — "%cr|%s" of pinned commit (relative time + subject)
* remote_info — "%cr|%s" of remote head commit
* behind — int; commits between pinned and remote (0 = up to date)
* wt_dirty — bool; working tree of the submodule no longer at pinned (rare)
*/
function platform_submodules(string $repo_root): array {
$gm = $repo_root . '/.gitmodules';
if (!is_file($gm)) return [];
$raw = @file_get_contents($gm);
if ($raw === false) return [];
// Parse `[submodule "name"]` blocks manually — parse_ini_file chokes on
// section names with quotes/spaces.
$sections = [];
$current = null;
foreach (preg_split('/\R/', $raw) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
if (preg_match('/^\[submodule\s+"(.+)"\]$/', $line, $m)) {
$current = $m[1];
$sections[$current] = [];
continue;
}
if ($current && preg_match('/^([a-z]+)\s*=\s*(.+)$/i', $line, $m)) {
$sections[$current][strtolower($m[1])] = trim($m[2]);
}
}
$subs = [];
foreach ($sections as $name => $vals) {
$path = $vals['path'] ?? $name;
$url = $vals['url'] ?? '';
$branch = $vals['branch'] ?? '';
$full = $repo_root . '/' . $path;
$entry = [
'name' => $name, 'path' => $path, 'url' => $url,
'branch' => $branch ?: 'main',
'initialized' => false,
'pinned_sha' => '', 'wt_sha' => '', 'remote_sha' => '',
'pinned_info' => '', 'remote_info' => '',
'behind' => 0, 'wt_dirty' => false,
];
// A submodule is "initialized" when it has a .git entry (file or dir).
if (!file_exists($full . '/.git')) {
$subs[] = $entry;
continue;
}
$entry['initialized'] = true;
// Pinned SHA from the parent's HEAD tree (the gitlink).
$ls = git_run(['ls-tree', 'HEAD', $path], $repo_root);
if ($ls['ok'] && preg_match('/^\d{6}\s+commit\s+([0-9a-f]{40})\s/', $ls['output'], $m)) {
$entry['pinned_sha'] = $m[1];
}
// Working-tree SHA.
$rp = git_run(['rev-parse', 'HEAD'], $full);
if ($rp['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rp['output']))) {
$entry['wt_sha'] = trim($rp['output']);
}
// Resolve the branch to track. If .gitmodules didn't say, fall back to
// origin/HEAD's symbolic ref (set by `git remote set-head`), else "main".
if (!$branch) {
$sh = git_run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], $full);
if ($sh['ok'] && $sh['output'] !== '') {
$b = trim($sh['output']);
$entry['branch'] = str_starts_with($b, 'origin/') ? substr($b, 7) : $b;
}
}
// Remote SHA (after fetch). Empty if origin/<branch> doesn't exist yet.
$rs = git_run(['rev-parse', '--verify', '--quiet', 'origin/' . $entry['branch']], $full);
if ($rs['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rs['output']))) {
$entry['remote_sha'] = trim($rs['output']);
}
// Commit details.
if ($entry['pinned_sha']) {
$li = git_run(['log', '-1', '--format=%cr|%s', $entry['pinned_sha']], $full);
if ($li['ok']) $entry['pinned_info'] = $li['output'];
}
if ($entry['remote_sha']) {
$li = git_run(['log', '-1', '--format=%cr|%s', $entry['remote_sha']], $full);
if ($li['ok']) $entry['remote_info'] = $li['output'];
}
// Commits between pinned and remote.
if ($entry['pinned_sha'] && $entry['remote_sha'] && $entry['pinned_sha'] !== $entry['remote_sha']) {
$rc = git_run(['rev-list', '--count', $entry['pinned_sha'] . '..' . $entry['remote_sha']], $full);
if ($rc['ok']) $entry['behind'] = (int) trim($rc['output']);
}
$entry['wt_dirty'] = $entry['pinned_sha'] && $entry['wt_sha']
&& $entry['pinned_sha'] !== $entry['wt_sha'];
$subs[] = $entry;
}
return $subs;
}
@set_time_limit(0);
if (!isset($repo_root) || !is_string($repo_root)) {
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
}
$repo_root = rtrim($repo_root, '/');
// Tools that shell out (git, composer) need the proc_* family; shared hosts
// often kill it via disable_functions. When missing, those tools are hidden
// from the UI and their POSTs refused — migrations, esquema, OPcache e log de
// erros continuam totalmente funcionais.
$can_exec = platform_proc_open_available();
$git_available = $can_exec && file_exists($repo_root . '/.git');
$action_output = null;
$action_label = null;
$action_ok = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$uid = (int) ($_SESSION['user_id'] ?? ($_SESSION['user']['id'] ?? 0)) ?: null;
// Every action on this page mutates server state (reset --hard, composer
// update, migrations…) — none may run off a cross-site form post.
if (!platform_csrf_ok()) {
$action = '';
$action_label = 'Pedido rejeitado';
$action_ok = false;
$action_output = 'Token CSRF ausente ou inválido. Recarrega a página e repete a ação.';
}
// Defense-in-depth: a UI esconde estas ferramentas quando a capacidade
// falta, mas um POST direto tem de ser recusado na mesma — nunca fatal.
$git_actions = ['fetch', 'pull', 'sync_submodules', 'force_sync', 'discard_local',
'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push',
'reset_upstream', 'submodule_fetch', 'submodule_update', 'switch_branch',
'auto_update_run', 'restore_point_revert'];
if ($action !== '' && in_array($action, $git_actions, true) && !$git_available) {
$action_label = 'Ação indisponível';
$action_ok = false;
$action_output = $can_exec
? 'Esta pasta não é um repositório git — inicializa com git init + remote.'
: 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.';
$action = '';
}
if (in_array($action, ['composer_install', 'composer_update', 'composer_check', 'composer_update_pkg'], true) && !$can_exec) {
$action_label = 'Ação indisponível';
$action_ok = false;
$action_output = 'proc_open() está desativado no PHP deste servidor (disable_functions) — o composer via web não está disponível.';
$action = '';
}
switch ($action) {
case 'fetch':
$res = git_run(['fetch', '--all', '--prune'], $repo_root);
$action_label = 'git fetch';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)');
break;
case 'pull':
// Robust, submodule-aware, mode-bit-tolerant update. Hardens
// core.fileMode first, tries a fast-forward, and only force-syncs
// when the divergence is provably nothing but mode/submodule drift.
// Real edits/commits are never discarded — it stops and reports them.
//
// O backup pré-atualização corre primeiro e é bloqueante: se estiver
// ligado, ou existe ponto de restauro ou não se mexe em nada. É o
// mesmo caminho que o webhook percorre.
$action_label = 'Atualizar plataforma';
$bk = platform_pre_update_backup($pdo, $repo_root);
if (!$bk['ok']) {
$action_ok = false;
$action_output = implode("\n", $bk['log'])
. "\n\nNada foi atualizado.";
break;
}
$before_sha = update_head_sha($repo_root);
$res = git_safe_update($repo_root);
$action_ok = $res['ok'];
$action_output = ($bk['log'] ? implode("\n", $bk['log']) . "\n\n" : '')
. ($res['output'] ?: '(sem saída)');
// Ponto de restauro só quando HEAD mexeu mesmo — ver
// update_restore_point_store(). Migrations aqui são um passo à parte
// (têm botões próprios), por isso a lista vai vazia.
$after_sha = update_head_sha($repo_root);
if ($res['ok'] && $before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) {
update_restore_point_store($pdo, $repo_root, $before_sha, [], $bk['created']);
$action_output .= "\n\nPonto de restauro guardado: " . substr($before_sha, 0, 8)
. ' — podes voltar atrás no cartão "Ponto de restauro".';
}
// Invalidate bytecode cache so updated PHP files take effect
// immediately (only matters when opcache.validate_timestamps=0).
if ($res['ok'] && function_exists('opcache_reset')) {
@opcache_reset();
$action_output .= "\n\n(OPcache invalidado)";
}
// Settings cache (DSA/fotografia-style): drop so a setting that
// changed upstream is read fresh from the DB on next request.
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'sync_submodules':
// Normalize the mode bit everywhere, then realign every submodule
// working tree to the SHA pinned by the parent (discarding only mode
// drift). Safe no-op when there are no submodules.
$hard = git_harden_repo($repo_root);
$sync = git_sync_submodules($repo_root);
$action_label = 'Sincronizar submódulos';
$action_ok = $sync['ok'];
$action_output = implode("\n", array_merge(
['── Normalização (core.fileMode) ──'],
$hard['log'],
['', '── Submódulos ──'],
$sync['log']
));
if ($sync['ok'] && function_exists('opcache_reset')) @opcache_reset();
if ($sync['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'force_sync':
// Destructive escape hatch: discard local state and hard-reset to
// origin/<branch>. Gated behind an explicit confirmation checkbox so
// it can never run by accident.
if (empty($_POST['confirm_force'])) {
$action_label = 'Sincronização forçada';
$action_ok = false;
$action_output = 'Recusado: marca "Confirmo descartar o estado local" para alinhar à força com o remoto.';
break;
}
$res = git_force_sync($repo_root);
$action_label = 'Sincronização forçada com o remoto';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: '(sem saída)';
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'switch_branch':
$target = trim((string) ($_POST['branch'] ?? ''));
if ($target === '' || !preg_match('#^[a-zA-Z0-9._/-]+$#', $target)) {
$action_label = 'Mudar de ramo';
$action_ok = false;
$action_output = 'Nome de ramo inválido.';
break;
}
$fetch = git_run(['fetch', 'origin', $target], $repo_root);
if (!$fetch['ok']) {
$action_label = 'Mudar de ramo: ' . $target;
$action_ok = false;
$action_output = 'fetch falhou: ' . trim((string) $fetch['output']);
break;
}
$checkout = git_run(['checkout', '-B', $target, 'origin/' . $target], $repo_root);
if (!$checkout['ok']) {
$action_label = 'Mudar de ramo: ' . $target;
$action_ok = false;
$action_output = 'checkout falhou: ' . trim((string) $checkout['output']);
break;
}
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root);
$action_label = 'Mudar de ramo: ' . $target;
$action_ok = $sub['ok'];
$action_output = 'checkout ' . $target . ': ok' . PHP_EOL
. ($sub['ok'] ? 'submódulos: ok' : 'submódulos falharam: ' . trim((string) $sub['output']));
if ($sub['ok'] && function_exists('opcache_reset')) @opcache_reset();
if ($sub['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'composer_check':
// Network call -- only ever on explicit request, never on page load.
$out = composer_outdated($repo_root, true);
$_SESSION['platform_composer_outdated'] = $out + ['checked_at' => time()];
$action_label = 'composer outdated --direct';
$action_ok = $out['ok'];
if (!$out['ok']) {
$action_output = $out['error'];
} elseif (!$out['packages']) {
$action_output = $out['note'] ?? 'Todas as dependências diretas estão atualizadas.';
} else {
$lines = [];
foreach ($out['packages'] as $pk) {
$lines[] = sprintf('%-34s %-12s -> %-12s %s', $pk['name'], $pk['version'], $pk['latest'],
$pk['updatable']
? '(atualizável agora)'
: '(bloqueado pela restrição ' . ($pk['constraint'] ?: '?') . ')');
}
$action_output = implode("\n", $lines);
}
break;
case 'composer_update_pkg':
// The package name arrives from a POST. Validate it against what
// composer.lock actually contains before it becomes a shell
// argument -- never hand a request-supplied string to composer.
$want = (string) ($_POST['package'] ?? '');
$known = composer_known_packages($repo_root);
if ($want === '' || !in_array($want, $known, true)) {
$action_label = 'Atualizar pacote';
$action_ok = false;
$action_output = 'Pacote desconhecido: ' . $want;
break;
}
$res = composer_update_packages([$want], $repo_root);
$action_label = 'composer update ' . $want . ' --with-dependencies';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: '(sem saída)';
// The cached list is now stale for this package -- drop it so the
// page does not keep offering an update that already happened.
unset($_SESSION['platform_composer_outdated']);
break;
case 'composer_install':
// Rebuild vendor/ from composer.json + composer.lock (reproducible).
$res = composer_run(['install', '--no-dev', '--optimize-autoloader'], $repo_root);
$action_label = 'composer install --no-dev --optimize-autoloader';
$action_ok = $res['ok'];
$action_output = $res['output'];
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
break;
case 'composer_update':
// Resolve newer versions within the constraints + rewrite composer.lock.
unset($_SESSION['platform_composer_outdated']);
$res = composer_run(['update', '--no-dev', '--optimize-autoloader'], $repo_root);
$action_label = 'composer update --no-dev --optimize-autoloader';
$action_ok = $res['ok'];
$action_output = $res['output'];
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
break;
case 'discard_local':
// `git reset --hard HEAD` forcefully realigns the working tree
// with HEAD for all tracked files. Stronger than `git restore .`,
// which can silently no-op on diffs caused by CRLF normalization
// or filesystem-mode quirks visible to PHP-FPM but not the CLI
// shell user. Gitignored content (.env, config/cache, uploads/…)
// is left alone.
$res = git_run(['reset', '--hard', 'HEAD'], $repo_root);
$action_label = 'Descartar alterações locais';
$action_ok = $res['ok'];
$combined = $res['output'];
// If a stubborn diff survives (e.g. CRLF mismatch with a permissive
// .gitattributes), force-extract every tracked file from the index.
if ($res['ok']) {
$check = git_run(['status', '--porcelain'], $repo_root);
if ($check['ok'] && $check['output'] !== '') {
$force = git_run(['checkout-index', '--force', '-a'], $repo_root);
$combined .= ($combined !== '' ? "\n" : '') . 'Estado ainda sujo após reset --hard — re-extracção forçada do index:';
if ($force['output'] !== '') $combined .= "\n" . $force['output'];
$action_ok = $force['ok'];
}
}
$action_output = $combined !== '' ? $combined : ($action_ok ? 'Alterações descartadas.' : '(sem saída)');
break;
case 'fix_filemode':
// Stop git from treating filesystem executable-bit differences as
// a diff. Common cause of "Descartar alterações locais" doing
// nothing — `git reset --hard` doesn't touch the local config, so
// the same phantom diff reappears immediately.
//
// Only per-repo config is written (main repo + every submodule via
// git_harden_repo). A `--global` write would be pointless here:
// git_run points GIT_CONFIG_GLOBAL at a throwaway temp file, so it
// would never persist.
$harden = git_harden_repo($repo_root);
$lines = $harden['log'];
$check = git_run(['status', '--porcelain'], $repo_root);
$lines[] = '';
$lines[] = 'git status --porcelain:';
$lines[] = $check['output'] !== '' ? $check['output'] : ' (working tree limpo)';
$action_label = 'Corrigir filemode';
$action_ok = $harden['ok'];
$action_output = implode("\n", $lines);
break;
case 'stash_push':
$msg = 'platform_update ' . date('Y-m-d H:i');
$res = git_run(['stash', 'push', '-u', '-m', $msg], $repo_root);
$action_label = 'git stash push';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? 'Alterações guardadas no stash.' : '(sem saída)');
break;
case 'stash_pop':
$idx = (string) ($_POST['stash_ref'] ?? 'stash@{0}');
if (!preg_match('/^stash@\{\d+\}$/', $idx)) $idx = 'stash@{0}';
$res = git_run(['stash', 'pop', $idx], $repo_root);
$action_label = 'git stash pop ' . $idx;
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? 'Stash aplicado.' : '(sem saída)');
break;
case 'stash_drop':
$idx = (string) ($_POST['stash_ref'] ?? '');
if (!preg_match('/^stash@\{\d+\}$/', $idx)) {
$action_label = 'git stash drop';
$action_ok = false;
$action_output = 'Referência de stash inválida.';
break;
}
$res = git_run(['stash', 'drop', $idx], $repo_root);
$action_label = 'git stash drop ' . $idx;
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? 'Stash removido.' : '(sem saída)');
break;
case 'push':
$force_protected = !empty($_POST['force_protected']);
$branch_now = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output']);
$is_protected = in_array(strtolower($branch_now), ['main', 'master'], true);
if ($is_protected && !$force_protected) {
$action_label = 'git push';
$action_ok = false;
$action_output = "Recusado: o branch atual ({$branch_now}) é protegido. Marca \"Confirmo push de branch protegido\" se realmente queres empurrar a partir do servidor.";
break;
}
$res = git_run(['push'], $repo_root);
$action_label = 'git push';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? 'Push concluído.' : '(sem saída)');
break;
case 'reset_upstream':
$res = git_run(['reset', '--hard', '@{u}'], $repo_root);
$action_label = 'git reset --hard @{u}';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? 'Working tree alinhado com o upstream.' : '(sem saída)');
if ($res['ok']) {
if (function_exists('opcache_reset')) @opcache_reset();
if (function_exists('settings_cache_clear')) settings_cache_clear();
$action_output .= "\n\n(OPcache + settings cache invalidados)";
}
break;
case 'reset_opcache':
if (function_exists('opcache_reset')) {
@opcache_reset();
$action_label = 'opcache_reset()';
$action_ok = true;
$action_output = 'OPcache invalidado.';
} else {
$action_label = 'opcache_reset()';
$action_ok = false;
$action_output = 'OPcache não está disponível neste servidor (extensão não instalada ou desativada).';
}
break;
case 'apply_migration':
$file = $_POST['filename'] ?? '';
$res = migrations_apply_file($pdo, $file, $uid);
$action_label = 'Aplicar migration: ' . $file;
$action_ok = $res['ok'];
$action_output = $res['ok']
? "OK — {$res['applied']}/{$res['total']} statements aplicados."
: ($res['error'] . (isset($res['applied']) ? "\n(falhou após {$res['applied']}/{$res['total']} statements)" : ''));
// Migrations may seed new rows into system_settings.
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'apply_pending':
$applied = []; $errors = [];
foreach (migrations_status($pdo) as $row) {
if ($row['applied_at']) continue;
$res = migrations_apply_file($pdo, $row['filename'], $uid);
if ($res['ok']) {
$applied[] = $row['filename'] . " ({$res['applied']}/{$res['total']})";
} else {
$errors[] = $row['filename'] . ': ' . $res['error'];
break; // stop on first failure — manual intervention needed
}
}
$action_label = 'Aplicar migrations pendentes';
$action_ok = empty($errors);
$lines = [];
if ($applied) $lines[] = "Aplicadas:\n " . implode("\n ", $applied);
if ($errors) $lines[] = "Erros:\n " . implode("\n ", $errors);
if (!$lines) $lines[] = 'Nada pendente.';
$action_output = implode("\n\n", $lines);
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'dump_schema':
// Structure-only, deterministic export to database/schema.sql. No git
// (nem proc_open) needed to produce it — funciona mesmo em alojamento
// partilhado onde as ferramentas git estão ocultas.
$res = schema_dump_to_file($pdo);
$action_label = 'Exportar esquema → ' . basename(schema_dump_path());
$action_ok = $res['ok'];
$action_output = $res['ok']
? sprintf(
"OK — %d tabela(s)%s escritas em:\n%s\n(%s)",
$res['tables'],
$res['views'] ? " + {$res['views']} view(s)" : '',
$res['path'],
number_format($res['bytes']) . ' bytes'
)
: $res['error'];
break;
case 'auto_update_toggle':
$on = !auto_update_enabled($pdo);
platform_settings_set($pdo, 'auto_update_enabled', $on ? '1' : '0', $uid);
$action_label = $on ? 'Auto-update ligado' : 'Auto-update desligado';
$action_ok = true;
$action_output = $on
? "Cada push para este ramo passa a atualizar a plataforma.\nConfirma que o webhook está criado no Gitea e que AUTO_UPDATE_SECRET está no .env."
: 'Os pedidos do webhook passam a ser respondidos com "desligado", sem tocar no código.';
break;
case 'auto_update_migrations_toggle':
$on = !auto_update_migrations_enabled($pdo);
platform_settings_set($pdo, 'auto_update_migrations', $on ? '1' : '0', $uid);
$action_label = $on ? 'Migrations automáticas ligadas' : 'Migrations automáticas desligadas';
$action_ok = true;
$action_output = $on
? 'Um deploy passa a aplicar as migrations que vieram no mesmo commit.'
: 'O deploy passa a trazer só código — as migrations ficam pendentes para aplicares aqui.';
break;
case 'restore_point_revert':
// O SHA vem das definições, nunca do pedido: um POST forjado não
// consegue apontar isto para lado nenhum.
$res = update_restore_point_apply($pdo, $repo_root, !empty($_POST['confirm_discard']));
$action_label = 'Reverter atualização';
$action_ok = $res['ok'];
$action_output = $res['output'];
break;
case 'restore_point_forget':
update_restore_point_clear($pdo);
$action_label = 'Ponto de restauro descartado';
$action_ok = true;
$action_output = 'A versão atual passa a ser o ponto de partida. '
. 'A próxima atualização volta a criar um ponto de restauro.';
break;
case 'update_backup_db_toggle':
case 'update_backup_files_toggle':
$which = $action === 'update_backup_db_toggle' ? 'db' : 'files';
$key = $which === 'db' ? 'update_backup_db' : 'update_backup_files';
$what = $which === 'db' ? 'da base de dados' : 'dos ficheiros (uploads)';
$on = !update_backup_settings($pdo)[$which];
platform_settings_set($pdo, $key, $on ? '1' : '0', $uid);
$action_label = 'Backup ' . $what . ' antes de atualizar: ' . ($on ? 'ligado' : 'desligado');
$action_ok = true;
$action_output = $on
? "Cada atualização — botão ou webhook — passa a esperar por este backup antes de tocar em código.\nSe o backup falhar ou demorar de mais, a atualização é cancelada e nada muda."
: 'As atualizações deixam de esperar por este backup.';
break;
case 'auto_update_run':
// O mesmo caminho que o webhook percorre, disparado à mão. É assim
// que se testa a configuração sem ter de fazer um push a sério.
$res = auto_update_run($pdo, $repo_root, null);
$action_label = 'Auto-update (execução manual)';
$action_ok = $res['ok'];
$action_output = $res['summary'] . "\n\n" . $res['output'];
break;
case 'mark_all_applied':
$n = migrations_mark_all_applied($pdo, $uid);
$action_label = 'Marcar todas como aplicadas';
$action_ok = true;
$action_output = $n > 0 ? "{$n} migration(s) marcada(s) como aplicada(s)." : 'Nenhuma migration nova para marcar.';
break;
case 'submodule_fetch':
$sub_path = (string) ($_POST['submodule_path'] ?? '');
$valid = git_submodule_paths($repo_root);
if (!in_array($sub_path, $valid, true)) {
$action_label = 'Fetch submódulo';
$action_ok = false;
$action_output = 'Caminho de submódulo inválido.';
break;
}
$full = $repo_root . '/' . $sub_path;
$res = git_run(['fetch', 'origin', '--prune'], $full);
$action_label = 'git fetch (submódulo: ' . $sub_path . ')';
$action_ok = $res['ok'];
$action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)');
break;
case 'submodule_update':
$sub_path = (string) ($_POST['submodule_path'] ?? '');
$valid = git_submodule_paths($repo_root);
if (!in_array($sub_path, $valid, true)) {
$action_label = 'Atualizar submódulo';
$action_ok = false;
$action_output = 'Caminho de submódulo inválido.';
break;
}
// 1. Move the gitlink in the working tree to the latest remote ref.
$r1 = git_run(['submodule', 'update', '--remote', $sub_path], $repo_root);
if (!$r1['ok']) {
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
$action_ok = false;
$action_output = $r1['output'] ?: '(sem saída)';
break;
}
$new_sha = trim(git_run(['rev-parse', 'HEAD'], $repo_root . '/' . $sub_path)['output']);
$short_sha = $new_sha ? substr($new_sha, 0, 7) : '???????';
// 2. Stage the gitlink change in the parent.
$r2 = git_run(['add', $sub_path], $repo_root);
if (!$r2['ok']) {
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
$action_ok = false;
$action_output = "Bump OK, mas git add falhou:\n" . ($r2['output'] ?: '(sem saída)');
break;
}
// 3. If nothing was actually staged, we were already at HEAD.
$diff = git_run(['diff', '--cached', '--quiet', '--', $sub_path], $repo_root);
if ($diff['ok']) {
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
$action_ok = true;
$action_output = 'Já estava no commit mais recente (' . $short_sha . '). Nada para committar.';
break;
}
// 4. Commit the bump. Inject identity flags so this works even
// when the server has no user.email/user.name configured.
$r3 = git_run([
'-c', 'user.email=platform-update@local',
'-c', 'user.name=Platform Update',
'commit', '-m', "Bump {$sub_path} to {$short_sha}"
], $repo_root);
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
$action_ok = $r3['ok'];
$action_output = ($r3['ok']
? "Submódulo {$sub_path} atualizado para {$short_sha}."
: 'Bump aplicado, mas o commit falhou:')
. ($r3['output'] !== '' ? "\n\n" . $r3['output'] : '');
// 5. Publish it in the same action, by default. A bump that stays
// local is exactly what makes the NEXT update (this sysadmin's or
// anyone else's) refuse to fast-forward: the parent repo now has a
// commit the remote doesn't, and platform_update's own divergence
// guard stops rather than guess how to reconcile it. Unticking
// "publicar" leaves the old two-step behaviour (bump, then the
// git push button below) for whoever wants to review first.
if ($r3['ok'] && !empty($_POST['also_push'])) {
$push = git_run(['push'], $repo_root);
$action_output .= "\n\n" . ($push['ok']
? "Publicado (git push)."
: "git push falhou — o bump ficou comitado localmente, publica-o com o botão git push abaixo:")
. ($push['output'] !== '' ? "\n\n" . $push['output'] : '');
$action_ok = $action_ok && $push['ok'];
} elseif ($r3['ok']) {
$action_output .= "\nCommit criado — usa o botão git push para publicar.";
}
if ($r3['ok'] && function_exists('opcache_reset')) @opcache_reset();
if ($r3['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
break;
case 'clear_error_log':
$logFile = $platform_error_log_file
?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null);
if ($logFile) {
@file_put_contents($logFile, '');
$action_label = 'Limpar log de erros';
$action_ok = true;
$action_output = 'Log de erros PHP limpo.';
} else {
$action_label = 'Limpar log de erros';
$action_ok = false;
$action_output = 'Sem ficheiro de log configurado (define ERROR_LOG_FILE ou $platform_error_log_file).';
}
break;
}
// Auditoria: cada ação desta página mexe no servidor. As que só leem
// (fetch, composer_check) ficam de fora — registá-las era só ruído.
if ($action !== '' && $action_label !== null && !in_array($action, ['fetch', 'composer_check'], true)
&& function_exists('platform_audit')) {
$post = $_POST; unset($post['action'], $post['_pt_csrf'], $post['csrf_token']);
platform_audit($pdo instanceof PDO ? $pdo : null, 'platform.' . $action, [
'target_type' => 'platform',
'summary' => $action_label . ' — ' . ($action_ok ? 'ok' : 'falhou'),
'changes' => $post ?: null,
]);
}
// ── Post/Redirect/Get ────────────────────────────────────────────────────
// Stash the result in the session and redirect to a GET of the same URL, so
// a browser refresh re-issues the GET instead of re-POSTing the action
// (no "Confirm form resubmission?" prompt, no accidental double-apply).
if ($action_label !== null) {
$_SESSION['platform_update_flash'] = [
'label' => $action_label,
'ok' => $action_ok,
'output' => $action_output,
];
}
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
exit;
}
// On the GET that follows a POST, surface the flash the action left behind.
if ($action_label === null && !empty($_SESSION['platform_update_flash'])) {
$flash = $_SESSION['platform_update_flash'];
unset($_SESSION['platform_update_flash']);
$action_label = $flash['label'] ?? null;
$action_ok = $flash['ok'] ?? null;
$action_output = $flash['output'] ?? null;
}
// ─── Gather state for the page ───────────────────────────────────────────────
$git_branch = $git_available ? git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output'] : '—';
$remote_branches = $git_available ? git_remote_branches($repo_root) : [];
$git_sha = $git_available ? git_run(['rev-parse', '--short', 'HEAD'], $repo_root)['output'] : '—';
$git_remote = $git_available ? git_run(['config', '--get', 'remote.origin.url'], $repo_root)['output'] : '';
$git_last = $git_available
? git_run(['log', '-1', '--format=%cd|%s', '--date=format:%Y-%m-%d %H:%M:%S'], $repo_root)['output']
: '';
[$last_date, $last_msg] = $git_last !== '' ? array_pad(explode('|', $git_last, 2), 2, '') : ['', ''];
// Harden BEFORE reading status: setting core.fileMode=false in the main repo and
// every submodule makes the phantom `old mode → new mode` diffs (and the
// resulting `modified: <submodule>`) disappear, so the status shown below
// reflects only real changes. Idempotent — must run on every load because
// core.fileMode is local config that doesn't travel in commits.
$filemode_normalized = null;
$filemode_log = [];
if ($git_available) {
$harden = git_harden_repo($repo_root);
$filemode_normalized = $harden['normalized'];
$filemode_log = $harden['log'];
}
$git_dirty = false;
$git_dirty_files = '';
if ($git_available) {
$status = git_run(['status', '--porcelain'], $repo_root);
$git_dirty_files = $status['ok'] ? $status['output'] : '';
$git_dirty = $git_dirty_files !== '';
}
$git_ahead = 0; $git_behind = 0;
if ($git_available) {
$rev = git_run(['rev-list', '--left-right', '--count', '@{u}...HEAD'], $repo_root);
if ($rev['ok'] && preg_match('/^(\d+)\s+(\d+)$/', $rev['output'], $m)) {
$git_behind = (int) $m[1];
$git_ahead = (int) $m[2];
}
}
$git_stash_list = [];
if ($git_available) {
$sl = git_run(['stash', 'list', '--format=%gd|%s|%cr'], $repo_root);
if ($sl['ok'] && $sl['output'] !== '') {
foreach (explode("\n", $sl['output']) as $line) {
$parts = explode('|', $line, 3);
if (count($parts) >= 2) {
$git_stash_list[] = [
'ref' => $parts[0],
'desc' => $parts[1],
'when' => $parts[2] ?? '',
];
}
}
}
}
$git_log = '';
if ($git_available) {
$lg = git_run(
['log', '-n', '20', '--format=%h %cd %s', '--date=format:%Y-%m-%d %H:%M'],
$repo_root
);
if ($lg['ok']) $git_log = $lg['output'];
}
$git_diff = '';
if ($git_available && $git_dirty) {
$df = git_run(['diff', '--no-color'], $repo_root);
if ($df['ok']) $git_diff = $df['output'];
}
$git_protected_branch = in_array(strtolower((string) $git_branch), ['main', 'master'], true);
// Divergence that a plain fast-forward can't resolve: the remote has commits we
// don't, AND we have either local commits or uncommitted changes in the way.
// "Atualizar" handles the mode-only case automatically; this only gates the
// manual destructive escape hatch (force sync) for the real-divergence case.
$git_diverged = $git_available && $git_behind > 0 && ($git_ahead > 0 || $git_dirty);
$migrations = migrations_status($pdo);
$pending = array_filter($migrations, fn($r) => $r['applied_at'] === null);
$applied_n = count($migrations) - count($pending);
// Pendentes primeiro. A lista só cresce, e com dezenas de ficheiros aplicados a
// única linha que interessa — a que ainda não correu — acabava fora do ecrã.
// usort mantém a ordem por nome dentro de cada grupo (ordenação estável no PHP 8).
usort($migrations, fn($a, $b) => ($a['applied_at'] === null ? 0 : 1) <=> ($b['applied_at'] === null ? 0 : 1));
$submodules = $git_available ? platform_submodules($repo_root) : [];
// ─── Auto-update (webhook de deploy) ─────────────────────────────────────────
$au_on = auto_update_enabled($pdo);
$au_migrations = auto_update_migrations_enabled($pdo);
$au_secret_set = auto_update_secret() !== '';
$au_last = auto_update_last($pdo);
// O URL que se cola no Gitea. base_url() aponta para a docroot pública, que é
// exactamente onde vive o stub api/auto_update.php.
$au_hook_url = function_exists('base_url') ? base_url('api/auto_update.php') : '/api/auto_update.php';
$restore_point = update_restore_point($pdo);
// ─── Backup antes de atualizar ───────────────────────────────────────────────
$ub = update_backup_settings($pdo);
// O último backup de cada tipo serve de estimativa do custo: "ligar isto vai
// juntar 4 GB e uns minutos a cada deploy" é uma coisa que se deve ver ANTES de
// ligar, não descobrir no primeiro push.
$ub_last = ['db' => null, 'files' => null];
if (platform_backup_bootstrap($repo_root)) {
foreach (backup_list() as $b) {
if ($ub_last[$b['type']] === null) $ub_last[$b['type']] = $b;
}
}
$composer = composer_state($repo_root);
// Only probe the binary when the card will actually render (and when the
// server can spawn processes at all).
$composer_avail = ($composer['has_json'] && $can_exec) ? composer_available($repo_root) : null;
// Populated only by the explicit "Verificar atualizações" action -- composer
// outdated hits the network and must not run on every page load.
$composer_outdated = $_SESSION['platform_composer_outdated'] ?? null;
// ─── Error log (optional) ────────────────────────────────────────────────────
$error_log_file = $platform_error_log_file
?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null);
$error_log_entries = [];
$error_log_total = 0;
if ($error_log_file && is_readable($error_log_file)) {
$lines = file($error_log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$error_log_total = count($lines);
foreach (array_reverse(array_slice($lines, -200)) as $line) {
$entry = json_decode($line, true);
if (is_array($entry)) $error_log_entries[] = $entry;
}
}
// ─── Layout resolution ───────────────────────────────────────────────────────
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$dashboard_url = $platform_dashboard_url
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
$pageTitle = 'Atualizar plataforma';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<style>
/* Tabelas com altura limitada: a página nunca cresce sem limite, e o
cabeçalho fica fixo durante o scroll. */
.pt-scroll { max-height: 380px; overflow: auto; }
.pt-scroll thead th { position: sticky; top: 0; z-index: 1; }
/* Cabeçalho de card clicável (acordeões). */
.pt-toggle { cursor: pointer; }
.pt-toggle .pt-chevron { transition: transform .2s ease; }
.pt-toggle[aria-expanded="true"] .pt-chevron { transform: rotate(180deg); }
/* Cabeçalho de estado: um filete da marca chega para o separar dos cartões
de trabalho sem lhe dar o peso de mais uma caixa colorida. */
.pt-hero { border-top: 3px solid var(--am-photo-gold, var(--bs-primary)); }
/* Separadores. Em ecrã estreito rolam na horizontal: quatro etiquetas a
partir em duas ou três linhas ocupavam meia página antes do conteúdo. */
.pt-tabs { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
.pt-tabs::-webkit-scrollbar { display: none; }
.pt-tabs .nav-link { white-space: nowrap; }
</style>
<div class="container py-4" style="max-width:1200px;">
<?php
// ── Cabeçalho de estado ──────────────────────────────────────────────
// O que se vem cá saber (que versão está de pé, se está atualizada, se há
// trabalho local no caminho) e a ação principal, os dois no mesmo sítio.
// Antes era preciso descer até ao cartão Git para perceber o estado e para
// encontrar o botão.
$changelog = changelog_latest();
$hero_pills = [];
if ($git_available) {
if ($git_behind > 0) {
$hero_pills[] = ['warning text-dark', 'bi-cloud-arrow-down', $git_behind . ' por atualizar'];
} elseif (!$git_dirty && $git_ahead === 0) {
$hero_pills[] = ['success', 'bi-check2-circle', 'atualizado'];
}
if ($git_dirty) $hero_pills[] = ['danger', 'bi-pencil', 'alterações locais'];
if ($git_ahead > 0) $hero_pills[] = ['info', 'bi-cloud-arrow-up', $git_ahead . ' por enviar'];
}
if ($pending) $hero_pills[] = ['warning text-dark', 'bi-database', count($pending) . ' migration(s)'];
if ($au_on) $hero_pills[] = ['success', 'bi-arrow-repeat', 'auto-update'];
if ($ub['db'] || $ub['files']) $hero_pills[] = ['secondary', 'bi-shield-check', 'backup pré-update'];
?>
<div class="card shadow-sm mb-3 pt-hero">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="me-auto" style="min-width:15rem">
<div class="text-uppercase text-muted" style="font-size:.7rem;letter-spacing:.1em">Atualizar plataforma</div>
<div class="d-flex align-items-baseline gap-2 flex-wrap mt-1">
<span class="fs-4 fw-semibold">
<?= $changelog['version'] !== null ? htmlspecialchars($changelog['version']) : 'Versão não declarada' ?>
</span>
<?php if ($git_available && $git_branch): ?>
<code class="small"><?= htmlspecialchars($git_branch) ?></code>
<?php endif; ?>
<?php if ($git_sha): ?>
<span class="text-muted small font-monospace"><?= htmlspecialchars($git_sha) ?></span>
<?php endif; ?>
</div>
<?php if ($hero_pills): ?>
<div class="d-flex gap-2 flex-wrap mt-2">
<?php foreach ($hero_pills as [$cls, $icon, $text]): ?>
<span class="badge text-bg-<?= $cls ?>"><i class="bi <?= $icon ?> me-1"></i><?= htmlspecialchars($text) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<?php if ($git_available): ?>
<form method="POST" class="m-0" onsubmit="return confirm('Atualizar a plataforma?\n\n• normaliza core.fileMode (repo + submódulos)\n• git pull --ff-only\n• sincroniza submódulos\n\nNão destrutivo: se houver alterações locais reais, pára e mostra-as sem apagar nada.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="pull">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-down-circle me-1"></i>Atualizar
</button>
</form>
<?php endif; ?>
<?php if ($restore_point): ?>
<a href="#tab-atualizar" class="btn btn-outline-danger" onclick="ptShowTab('atualizar')">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reverter
</a>
<?php endif; ?>
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary">Voltar</a>
</div>
</div>
<?php if ($changelog['changes']): ?>
<div class="collapse" id="heroChanges">
<div class="card-body border-top pt-3">
<ul class="mb-0 small">
<?php foreach ($changelog['changes'] as $ch): ?>
<li><?= htmlspecialchars($ch) ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<div class="card-footer py-1 pt-toggle" data-bs-toggle="collapse" data-bs-target="#heroChanges"
role="button" aria-expanded="false" aria-controls="heroChanges">
<span class="small text-muted">
<i class="bi bi-chevron-down small pt-chevron me-1"></i>O que mudou nesta versão
</span>
</div>
<?php endif; ?>
</div>
<?php if (!$can_exec): ?>
<div class="alert alert-warning">
<strong>Execução de processos indisponível neste servidor.</strong>
O PHP tem <code>proc_open()</code> desativado (<code>disable_functions</code> no php.ini), por isso as
ferramentas git e composer estão ocultas. Migrations, esquema, OPcache e log de erros continuam disponíveis.
</div>
<?php elseif (!$git_available): ?>
<div class="alert alert-warning">
<strong>Esta pasta não é um repositório git.</strong>
Inicializa com <code>git init</code> + remote para o Gitea. A actualização via web só funciona quando existe um <code>.git</code>.
</div>
<?php endif; ?>
<?php if ($action_label !== null): ?>
<div class="alert alert-<?= $action_ok ? 'success' : 'danger' ?>">
<strong><?= htmlspecialchars($action_label) ?></strong>
<pre class="mb-0 mt-2 small" style="white-space:pre-wrap;max-height:280px;overflow:auto;"><?= htmlspecialchars($action_output ?? '') ?></pre>
</div>
<?php endif; ?>
<?php if (!empty($git_stash_list)): ?>
<!-- Persistent stash banner: stashed work is invisible until popped, so we surface it. -->
<div class="alert alert-warning">
<strong><i class="bi bi-archive me-1"></i>Stashes guardados (<?= count($git_stash_list) ?>):</strong>
<ul class="mb-2 mt-2 small">
<?php foreach ($git_stash_list as $st): ?>
<li class="d-flex flex-wrap gap-2 align-items-center">
<code><?= htmlspecialchars($st['ref']) ?></code>
<span><?= htmlspecialchars($st['desc']) ?></span>
<?php if ($st['when']): ?><span class="text-muted">(<?= htmlspecialchars($st['when']) ?>)</span><?php endif; ?>
<span class="ms-auto d-flex gap-1">
<form method="POST" class="m-0"
onsubmit="return confirm('Aplicar <?= htmlspecialchars(addslashes($st['ref'])) ?> ao working tree e remover do stash?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_pop">
<input type="hidden" name="stash_ref" value="<?= htmlspecialchars($st['ref']) ?>">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up"></i> Pop
</button>
</form>
<form method="POST" class="m-0"
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($st['ref'])) ?>? Esta acção não pode ser desfeita.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_drop">
<input type="hidden" name="stash_ref" value="<?= htmlspecialchars($st['ref']) ?>">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Apagar stash" aria-label="Apagar <?= htmlspecialchars($st['ref']) ?>">
<i class="bi bi-trash"></i>
</button>
</form>
</span>
</li>
<?php endforeach; ?>
</ul>
<div class="small mb-0">Pop aplica o stash ao working tree e remove-o; drop apaga sem aplicar.</div>
</div>
<?php endif; ?>
<?php
// Depois de um POST volta-se ao separador onde a ação foi pedida — cair
// sempre no primeiro obrigava a reencontrar o sítio a cada clique.
$tab_of_action = [
'atualizar' => ['fetch', 'pull', 'sync_submodules', 'force_sync', 'switch_branch', 'discard_local',
'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push', 'reset_upstream',
'submodule_fetch', 'submodule_update', 'restore_point_revert', 'restore_point_forget'],
'automacao' => ['auto_update_toggle', 'auto_update_migrations_toggle', 'auto_update_run',
'update_backup_db_toggle', 'update_backup_files_toggle'],
'dados' => ['apply_migration', 'apply_pending', 'mark_all_applied', 'dump_schema'],
'servidor' => ['composer_check', 'composer_install', 'composer_update', 'composer_update_pkg',
'reset_opcache', 'clear_error_log'],
];
$active_tab = 'atualizar';
$posted = (string) ($_POST['action'] ?? '');
foreach ($tab_of_action as $tab => $acts) {
if ($posted !== '' && in_array($posted, $acts, true)) { $active_tab = $tab; break; }
}
$tabs = [
'atualizar' => ['Atualizar', 'bi-arrow-down-circle', $git_available ? null : 'indisponível'],
'automacao' => ['Automação', 'bi-robot', $au_on ? 'on' : null],
'dados' => ['Base de dados', 'bi-database', $pending ? (string) count($pending) : null],
'servidor' => ['Servidor', 'bi-hdd-stack', $error_log_entries ? (string) count($error_log_entries) : null],
];
?>
<ul class="nav nav-tabs pt-tabs mb-3" role="tablist">
<?php foreach ($tabs as $id => [$label, $icon, $badge]): ?>
<li class="nav-item" role="presentation">
<button class="nav-link <?= $active_tab === $id ? 'active' : '' ?>" type="button"
id="tabbtn-<?= $id ?>" data-bs-toggle="tab" data-bs-target="#tab-<?= $id ?>"
role="tab" aria-controls="tab-<?= $id ?>"
aria-selected="<?= $active_tab === $id ? 'true' : 'false' ?>">
<i class="bi <?= $icon ?> me-1"></i><?= htmlspecialchars($label) ?>
<?php if ($badge !== null): ?>
<span class="badge text-bg-secondary ms-1"><?= htmlspecialchars($badge) ?></span>
<?php endif; ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="tab-atualizar" role="tabpanel" aria-labelledby="tabbtn-atualizar" tabindex="0">
<!-- ── Ponto de restauro ──────────────────────────────────────────── -->
<?php if ($restore_point): ?>
<div class="card shadow-sm mb-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-arrow-counterclockwise me-1"></i>Ponto de restauro</strong>
<span class="badge text-bg-warning text-dark">disponível</span>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
A última atualização partiu daqui. Voltar atrás põe o servidor exactamente no código que
estava antes — os commits novos ficam no remote e podem ser puxados outra vez a seguir.
</p>
<div class="small">
<div>
<span class="text-muted">Commit:</span>
<code><?= htmlspecialchars($restore_point['short']) ?></code>
<?php if ($restore_point['commit_at']): ?>
<span class="text-muted ms-1"><?= htmlspecialchars($restore_point['commit_at']) ?></span>
<?php endif; ?>
</div>
<?php if ($restore_point['subject']): ?>
<div class="text-muted text-truncate" title="<?= htmlspecialchars($restore_point['subject']) ?>">
<?= htmlspecialchars($restore_point['subject']) ?>
</div>
<?php endif; ?>
<?php if ($restore_point['updated_at']): ?>
<div class="text-muted mt-1">
Atualizado em <?= htmlspecialchars(date('Y-m-d H:i', strtotime($restore_point['updated_at'] . ' UTC'))) ?>
</div>
<?php endif; ?>
</div>
<?php if ($restore_point['migrations']): ?>
<div class="alert alert-warning small mt-3 mb-0 py-2">
<strong><i class="bi bi-database-exclamation me-1"></i>A base de dados não recua com o código.</strong>
Estas migrations correram nesta atualização e continuam aplicadas:
<ul class="mb-1 mt-1">
<?php foreach ($restore_point['migrations'] as $m): ?>
<li><code><?= htmlspecialchars($m) ?></code></li>
<?php endforeach; ?>
</ul>
<?php if ($restore_point['backups']): ?>
Backup tirado imediatamente antes:
<?php foreach ($restore_point['backups'] as $b): ?>
<code><?= htmlspecialchars($b) ?></code>
<?php endforeach; ?>
— restaura-o pela página <em>Restaurar base de dados</em> se o esquema novo for o problema.
<?php else: ?>
Não há backup pré-atualização deste deploy; liga-o no separador <em>Automação</em> para o próximo.
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
<form method="POST" class="m-0 d-flex gap-2 flex-wrap align-items-center"
onsubmit="return confirm('Voltar ao commit <?= htmlspecialchars(addslashes($restore_point['short'])) ?>?\n\nO código do servidor recua. As migrations já aplicadas NÃO são desfeitas.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="restore_point_revert">
<button type="submit" class="btn btn-warning btn-sm">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reverter para <?= htmlspecialchars($restore_point['short']) ?>
</button>
<?php // Sem isto, um reset --hard apagava alterações por comitar — e quem
// está a depurar um deploy mau é precisamente quem pode ter editado
// um ficheiro no servidor. ?>
<div class="form-check form-check-inline m-0 small">
<input class="form-check-input" type="checkbox" name="confirm_discard" id="rpDiscard" value="1">
<label class="form-check-label text-muted" for="rpDiscard">Descartar alterações locais</label>
</div>
</form>
<form method="POST" class="m-0 ms-auto"
onsubmit="return confirm('Descartar o ponto de restauro? A versão atual passa a ser o ponto de partida.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="restore_point_forget">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-check2 me-1"></i>Está bem assim
</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- ── Git status & pull (só quando o servidor consegue correr git) ── -->
<?php if ($git_available): ?>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-git me-1"></i>Git</strong>
<?php if ($filemode_normalized !== null): ?>
<span class="small" title="<?= htmlspecialchars(implode("\n", $filemode_log)) ?>">
<?php if ($filemode_normalized): ?>
<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>core.fileMode normalizado</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>core.fileMode não normalizado</span>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
<div class="card-body">
<div class="row g-3 small">
<div class="col-md-6">
<div><span class="text-muted">Branch:</span> <code><?= htmlspecialchars($git_branch ?: '—') ?></code></div>
<form method="POST" class="mt-2 d-flex gap-2 flex-wrap"
onsubmit="return confirm('Mudar para o ramo selecionado? Faz fetch + checkout + sync de submódulos.');">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="switch_branch">
<select name="branch" class="form-select form-select-sm" style="width:auto;min-width:170px;">
<option value="">— Mudar de ramo —</option>
<?php foreach ($remote_branches as $rb): ?>
<option value="<?= htmlspecialchars($rb) ?>" <?= $rb === $git_branch ? 'selected' : '' ?>><?= htmlspecialchars($rb) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-arrow-left-right me-1"></i>Mudar</button>
</form>
<div><span class="text-muted">Commit actual:</span> <code><?= htmlspecialchars($git_sha ?: '—') ?></code></div>
<?php if ($git_remote): ?>
<div><span class="text-muted">Remote:</span> <code><?= htmlspecialchars($git_remote) ?></code></div>
<?php endif; ?>
</div>
<div class="col-md-6">
<?php if ($last_date): ?>
<div><span class="text-muted">Último commit:</span> <?= htmlspecialchars($last_date) ?></div>
<div class="text-muted text-truncate" title="<?= htmlspecialchars($last_msg) ?>"><?= htmlspecialchars($last_msg) ?></div>
<?php endif; ?>
<?php if ($git_available && ($git_ahead || $git_behind)): ?>
<div class="mt-2">
<?php if ($git_behind): ?>
<span class="badge text-bg-warning"><?= $git_behind ?> commit(s) por puxar</span>
<?php endif; ?>
<?php if ($git_ahead): ?>
<span class="badge text-bg-info"><?= $git_ahead ?> commit(s) local não-empurrado(s)</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($git_log !== ''): ?>
<div class="mt-3">
<a class="small text-muted text-decoration-none" data-bs-toggle="collapse" href="#gitRecentLog" role="button">
<i class="bi bi-clock-history me-1"></i>Últimos 20 commits
</a>
<div class="collapse mt-2" id="gitRecentLog">
<pre class="mb-0 small bg-body-tertiary p-2 rounded" style="white-space:pre-wrap;max-height:280px;overflow:auto;font-size:.75rem;"><?= htmlspecialchars($git_log) ?></pre>
</div>
</div>
<?php endif; ?>
<?php if ($git_dirty): ?>
<div class="alert alert-warning mt-3 mb-0 small">
<strong>Atenção:</strong> há alterações locais não-comitidas. <code>git pull --ff-only</code> vai falhar até que sejam comitidas ou descartadas.
<pre class="mb-0 mt-2" style="white-space:pre-wrap;font-size:.75rem;max-height:140px;overflow:auto;"><?= htmlspecialchars($git_dirty_files) ?></pre>
<div class="mt-2 d-flex gap-2 flex-wrap">
<form method="POST" class="m-0"
onsubmit="return confirm('Guardar TODAS as alterações locais num stash? Podes recuperá-las depois com Pop. Inclui ficheiros não-rastreados.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_push">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-archive me-1"></i>Guardar em stash
</button>
</form>
<?php if ($git_diff !== ''): ?>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal" data-bs-target="#gitDiffModal">
<i class="bi bi-file-diff me-1"></i>Ver diff
</button>
<?php endif; ?>
<form method="POST" class="m-0"
onsubmit="return confirm('Executar git config core.filemode false no repo principal e em todos os submódulos?\n\nDeve resolver diferenças locais causadas por bits de execução (chmod) vistos pelo PHP-FPM mas não pela shell. Não destrutivo.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="fix_filemode">
<button type="submit" class="btn btn-sm btn-outline-info">
<i class="bi bi-wrench-adjustable me-1"></i>Corrigir filemode
</button>
</form>
<form method="POST" class="m-0"
onsubmit="return confirm('Descartar TODAS as alterações locais? Os ficheiros listados acima voltam ao estado do último commit. Ficheiros gitignored (.env, uploads, cache) não são tocados.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="discard_local">
<button type="submit" class="btn btn-sm btn-outline-warning">
<i class="bi bi-arrow-counterclockwise me-1"></i>Descartar alterações locais
</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<div class="card-footer d-flex gap-2 flex-wrap">
<form method="POST" class="m-0" onsubmit="return confirm('Atualizar a plataforma?\n\n• normaliza core.fileMode (repo + submódulos)\n• git pull --ff-only\n• sincroniza submódulos\n\nNão destrutivo: se houver alterações locais reais, pára e mostra-as sem apagar nada.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="pull">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-arrow-down-circle me-1"></i>Atualizar (git pull)
</button>
</form>
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="fetch">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-cloud-download me-1"></i>Verificar atualizações (fetch)
</button>
</form>
<?php if ($git_ahead > 0): ?>
<form method="POST" class="m-0 d-flex gap-2 align-items-center"
onsubmit="return confirm('Executar git push? Vai enviar <?= $git_ahead ?> commit(s) locais para o remote.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="push">
<?php if ($git_protected_branch): ?>
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" name="force_protected" value="1" id="pushProtectedAck">
<label class="form-check-label small text-warning" for="pushProtectedAck">
Confirmo push de branch protegido (<?= htmlspecialchars($git_branch) ?>)
</label>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-up-circle me-1"></i>git push (<?= $git_ahead ?>)
</button>
</form>
<?php endif; ?>
<?php if ($git_diverged): ?>
<!-- Real divergence the fast-forward can't resolve. "Atualizar" already
handles the mode-only case automatically; this is the explicit,
destructive path for when there ARE local commits/edits in the way. -->
<form method="POST" class="m-0 d-flex gap-2 align-items-center ms-auto"
onsubmit="return confirm('SINCRONIZAÇÃO FORÇADA\n\ngit reset --hard origin/<?= htmlspecialchars(addslashes((string) $git_branch)) ?>\n\nDescarta TODOS os commits/alterações locais rastreados e alinha com o remoto. Esta ação NÃO pode ser desfeita. Continuar?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="force_sync">
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" name="confirm_force" value="1" id="forceSyncAck">
<label class="form-check-label small text-danger" for="forceSyncAck">
Confirmo descartar o estado local
</label>
</div>
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-cloud-download me-1"></i>Sincronização forçada (reset --hard)
</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- ── Submódulos ─────────────────────────────────────────────────── -->
<?php if (!empty($submodules)): ?>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-diagram-3 me-1"></i>Submódulos</strong>
<span class="small text-muted">
<?= count($submodules) ?>
<?php $behind_total = array_sum(array_column($submodules, 'behind')); ?>
<?php if ($behind_total > 0): ?>
· <span class="text-warning"><?= $behind_total ?> commit(s) por puxar</span>
<?php else: ?>
· <span class="text-success">tudo atualizado</span>
<?php endif; ?>
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive pt-scroll">
<table class="table table-sm align-middle mb-0">
<thead class="table-dark">
<tr>
<th>Submódulo</th>
<th>Pinned</th>
<th>Remoto (<code>origin/&lt;branch&gt;</code>)</th>
<th style="width:140px;">Estado</th>
<th style="width:300px;" class="text-end">Ações</th>
</tr>
</thead>
<tbody>
<?php foreach ($submodules as $s): ?>
<tr>
<td>
<div class="font-monospace small"><?= htmlspecialchars($s['path']) ?></div>
<div class="text-muted" style="font-size:.72rem;">
<code><?= htmlspecialchars($s['branch']) ?></code>
<?php if ($s['url']): ?>
· <span title="<?= htmlspecialchars($s['url']) ?>"><?= htmlspecialchars(parse_url($s['url'], PHP_URL_HOST) ?: '') ?></span>
<?php endif; ?>
</div>
</td>
<?php if (!$s['initialized']): ?>
<td colspan="3" class="text-muted small">
Não inicializado. Corre <code>git submodule update --init <?= htmlspecialchars($s['path']) ?></code> no servidor.
</td>
<?php else: ?>
<td>
<code class="small"><?= htmlspecialchars(substr($s['pinned_sha'], 0, 7) ?: '—') ?></code>
<?php if ($s['pinned_info']):
[$p_when, $p_msg] = array_pad(explode('|', $s['pinned_info'], 2), 2, ''); ?>
<div class="text-muted text-truncate" style="font-size:.72rem;max-width:240px;" title="<?= htmlspecialchars($p_msg) ?>">
<?= htmlspecialchars($p_when) ?> · <?= htmlspecialchars($p_msg) ?>
</div>
<?php endif; ?>
</td>
<td>
<?php if ($s['remote_sha']): ?>
<code class="small"><?= htmlspecialchars(substr($s['remote_sha'], 0, 7)) ?></code>
<?php if ($s['remote_info']):
[$r_when, $r_msg] = array_pad(explode('|', $s['remote_info'], 2), 2, ''); ?>
<div class="text-muted text-truncate" style="font-size:.72rem;max-width:280px;" title="<?= htmlspecialchars($r_msg) ?>">
<?= htmlspecialchars($r_when) ?> · <?= htmlspecialchars($r_msg) ?>
</div>
<?php endif; ?>
<?php else: ?>
<span class="text-muted small">Faz fetch primeiro</span>
<?php endif; ?>
</td>
<td>
<?php if (!$s['remote_sha']): ?>
<span class="badge text-bg-secondary">Por verificar</span>
<?php elseif ($s['behind'] > 0): ?>
<span class="badge text-bg-warning text-dark"><?= $s['behind'] ?> behind</span>
<?php else: ?>
<span class="badge text-bg-success">Atualizado</span>
<?php endif; ?>
<?php if ($s['wt_dirty']): ?>
<span class="badge text-bg-danger" title="Working tree do submódulo difere do pinned">WT difere</span>
<?php endif; ?>
</td>
<td class="text-end">
<div class="d-flex justify-content-end gap-1 flex-wrap">
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_fetch">
<input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-cloud-download me-1"></i>Verificar
</button>
</form>
<?php if ($s['behind'] > 0): ?>
<form method="POST" class="m-0 d-flex align-items-center gap-2"
onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($s['path'])) ?> para o último commit de origin/<?= htmlspecialchars(addslashes($s['branch'])) ?>, fazer commit do bump e publicá-lo (git push)?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_update">
<input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>">
<?php
// Bump sem push é o que faz o repo divergir do remote: o bump fica
// comitado localmente, e a próxima atualização (a tua ou de outra
// pessoa) já não avança em fast-forward — "ALTERAÇÕES LOCAIS REAIS,
// atualização interrompida" (visto em produção, 2026-09-14). Publicar
// logo a seguir ao bump, na MESMA ação, é o que evita isso; por isso
// vem pré-marcada em vez de depender de alguém carregar depois num
// segundo botão "git push" que fica mais abaixo.
?>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" value="1" checked
name="also_push" id="alsoPush-<?= htmlspecialchars($s['path']) ?>">
<label class="form-check-label small text-muted" for="alsoPush-<?= htmlspecialchars($s['path']) ?>">
publicar (git push)
</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-arrow-up-circle me-1"></i>Atualizar
</button>
</form>
<?php endif; ?>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<form method="POST" class="m-0 mb-2"
onsubmit="return confirm('Sincronizar submódulos?\n\nNormaliza core.fileMode e repõe cada submódulo no commit fixado pelo repo principal (descarta só o drift de modo, o conteúdo é idêntico). Não toca em commits reais.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="sync_submodules">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Sincronizar submódulos (repor nos commits fixados)
</button>
</form>
<div class="small text-muted">
<i class="bi bi-info-circle me-1"></i>
"Verificar" faz <code>git fetch origin</code> dentro do submódulo (precisa de <code>GIT_HTTP_USERNAME</code>/<code>GIT_HTTP_TOKEN</code> no <code>.env</code>).
"Atualizar" faz <code>git submodule update --remote</code>, regista o novo SHA no parent com um commit local, e deixa-o pronto para o botão <em>git push</em> acima.
"Sincronizar submódulos" corre <code>git submodule sync --recursive</code> + <code>update --init --recursive --force</code> — repõe o drift de modo sem perder conteúdo.
</div>
</div>
</div>
<?php endif; ?>
</div><!-- /tab-atualizar -->
<div class="tab-pane" id="tab-automacao" role="tabpanel" aria-labelledby="tabbtn-automacao" tabindex="0">
<!-- ── Auto-update ────────────────────────────────────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-arrow-repeat me-1"></i>Auto-update</strong>
<?php if ($au_on): ?>
<span class="badge text-bg-success">Ligado</span>
<?php else: ?>
<span class="badge text-bg-secondary">Desligado</span>
<?php endif; ?>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
Um push para <code><?= htmlspecialchars($git_branch ?: 'este ramo') ?></code> atualiza a plataforma
sozinha: <code>git pull</code>, migrations que vieram no mesmo commit, e caches invalidadas.
Nunca descarta trabalho local — se houver alterações por comitar no servidor, pára e diz.
</p>
<div class="row g-3 small">
<div class="col-md-7">
<div class="text-muted mb-1">URL do webhook (Gitea → Definições → Webhooks → Gitea):</div>
<input type="text" class="form-control form-control-sm font-monospace" readonly
onclick="this.select()" value="<?= htmlspecialchars($au_hook_url) ?>">
<div class="mt-2">
<span class="text-muted">Segredo:</span>
<?php if ($au_secret_set): ?>
<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>AUTO_UPDATE_SECRET definido</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>falta AUTO_UPDATE_SECRET no .env</span>
<?php endif; ?>
</div>
<div class="text-muted mt-1" style="font-size:.78rem">
O mesmo valor vai no campo "Secret" do webhook. Sem ele o endpoint recusa tudo com 401.
</div>
</div>
<div class="col-md-5">
<div class="text-muted mb-1">Última execução:</div>
<?php if ($au_last['at'] === null): ?>
<div class="text-muted">Ainda nunca correu.</div>
<?php else: ?>
<div>
<?php if ($au_last['ok']): ?>
<span class="badge text-bg-success">OK</span>
<?php else: ?>
<span class="badge text-bg-danger">Falhou</span>
<?php endif; ?>
<span class="ms-1"><?= htmlspecialchars(date('Y-m-d H:i', strtotime($au_last['at'] . ' UTC'))) ?></span>
<?php if ($au_last['ref'] !== ''): ?>
<code class="ms-1"><?= htmlspecialchars($au_last['ref']) ?></code>
<?php endif; ?>
</div>
<div class="text-muted mt-1"><?= htmlspecialchars($au_last['summary']) ?></div>
<?php if ($au_last['output'] !== ''): ?>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
data-bs-toggle="collapse" data-bs-target="#au-output">
<i class="bi bi-terminal me-1"></i>Ver saída
</button>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php if ($au_last['output'] !== ''): ?>
<div class="collapse mt-3" id="au-output">
<pre class="mb-0 p-3 small" style="white-space:pre-wrap;max-height:320px;overflow:auto;background:rgba(0,0,0,.15);"><?= htmlspecialchars($au_last['output']) ?></pre>
</div>
<?php endif; ?>
</div>
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
<form method="POST" class="m-0"
onsubmit="return confirm('<?= $au_on ? 'Desligar' : 'Ligar' ?> o auto-update?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_toggle">
<button type="submit" class="btn btn-sm <?= $au_on ? 'btn-outline-danger' : 'btn-primary' ?>">
<i class="bi bi-power me-1"></i><?= $au_on ? 'Desligar' : 'Ligar' ?>
</button>
</form>
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_migrations_toggle">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-database me-1"></i>Migrations no deploy:
<strong><?= $au_migrations ? 'sim' : 'não' ?></strong>
</button>
</form>
<?php if ($git_available): ?>
<form method="POST" class="m-0 ms-auto"
onsubmit="return confirm('Correr agora o mesmo processo que o webhook dispara?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_run">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-play-circle me-1"></i>Correr agora
</button>
</form>
<?php endif; ?>
</div>
</div>
<!-- ── Backup antes de atualizar ──────────────────────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-shield-check me-1"></i>Backup antes de atualizar</strong>
<?php if ($ub['db'] || $ub['files']): ?>
<span class="badge text-bg-success">
<?= $ub['db'] && $ub['files'] ? 'BD + ficheiros' : ($ub['db'] ? 'Base de dados' : 'Ficheiros') ?>
</span>
<?php else: ?>
<span class="badge text-bg-secondary">Sem backup</span>
<?php endif; ?>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
Aplica-se às duas vias de atualização — o botão <em>Atualizar</em> e o webhook. O backup corre
<strong>antes</strong> de o código se mexer e a atualização <strong>espera</strong> por ele: se falhar
ou demorar de mais, nada é atualizado. Um interruptor que promete ponto de restauro e depois avança
sem ele seria pior do que não existir.
</p>
<div class="row g-3 small">
<?php foreach ([
['key' => 'db', 'on' => $ub['db'], 'action' => 'update_backup_db_toggle',
'icon' => 'bi-database', 'title' => 'Base de dados',
'note' => 'Dump completo, encriptado quando há APP_SECRET_KEY. É o que permite voltar atrás de uma migration que correu mal.'],
['key' => 'files', 'on' => $ub['files'], 'action' => 'update_backup_files_toggle',
'icon' => 'bi-folder', 'title' => 'Ficheiros (uploads)',
'note' => 'ZIP de uploads/. O git nunca lhe toca, por isso raramente é o que falta a seguir a um deploy — e num site com fotografias é o que faz cada atualização demorar minutos.'],
] as $row): ?>
<div class="col-md-6">
<div class="border rounded p-3 h-100 d-flex flex-column gap-2">
<div class="d-flex justify-content-between align-items-center gap-2">
<strong><i class="bi <?= $row['icon'] ?> me-1"></i><?= htmlspecialchars($row['title']) ?></strong>
<?php if ($row['on']): ?>
<span class="badge text-bg-success">Ligado</span>
<?php else: ?>
<span class="badge text-bg-secondary">Desligado</span>
<?php endif; ?>
</div>
<div class="text-muted" style="font-size:.78rem"><?= htmlspecialchars($row['note']) ?></div>
<div class="text-muted mt-auto" style="font-size:.78rem">
<?php $last = $ub_last[$row['key']]; ?>
<?php if ($last): ?>
Último: <?= htmlspecialchars($last['size_human']) ?>
· <?= htmlspecialchars(date('Y-m-d H:i', $last['created_at'])) ?>
<?php else: ?>
Ainda não há nenhum backup deste tipo.
<?php endif; ?>
</div>
<form method="POST" class="m-0"
onsubmit="return confirm('<?= $row['on'] ? 'Desligar' : 'Ligar' ?> o backup <?= $row['key'] === 'db' ? 'da base de dados' : 'dos ficheiros' ?> antes de cada atualização?');">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="<?= $row['action'] ?>">
<button type="submit" class="btn btn-sm w-100 <?= $row['on'] ? 'btn-outline-danger' : 'btn-outline-primary' ?>">
<i class="bi bi-power me-1"></i><?= $row['on'] ? 'Desligar' : 'Ligar' ?>
</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($ub['files']): ?>
<div class="alert alert-warning small mt-3 mb-0 py-2">
<i class="bi bi-clock-history me-1"></i>
Com o backup de ficheiros ligado, cada atualização espera que <code>uploads/</code> seja arquivado.
Num site com galerias isso são minutos e vários GB por deploy — e o git nunca mexe em
<code>uploads/</code>, por isso este é quase sempre o interruptor a deixar desligado.
</div>
<?php endif; ?>
</div>
</div>
</div><!-- /tab-automacao -->
<div class="tab-pane" id="tab-dados" role="tabpanel" aria-labelledby="tabbtn-dados" tabindex="0">
<!-- ── Migrations ─────────────────────────────────────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-database me-1"></i>Migrations</strong>
<?php // O filtro abre em "Pendentes" quando há alguma: é a única
// resposta que se procura ao abrir esta página. Sem nenhuma
// pendente abre em "Todas", que é a lista completa e correta. ?>
<?php // Os três botões partilham a mesma variante neutra: a cor fica
// nos badges, e o preenchimento passa a significar UMA coisa
// só — "é esta a vista activa". Com variantes coloridas, um
// outline-warning nesta marca lê-se como preenchido mesmo
// inactivo, e deixava de se perceber qual estava escolhida. ?>
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrar migrations">
<button type="button" class="btn btn-outline-secondary" data-mig-filter="all">
Todas <span class="badge text-bg-secondary ms-1"><?= count($migrations) ?></span>
</button>
<button type="button" class="btn btn-outline-secondary" data-mig-filter="pending">
Pendentes <span class="badge text-bg-warning text-dark ms-1"><?= count($pending) ?></span>
</button>
<button type="button" class="btn btn-outline-secondary" data-mig-filter="applied">
Aplicadas <span class="badge text-bg-success ms-1"><?= $applied_n ?></span>
</button>
</div>
</div>
<div class="card-body p-0">
<?php if (empty($migrations)): ?>
<div class="text-muted text-center py-4 small">Sem migrations na pasta <code><?= htmlspecialchars(migrations_dir()) ?></code>.</div>
<?php else: ?>
<div class="table-responsive pt-scroll">
<table class="table table-sm align-middle mb-0">
<thead class="table-dark">
<tr>
<th>Ficheiro</th>
<th style="width:200px;">Estado</th>
<th style="width:160px;" class="text-end">Ação</th>
</tr>
</thead>
<tbody>
<?php foreach ($migrations as $mi => $m): ?>
<?php $mig_state = $m['applied_at'] ? 'applied' : 'pending'; ?>
<tr data-mig-state="<?= $mig_state ?>">
<td class="font-monospace small"><?= htmlspecialchars($m['filename']) ?></td>
<td>
<?php if ($m['applied_at']): ?>
<span class="badge text-bg-success">Aplicada</span>
<small class="text-muted ms-1"><?= htmlspecialchars($m['applied_at']) ?></small>
<?php else: ?>
<span class="badge text-bg-warning text-dark">Pendente</span>
<?php endif; ?>
</td>
<td class="text-end">
<?php if (!$m['applied_at']): ?>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="collapse" data-bs-target="#migsql-<?= $mi ?>"
title="Ver SQL" aria-label="Ver SQL de <?= htmlspecialchars($m['filename']) ?>">
<i class="bi bi-eye"></i>
</button>
<form method="POST" class="m-0 d-inline"
onsubmit="return confirm('Aplicar <?= htmlspecialchars(addslashes($m['filename'])) ?>?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="apply_migration">
<input type="hidden" name="filename" value="<?= htmlspecialchars($m['filename']) ?>">
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-play-fill"></i> Aplicar
</button>
</form>
<?php else: ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
</tr>
<?php if (!$m['applied_at']): ?>
<?php $mig_sql = @file_get_contents(migrations_dir() . '/' . $m['filename']); ?>
<tr class="collapse" id="migsql-<?= $mi ?>" data-mig-state="<?= $mig_state ?>">
<td colspan="3" class="p-0">
<pre class="mb-0 p-3" style="font-size:.72rem;white-space:pre-wrap;max-height:320px;overflow:auto;background:rgba(0,0,0,.15);"><?= htmlspecialchars($mig_sql !== false ? $mig_sql : '(não foi possível ler o ficheiro)') ?></pre>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div id="mig-empty" class="text-muted text-center py-4 small d-none">Nada nesta vista.</div>
<script>
(function () {
const rows = document.querySelectorAll('tr[data-mig-state]');
const buttons = document.querySelectorAll('[data-mig-filter]');
const empty = document.getElementById('mig-empty');
if (!buttons.length) return;
function apply(which) {
let shown = 0;
rows.forEach(function (tr) {
const match = which === 'all' || tr.dataset.migState === which;
tr.classList.toggle('d-none', !match);
// Só esconder a linha do SQL não chega: o collapse do
// Bootstrap volta a mostrá-la ao carregar no olho, por
// isso a linha filtrada fora fecha-se também.
if (!match && tr.classList.contains('show')) tr.classList.remove('show');
if (match && !tr.id.startsWith('migsql-')) shown++;
});
empty.classList.toggle('d-none', shown > 0);
buttons.forEach(function (b) {
const on = b.dataset.migFilter === which;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
buttons.forEach(function (b) {
b.addEventListener('click', function () { apply(b.dataset.migFilter); });
});
apply(<?= $pending ? "'pending'" : "'all'" ?>);
}());
</script>
<?php endif; ?>
</div>
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
<?php $schema_path = schema_dump_path(); $schema_exists = is_file($schema_path); ?>
<form method="POST" class="m-0"
onsubmit="return confirm('Exportar o esquema atual da base de dados para <?= htmlspecialchars(addslashes(basename($schema_path))) ?>? O ficheiro existente é substituído.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="dump_schema">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-filetype-sql me-1"></i>Exportar esquema
</button>
</form>
<span class="small text-muted">
<code><?= htmlspecialchars(basename($schema_path)) ?></code>
<?php if ($schema_exists): ?>
· <?= htmlspecialchars(date('Y-m-d H:i', filemtime($schema_path))) ?>
<?php else: ?>
· <span class="text-warning">ainda não exportado</span>
<?php endif; ?>
</span>
<?php if ($pending): ?>
<form method="POST" class="m-0 ms-auto"
onsubmit="return confirm('Aplicar todas as migrations pendentes (<?= count($pending) ?>)?');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="apply_pending">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-play-circle me-1"></i>Aplicar pendentes (<?= count($pending) ?>)
</button>
</form>
<?php endif; ?>
<?php if ($migrations): ?>
<form method="POST" class="m-0 <?= $pending ? '' : 'ms-auto' ?>"
onsubmit="return confirm('Marcar TODAS as migrations como aplicadas, sem executar SQL? Útil quando já correste manualmente.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="mark_all_applied">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>Marcar tudo como aplicado
</button>
</form>
<?php endif; ?>
</div>
</div>
</div><!-- /tab-dados -->
<div class="tab-pane" id="tab-servidor" role="tabpanel" aria-labelledby="tabbtn-servidor" tabindex="0">
<div class="row g-3">
<div class="col-lg-6">
<!-- ── OPcache ────────────────────────────────────────────────────── -->
<?php
$opcache_status = function_exists('opcache_get_status') ? @opcache_get_status(false) : false;
$opcache_enabled = is_array($opcache_status) && !empty($opcache_status['opcache_enabled']);
?>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-lightning-charge me-1"></i>PHP OPcache</strong>
<div class="d-flex align-items-center gap-2">
<?php if ($opcache_enabled): ?>
<span class="badge text-bg-success">Ativo</span>
<?php elseif (is_array($opcache_status)): ?>
<span class="badge text-bg-warning text-dark">Inativo</span>
<?php else: ?>
<span class="badge text-bg-secondary">Não disponível</span>
<?php endif; ?>
<?php if ($opcache_enabled): ?>
<form method="POST" class="m-0" onsubmit="return confirm('Invalidar OPcache? Será reconstruído nas próximas requests.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="reset_opcache">
<button type="submit" class="btn btn-outline-secondary btn-sm py-0"
title="Invalidar cache" aria-label="Invalidar OPcache">
<i class="bi bi-arrow-clockwise"></i>
</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if ($opcache_enabled): ?>
<div class="card-body small">
<?php
$mem = $opcache_status['memory_usage'] ?? [];
$stats = $opcache_status['opcache_statistics'] ?? [];
$hit = isset($stats['opcache_hit_rate']) ? number_format($stats['opcache_hit_rate'], 1) : '—';
$used = isset($mem['used_memory']) ? round($mem['used_memory'] / 1048576, 1) : '—';
$free = isset($mem['free_memory']) ? round($mem['free_memory'] / 1048576, 1) : '—';
$files = $stats['num_cached_scripts'] ?? '—';
$max = $stats['max_cached_keys'] ?? '—';
?>
<div class="row g-2">
<div class="col-6"><span class="text-muted d-block">Hit rate</span><strong><?= $hit ?>%</strong></div>
<div class="col-6"><span class="text-muted d-block">Scripts em cache</span><?= $files ?> / <?= $max ?></div>
<div class="col-6"><span class="text-muted d-block">Memória</span><?= $used ?> MB usados · <?= $free ?> MB livres</div>
<div class="col-6"><span class="text-muted d-block">Hits</span><?= number_format((int) ($stats['hits'] ?? 0), 0, ',', '.') ?></div>
</div>
</div>
<?php else: ?>
<div class="card-body small text-muted">
OPcache acelera o PHP em ~5–10× ao guardar o bytecode dos scripts em memória partilhada.
Para o ativar em produção, no <code>php.ini</code>:
<pre class="mb-0 mt-2" style="white-space:pre-wrap;font-size:.75rem;">opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0 ; em prod (em dev: 1)</pre>
Com <code>validate_timestamps=0</code>, esta página invalida o cache automaticamente após cada <code>git pull</code>.
</div>
<?php endif; ?>
</div>
</div>
<div class="col-lg-6">
<!-- ── Dependências (Composer) ───────────────────────────────────── -->
<?php if ($composer['has_json']): ?>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong><i class="bi bi-box-seam me-1"></i>Composer</strong>
<span class="small">
<?php if ($composer['has_autoload']): ?>
<span class="badge text-bg-success">vendor/ instalado</span>
<?php else: ?>
<span class="badge text-bg-warning text-dark">vendor/ em falta</span>
<?php endif; ?>
</span>
</div>
<div class="card-body">
<?php if ($composer_outdated && !empty($composer_outdated['ok'])): ?>
<?php if (empty($composer_outdated['packages'])): ?>
<div class="alert alert-<?= !empty($composer_outdated['note']) ? 'secondary' : 'success' ?> small py-2 mb-2">
<i class="bi bi-<?= !empty($composer_outdated['note']) ? 'info-circle' : 'check-circle' ?> me-1"></i>
<?= !empty($composer_outdated['note'])
? htmlspecialchars((string) $composer_outdated['note'])
: 'Todas as dependências diretas estão atualizadas' ?>
<span class="text-muted">(verificado <?= date('H:i', (int) ($composer_outdated['checked_at'] ?? time())) ?>)</span>.
</div>
<?php else: ?>
<div class="table-responsive mb-2">
<table class="table table-sm align-middle small mb-0">
<thead class="table-light">
<tr>
<th>Pacote</th><th>Instalado</th><th>Disponível</th>
<th>Restrição</th><th class="text-end">Ação</th>
</tr>
</thead>
<tbody>
<?php foreach ($composer_outdated['packages'] as $pk): ?>
<tr>
<td class="font-monospace text-break"><?= htmlspecialchars($pk['name']) ?></td>
<td class="font-monospace text-muted"><?= htmlspecialchars($pk['version']) ?></td>
<td class="font-monospace">
<?= htmlspecialchars($pk['latest']) ?>
<?php if ($pk['updatable']): ?>
<span class="badge text-bg-success ms-1">atualizável</span>
<?php else: ?>
<span class="badge text-bg-secondary ms-1">bloqueado</span>
<?php endif; ?>
</td>
<td class="font-monospace text-muted"><?= htmlspecialchars($pk['constraint'] ?: '—') ?></td>
<td class="text-end">
<?php if ($pk['updatable']): ?>
<?php $mv = $pk['would_move']; ?>
<form method="POST" class="d-inline m-0"
onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($pk['name'])) ?> (<?= htmlspecialchars(addslashes((string) ($mv['from'] ?? $pk['version']))) ?> => <?= htmlspecialchars(addslashes((string) ($mv['to'] ?? $pk['latest']))) ?>)?');">
<?= platform_csrf_field() ?>
<input type="hidden" name="action" value="composer_update_pkg">
<input type="hidden" name="package" value="<?= htmlspecialchars($pk['name']) ?>">
<button type="submit" class="btn btn-sm btn-outline-primary"
title="composer confirmou que move para <?= htmlspecialchars((string) ($mv['to'] ?? '?')) ?>">
<i class="bi bi-arrow-up-circle"></i>
atualizar<?= isset($mv['to']) && $mv['to'] !== $pk['latest'] ? ' &rarr; ' . htmlspecialchars((string) $mv['to']) : '' ?>
</button>
</form>
<?php else: ?>
<!-- No button here on purpose. composer's dry run says this package
will not move under the declared constraint, so a button would
run, change nothing, and report success. Show the constraint
that has to be widened instead. -->
<span class="text-muted small"
title="composer update não move este pacote enquanto a restrição for <?= htmlspecialchars($pk['constraint'] ?: '?') ?>">
restrição <code><?= htmlspecialchars($pk['constraint'] ?: '?') ?></code> impede
</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php elseif ($composer_outdated && empty($composer_outdated['ok'])): ?>
<div class="alert alert-danger small py-2 mb-2">
Falha ao verificar: <?= htmlspecialchars((string) ($composer_outdated['error'] ?? '')) ?>
</div>
<?php elseif ($composer['declared']): ?>
<div class="small mb-2" style="max-height:120px;overflow-y:auto;">
<span class="text-muted">Pacotes declarados:</span>
<?php foreach ($composer['declared'] as $pkg => $ver): ?>
<code class="d-block text-truncate"><?= htmlspecialchars($pkg) ?>:<?= htmlspecialchars((string) $ver) ?></code>
<?php endforeach; ?>
</div>
<p class="small text-muted mb-2">
<i class="bi bi-info-circle me-1"></i>Carrega em <strong>verificar atualizações</strong> para
comparar com o packagist.
</p>
<?php else: ?>
<p class="small text-muted mb-2"><code>composer.json</code> sem pacotes em <code>require</code>.</p>
<?php endif; ?>
<p class="small text-muted mb-0">
<code>install</code> reconstrói o <code>vendor/</code> a partir do lock (reproduzível);
<code>update</code> resolve versões novas e reescreve o lock.
Ambos com <code>--no-dev --optimize-autoloader</code>.
</p>
</div>
<div class="card-footer">
<?php if ($can_exec): ?>
<div class="d-flex gap-2 flex-wrap mb-2">
<form method="POST" class="m-0" onsubmit="return confirm('Correr composer install? Reconstrói o vendor/ a partir do composer.lock.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_install">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-download me-1"></i>install
</button>
</form>
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_check">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-search me-1"></i>verificar atualizações
</button>
</form>
<form method="POST" class="m-0" onsubmit="return confirm('Correr composer update em TODOS os pacotes? Atualiza dentro das restrições e reescreve o composer.lock.');">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_update">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-up-circle me-1"></i>update (todos)
</button>
</form>
</div>
<?php if ($composer_avail && $composer_avail['ok']): ?>
<div class="small text-muted text-truncate" title="<?= htmlspecialchars(($composer_avail['version'] ?? '') . ' · ' . $composer_avail['how']) ?>">
<i class="bi bi-check2 text-success me-1"></i><?= htmlspecialchars($composer_avail['version'] ?? 'composer') ?>
· <?= htmlspecialchars($composer_avail['how']) ?>
</div>
<?php else: ?>
<div class="small text-danger">
<i class="bi bi-exclamation-triangle me-1"></i>composer não encontrado —
requer <code>composer</code> no PATH ou <code>composer.phar</code> na raiz.
</div>
<?php endif; ?>
<?php else: ?>
<div class="small text-muted">
<i class="bi bi-slash-circle me-1"></i>Instalação via web indisponível:
<code>proc_open()</code> está desativado no PHP deste servidor.
Corre o composer localmente e faz upload do <code>vendor/</code>.
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- ── PHP Error Log (only if configured) ─────────────────────────── -->
<?php if ($error_log_file): ?>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<?php if ($error_log_entries): ?>
<!-- Acordeão: só a zona do título alterna o painel; o botão Limpar
fica fora do alvo do toggle para não abrir/fechar ao limpar. -->
<div class="pt-toggle d-flex align-items-center gap-2 flex-grow-1"
data-bs-toggle="collapse" data-bs-target="#errorLogPanel"
role="button" aria-expanded="false" aria-controls="errorLogPanel">
<strong><i class="bi bi-bug me-1"></i>Log de erros PHP</strong>
<span class="badge text-bg-danger" title="<?= $error_log_total ?> entrada(s) no total; a tabela mostra as últimas 200">
<?= $error_log_total > 200 ? '200+' : count($error_log_entries) ?>
</span>
<i class="bi bi-chevron-down small pt-chevron ms-1"></i>
</div>
<?php else: ?>
<strong><i class="bi bi-bug me-1"></i>Log de erros PHP</strong>
<?php endif; ?>
<form method="POST" class="m-0">
<?= platform_csrf_field() ?><input type="hidden" name="action" value="clear_error_log">
<button class="btn btn-sm btn-outline-danger"
onclick="return confirm('Limpar todo o log de erros?')">
<i class="bi bi-trash me-1"></i>Limpar
</button>
</form>
</div>
<?php if (!$error_log_entries): ?>
<div class="card-body small text-muted">Nenhum erro registado.</div>
<?php else: ?>
<div class="collapse" id="errorLogPanel">
<div class="table-responsive pt-scroll" style="max-height:420px;">
<table class="table table-sm mb-0" style="font-size:.78rem;">
<thead class="table-dark">
<tr>
<th style="min-width:130px;">Data/hora</th>
<th style="min-width:90px;">Tipo</th>
<th>Mensagem</th>
<th style="min-width:80px;">Utilizador</th>
<th style="min-width:160px;">URL</th>
<th style="min-width:60px;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($error_log_entries as $i => $err): ?>
<?php
$badgeClass = match(true) {
str_contains(strtolower($err['type'] ?? ''), 'fatal'),
str_contains(strtolower($err['type'] ?? ''), 'exception'),
str_contains(strtolower($err['type'] ?? ''), 'error') => 'text-bg-danger',
str_contains(strtolower($err['type'] ?? ''), 'warning') => 'text-bg-warning',
str_contains(strtolower($err['type'] ?? ''), 'notice'),
str_contains(strtolower($err['type'] ?? ''), 'deprecat') => 'text-bg-secondary',
default => 'text-bg-secondary',
};
$ts = isset($err['ts']) ? date('d/m/Y H:i:s', strtotime($err['ts'])) : '—';
$uid = !empty($err['user_id'])
? '(' . (int) $err['user_id'] . ') ' . htmlspecialchars($err['user_name'] ?? '')
: '<span class="text-muted">anónimo</span>';
?>
<tr>
<td class="text-muted"><?= $ts ?></td>
<td><span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($err['type'] ?? '—') ?></span></td>
<td title="<?= htmlspecialchars($err['message'] ?? '') ?>">
<?= htmlspecialchars(mb_strimwidth($err['message'] ?? '', 0, 80, '…')) ?>
<?php if (!empty($err['file'])): ?>
<div class="text-muted" style="font-size:.72rem;">
<?= htmlspecialchars(basename($err['file'])) ?>:<?= (int)($err['line'] ?? 0) ?>
</div>
<?php endif; ?>
</td>
<td><?= $uid ?></td>
<td class="text-muted text-truncate" style="max-width:180px;"
title="<?= htmlspecialchars($err['url'] ?? '') ?>">
<?= htmlspecialchars(mb_strimwidth($err['url'] ?? '', 0, 40, '…')) ?>
</td>
<td>
<?php if (!empty($err['trace']) || !empty($err['file'])): ?>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1"
data-bs-toggle="collapse" data-bs-target="#errdetail-<?= $i ?>"
title="Ver detalhes" aria-label="Ver detalhes do erro">
<i class="bi bi-chevron-down" style="font-size:.7rem;"></i>
</button>
<?php endif; ?>
</td>
</tr>
<?php if (!empty($err['trace']) || !empty($err['file'])): ?>
<tr class="collapse" id="errdetail-<?= $i ?>">
<td colspan="6" class="p-0">
<pre class="mb-0 p-3" style="font-size:.72rem;white-space:pre-wrap;word-break:break-all;background:rgba(0,0,0,.2);border-radius:0;"><?php
if (!empty($err['file'])) echo htmlspecialchars($err['file'] . ':' . ($err['line'] ?? 0)) . "\n\n";
if (!empty($err['trace'])) echo htmlspecialchars($err['trace']);
?></pre>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div><!-- /collapse -->
<?php endif; ?>
</div>
<?php endif; ?><!-- ── Pré-requisitos (acordeão, fechado por omissão) ─────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center pt-toggle"
data-bs-toggle="collapse" data-bs-target="#preReqsPanel"
role="button" aria-expanded="false" aria-controls="preReqsPanel">
<strong><i class="bi bi-info-circle me-1"></i>Pré-requisitos no servidor</strong>
<i class="bi bi-chevron-down small pt-chevron"></i>
</div>
<div class="collapse" id="preReqsPanel">
<div class="card-body small">
<ul class="mb-0 ps-3">
<li><code>proc_open()</code> ativo no PHP (fora de <code>disable_functions</code>) — necessário para as ferramentas git e composer; sem ele, esses cartões ficam ocultos.</li>
<li>O utilizador do Apache/PHP-FPM tem permissão de escrita nesta pasta.</li>
<li><strong>Autenticação:</strong> para remotes HTTPS/HTTP (Gitea típico), define <code>GIT_HTTP_USERNAME</code> e <code>GIT_HTTP_TOKEN</code> (Personal Access Token) no <code>.env</code>. Para remotes SSH, autoriza a chave do utilizador no Gitea.</li>
<li>O <em>dubious ownership</em> guard do Git é tratado em-process via gitconfig temporário com <code>safe.directory = <?= htmlspecialchars($repo_root) ?></code>.</li>
<li>Depois de <code>git pull</code> com mudanças a configurações em cache, as caches são invalidadas automaticamente quando o host define <code>settings_cache_clear()</code>.</li>
</ul>
</div>
</div>
</div>
</div><!-- /tab-servidor -->
</div><!-- /tab-content -->
</div>
<script>
// O separador ativo fica no endereço, para que F5, "voltar" e um link partilhado
// aterrem onde se estava. ptShowTab() também serve os atalhos fora das tabs
// (o botão Reverter do cabeçalho).
function ptShowTab(id) {
var btn = document.getElementById('tabbtn-' + id);
if (btn) new bootstrap.Tab(btn).show();
}
(function () {
var hash = (location.hash || '').replace('#tab-', '');
if (hash) ptShowTab(hash);
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function (b) {
b.addEventListener('shown.bs.tab', function (e) {
history.replaceState(null, '', '#' + e.target.dataset.bsTarget.slice(1));
});
});
}());
</script>
<script>
// Anti-duplo-clique: depois do confirm() passar, bloqueia reenvios e mostra um
// spinner no botão. Os botões não transportam name/value (as ações vão em
// inputs hidden), por isso desativá-los não altera o POST.
document.addEventListener('submit', function (e) {
var form = e.target;
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
form.dataset.ptSubmitted = '1';
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
btn.disabled = true;
btn.insertAdjacentHTML('afterbegin',
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
});
});
</script>
<?php if ($git_diff !== ''): ?>
<div class="modal fade" id="gitDiffModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-file-diff me-2"></i>Diff das alterações locais</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body p-0">
<pre class="mb-0 small p-3" style="white-space:pre-wrap;font-size:.75rem;"><?= htmlspecialchars($git_diff) ?></pre>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php if (is_file($layout_footer)) include $layout_footer; ?>