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/ 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/. 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}.\nCommit criado — usa o botão git push para publicar." : 'Bump aplicado, mas o commit falhou:') . ($r3['output'] !== '' ? "\n\n" . $r3['output'] : ''); 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; } // ── 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: `) 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; ?>
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']; ?>
Atualizar plataforma
Reverter Voltar
Execução de processos indisponível neste servidor. O PHP tem proc_open() desativado (disable_functions no php.ini), por isso as ferramentas git e composer estão ocultas. Migrations, esquema, OPcache e log de erros continuam disponíveis.
Esta pasta não é um repositório git. Inicializa com git init + remote para o Gitea. A actualização via web só funciona quando existe um .git.
Stashes guardados ():
  • ()
Pop aplica o stash ao working tree e remove-o; drop apaga sem aplicar.
['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], ]; ?>
Ponto de restauro disponível

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.

Commit:
Atualizado em
A base de dados não recua com o código. Estas migrations correram nesta atualização e continuam aplicadas:
Backup tirado imediatamente antes: — restaura-o pela página Restaurar base de dados se o esquema novo for o problema. Não há backup pré-atualização deste deploy; liga-o no separador Automação para o próximo.
Git "> core.fileMode normalizado core.fileMode não normalizado
Branch:
Commit actual:
Remote:
Último commit:
commit(s) por puxar commit(s) local não-empurrado(s)
Atenção: há alterações locais não-comitidas. git pull --ff-only vai falhar até que sejam comitidas ou descartadas.
Submódulos 0): ?> · commit(s) por puxar · tudo atualizado
Submódulo Pinned Remoto (origin/<branch>) Estado Ações
·
Não inicializado. Corre git submodule update --init no servidor.
·
·
Faz fetch primeiro
Por verificar 0): ?> behind Atualizado WT difere
0): ?>
Auto-update Ligado Desligado

Um push para atualiza a plataforma sozinha: git pull, 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.

URL do webhook (Gitea → Definições → Webhooks → Gitea):
Segredo: AUTO_UPDATE_SECRET definido falta AUTO_UPDATE_SECRET no .env
O mesmo valor vai no campo "Secret" do webhook. Sem ele o endpoint recusa tudo com 401.
Última execução:
Ainda nunca correu.
OK Falhou
Backup antes de atualizar Sem backup

Aplica-se às duas vias de atualização — o botão Atualizar e o webhook. O backup corre antes de o código se mexer e a atualização espera 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.

'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): ?>
Ligado Desligado
Último: · Ainda não há nenhum backup deste tipo.
Com o backup de ficheiros ligado, cada atualização espera que uploads/ seja arquivado. Num site com galerias isso são minutos e vários GB por deploy — e o git nunca mexe em uploads/, por isso este é quase sempre o interruptor a deixar desligado.
Migrations
Sem migrations na pasta .
$m): ?>
Ficheiro Estado Ação
Aplicada Pendente
—
Nada nesta vista.
PHP OPcache
Ativo Inativo Não disponível
Hit rate%
Scripts em cache /
Memória MB usados · MB livres
Hits
OPcache acelera o PHP em ~5–10× ao guardar o bytecode dos scripts em memória partilhada. Para o ativar em produção, no php.ini:
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0   ; em prod (em dev: 1)
Com validate_timestamps=0, esta página invalida o cache automaticamente após cada git pull.
Composer vendor/ instalado vendor/ em falta
(verificado ).
PacoteInstaladoDisponível RestriçãoAção
atualizável bloqueado
restrição impede
Falha ao verificar:
Pacotes declarados: $ver): ?> :

Carrega em verificar atualizações para comparar com o packagist.

composer.json sem pacotes em require.

install reconstrói o vendor/ a partir do lock (reproduzível); update resolve versões novas e reescreve o lock. Ambos com --no-dev --optimize-autoloader.

Log de erros PHP
Nenhum erro registado.
$err): ?> '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'] ?? '') : 'anónimo'; ?>
Data/hora Tipo Mensagem Utilizador URL
:
  • proc_open() ativo no PHP (fora de disable_functions) — necessário para as ferramentas git e composer; sem ele, esses cartões ficam ocultos.
  • O utilizador do Apache/PHP-FPM tem permissão de escrita nesta pasta.
  • Autenticação: para remotes HTTPS/HTTP (Gitea típico), define GIT_HTTP_USERNAME e GIT_HTTP_TOKEN (Personal Access Token) no .env. Para remotes SSH, autoriza a chave do utilizador no Gitea.
  • O dubious ownership guard do Git é tratado em-process via gitconfig temporário com safe.directory = .
  • Depois de git pull com mudanças a configurações em cache, as caches são invalidadas automaticamente quando o host define settings_cache_clear().