Files
fitme3070.pt/shared/platform-tools/functions_git.php
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

445 lines
19 KiB
PHP

<?php
require_once __DIR__ . '/functions_capabilities.php';
/**
* Run a git command in the project root and capture stdout+stderr together.
* Uses proc_open() in array form (no shell) so git placeholders like
* %s / %cd / `|` in --format pass through cleanly without word splitting.
*
* `safe.directory` is intentionally NOT honored by git via `-c` or
* `GIT_CONFIG_*` env vars — only via the system/per-user gitconfig. To
* bypass the "dubious ownership" guard without touching the host's
* gitconfig (typical Docker scenario: repo owned by root, PHP runs as
* www-data) we write a tiny config to a temp file and point
* `GIT_CONFIG_GLOBAL` at it for the duration of the call.
*
* @param array<string> $args Git args (e.g. ['log', '-1', '--format=%cd|%s'])
* @return array{ok:bool,code:int,output:string}
*/
function git_run(array $args, string $repo): array {
// On hosts with proc_open() in disable_functions, calling it is a FATAL
// "undefined function" error — fail soft so callers can degrade.
if (!platform_proc_open_available()) {
return ['ok' => false, 'code' => -1, 'output' => 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.'];
}
// One temp gitconfig per REQUEST, not per call: a page load fires 30+ git
// invocations and each one was writing + unlinking its own temp file.
// `safe.directory = *` covers the main repo and every submodule alike.
static $tmp_gitconfig = null;
if ($tmp_gitconfig === null) {
$tmp_gitconfig = @tempnam(sys_get_temp_dir(), 'platgit_');
if ($tmp_gitconfig !== false) {
@file_put_contents($tmp_gitconfig, "[safe]\n\tdirectory = *\n");
$cfg = $tmp_gitconfig;
register_shutdown_function(function () use ($cfg) { @unlink($cfg); });
}
}
// Base env every git subprocess shares. GIT_CONFIG_GLOBAL must point at
// the temp gitconfig BEFORE we even read the remote URL, otherwise the
// safe.directory guard can break that call.
$env = getenv() ?: [];
if ($tmp_gitconfig !== false) {
$env['GIT_CONFIG_GLOBAL'] = $tmp_gitconfig;
}
$env['GIT_TERMINAL_PROMPT'] = '0';
// HTTP credentials for non-interactive fetch/pull. PHP has no TTY, so any
// prompt for username/password kills the call. If GIT_HTTP_USERNAME +
// GIT_HTTP_TOKEN are set (via env() or getenv()), write them to a chmod
// 0600 temp file and tell git to read credentials from there via the
// built-in `store` helper. Resolved once per repo per request (the remote
// URL probe is itself a git subprocess); cleaned up at shutdown.
static $creds_cache = [];
if (!array_key_exists($repo, $creds_cache)) {
$creds_cache[$repo] = null;
$username = (string) (function_exists('env')
? (env('GIT_HTTP_USERNAME') ?: '')
: (getenv('GIT_HTTP_USERNAME') ?: ''));
$token = (string) (function_exists('env')
? (env('GIT_HTTP_TOKEN') ?: '')
: (getenv('GIT_HTTP_TOKEN') ?: ''));
if ($username !== '' && $token !== '') {
// Read the remote URL via git itself — works for regular repos AND
// submodules (where .git is a file pointing to .git/modules/<name>/,
// so parsing $repo/.git/config directly would fail).
$url_cmd = ['git', '-C', $repo, 'config', '--get', 'remote.origin.url'];
$url_desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$url_proc = @proc_open($url_cmd, $url_desc, $url_pipes, null, $env);
$remote_url = '';
if (is_resource($url_proc)) {
$remote_url = trim((string) stream_get_contents($url_pipes[1]));
fclose($url_pipes[1]);
fclose($url_pipes[2]);
proc_close($url_proc);
}
if ($remote_url !== '' && preg_match('#^https?://#', $remote_url)) {
$url = parse_url($remote_url);
if (isset($url['scheme'], $url['host'])) {
$host = $url['host'] . (isset($url['port']) ? ':' . $url['port'] : '');
$line = $url['scheme'] . '://'
. rawurlencode($username) . ':' . rawurlencode($token)
. '@' . $host . "\n";
$creds_file = @tempnam(sys_get_temp_dir(), 'gitcred_');
if ($creds_file !== false) {
@chmod($creds_file, 0600);
@file_put_contents($creds_file, $line);
register_shutdown_function(function () use ($creds_file) { @unlink($creds_file); });
$creds_cache[$repo] = $creds_file;
}
}
}
}
}
$creds_file = $creds_cache[$repo];
$cmd = ['git', '-C', $repo];
if ($creds_file !== null) {
$cmd[] = '-c'; $cmd[] = 'credential.helper=';
$cmd[] = '-c'; $cmd[] = 'credential.helper=store --file=' . $creds_file;
}
$cmd = array_merge($cmd, $args);
$descs = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $descs, $pipes, null, $env);
if (!is_resource($proc)) {
return ['ok' => false, 'code' => -1, 'output' => 'Não foi possível invocar o git. Confirme que está instalado e no PATH do utilizador do servidor web.'];
}
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
$output = trim((string) $out);
if ($err !== '' && $err !== false) {
$output .= ($output !== '' ? "\n" : '') . trim($err);
}
return ['ok' => $code === 0, 'code' => $code, 'output' => $output];
}
/**
* Submodule paths declared in .gitmodules, relative to the repo root.
*
* Reads them via git itself (`git config -f .gitmodules --get-regexp path`) so
* the result matches exactly what git acts on. Returns [] when there are no
* submodules (or no .gitmodules) — callers must treat that as a no-op, never an
* error.
*
* @return array<string>
*/
function git_submodule_paths(string $repo): array {
if (!is_file($repo . '/.gitmodules')) return [];
$res = git_run(['config', '-f', '.gitmodules', '--get-regexp', 'path'], $repo);
if (!$res['ok'] || $res['output'] === '') return [];
$paths = [];
foreach (preg_split('/\R/', $res['output']) as $line) {
// Each line: "submodule.<name>.path <path>"
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && $parts[1] !== '') {
$paths[] = $parts[1];
}
}
return $paths;
}
/**
* Idempotently stop git from tracking the filesystem executable bit, in the
* main repo AND every initialized submodule. Run this at the very start of any
* update flow, BEFORE fetch/pull/reset.
*
* `core.fileMode` is LOCAL per-repo config (it never travels in a commit), so on
* a bind-mount/Docker volume that reports +x inconsistently it must be re-asserted
* on every run — hence a function, not a versioned file. With it set to false the
* phantom `old mode 100644 → new mode 100755` diffs (and the resulting
* `modified: <submodule>` in the parent) simply vanish from `git status`.
*
* @return array{ok:bool,log:array<string>,normalized:bool}
*/
function git_harden_repo(string $repo): array {
$log = [];
$all_false = true;
$before = trim(git_run(['config', '--get', 'core.fileMode'], $repo)['output']);
$set = git_run(['config', 'core.fileMode', 'false'], $repo);
$log[] = 'core.fileMode=false (repo principal) → '
. ($set['ok'] ? ($before === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU')
. ($set['ok'] || $set['output'] === '' ? '' : ': ' . $set['output']);
if (!$set['ok']) $all_false = false;
foreach (git_submodule_paths($repo) as $path) {
$full = $repo . '/' . $path;
if (!file_exists($full . '/.git')) {
$log[] = "core.fileMode (submódulo {$path}) → ignorado (não inicializado)";
continue;
}
$b = trim(git_run(['config', '--get', 'core.fileMode'], $full)['output']);
$rs = git_run(['config', 'core.fileMode', 'false'], $full);
$log[] = "core.fileMode=false (submódulo {$path}) → "
. ($rs['ok'] ? ($b === 'false' ? 'já normalizado' : 'normalizado') : 'FALHOU');
if (!$rs['ok']) $all_false = false;
}
return ['ok' => $all_false, 'log' => $log, 'normalized' => $all_false];
}
/**
* Bring submodules back in line with the SHAs pinned by the parent's index,
* discarding ONLY drift (in practice: filesystem-mode flips). `--force` is the
* point — it resets each submodule's working tree to the pinned commit. Because
* the pinned content is identical to what's checked out, nothing real is lost;
* the only thing thrown away is the phantom mode diff. `--init` also clones any
* submodule freshly added upstream.
*
* A submodule declared `ignore = all` in .gitmodules follows its branch instead
* (`--remote`) — see git_submodule_follows_branch().
*
* Safe and quiet when there are no submodules.
*
* @return array{ok:bool,log:array<string>}
*/
function git_sync_submodules(string $repo): array {
$paths = git_submodule_paths($repo);
if ($paths === []) {
return ['ok' => true, 'log' => ['(sem submódulos)']];
}
$log = [];
$sync = git_run(['submodule', 'sync', '--recursive'], $repo);
$log[] = 'git submodule sync --recursive → ' . ($sync['ok'] ? 'OK' : 'FALHOU');
if (trim($sync['output']) !== '') $log[] = $sync['output'];
$ok = $sync['ok'];
$pinned = $tracking = [];
foreach ($paths as $path) {
if (git_submodule_follows_branch($repo, $path)) $tracking[] = $path;
else $pinned[] = $path;
}
// Um submódulo com `ignore = all` segue o seu branch, não o commit gravado
// (é o que o .gitmodules da quack declara). Pô-lo no commit gravado era
// fazer a plataforma andar para trás a cada atualização.
foreach ([[$pinned, []], [$tracking, ['--remote']]] as [$group, $extra]) {
if (!$group) continue;
$args = array_merge(['submodule', 'update', '--init', '--recursive', '--force'], $extra, ['--'], $group);
$upd = git_run($args, $repo);
$log[] = 'git ' . implode(' ', $args) . ' → ' . ($upd['ok'] ? 'OK' : 'FALHOU');
if (trim($upd['output']) !== '') $log[] = $upd['output'];
$ok = $ok && $upd['ok'];
}
return ['ok' => $ok, 'log' => $log];
}
/**
* O submódulo segue o branch (`update --remote`) em vez do commit gravado? Sim
* quando o .gitmodules lhe põe `ignore = all`: o commit gravado deixa de ser
* vigiado, portanto só pode ser um ponto de partida, não uma versão a cumprir.
*/
function git_submodule_follows_branch(string $repo, string $path): bool {
$res = git_run(['config', '-f', '.gitmodules', '--get-regexp', '^submodule\..*\.path$'], $repo);
if (!$res['ok']) return false;
foreach (preg_split('/\R/', trim($res['output'])) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && $parts[1] === $path) {
$name = substr($parts[0], strlen('submodule.'), -strlen('.path'));
$ignore = git_run(['config', '-f', '.gitmodules', '--get', "submodule.$name.ignore"], $repo);
return $ignore['ok'] && trim($ignore['output']) === 'all';
}
}
return false;
}
/**
* True when a `git diff` contains ONLY executable-bit changes — i.e. every
* changed file is just `old mode …` / `new mode …` with no content hunks, no
* index lines, no rename/binary/submodule markers. An empty diff returns false
* (the caller decides what "no diff" means in its context).
*/
function git_diff_is_mode_only(string $diff): bool {
$diff = trim($diff);
if ($diff === '') return false;
foreach (preg_split('/\R/', $diff) as $line) {
if ($line === '') continue;
if (str_starts_with($line, 'diff --git ')) continue;
if (str_starts_with($line, 'old mode ')) continue;
if (str_starts_with($line, 'new mode ')) continue;
// Anything else (index/@@/+/-/Subproject commit/Binary/rename) is real.
return false;
}
return true;
}
/**
* Snapshot how the local repo diverges from origin/<branch>, classifying the
* divergence as either "safe to hard-reset" (only mode flips / submodule drift)
* or "real work that must never be discarded".
*
* Call this AFTER git_harden_repo() + a fetch (and ideally after
* git_sync_submodules()), so phantom mode diffs have already disappeared from
* `git status` and a leftover dirty entry genuinely means real content.
*
* @return array{
* branch:string, ahead:int, behind:int,
* content_dirty:bool, dirty_files:string,
* real_ahead:bool, ahead_log:string,
* safe_to_reset:bool
* }
*/
function git_remote_branches(string $repo): array {
$res = git_run(['for-each-ref', 'refs/remotes/origin', '--format=%(refname:short)'], $repo);
if (!$res['ok']) return [];
$branches = [];
foreach (explode(PHP_EOL, trim((string) $res['output'])) as $line) {
$line = trim($line);
if ($line === '' || $line === 'origin/HEAD') continue;
if (str_starts_with($line, 'origin/')) $line = substr($line, 7);
$branches[] = $line;
}
return array_values(array_unique($branches));
}
function git_local_state(string $repo, ?string $branch = null): array {
if ($branch === null || $branch === '') {
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
}
$ref = 'origin/' . $branch;
// Working-tree changes. Untracked files (??) are ignored: `reset --hard`
// never deletes them, so they don't make a reset unsafe.
$st = git_run(['status', '--porcelain'], $repo);
$dirty_lines = [];
$content_dirty = false;
foreach (preg_split('/\R/', $st['output']) as $line) {
if ($line === '') continue;
$dirty_lines[] = $line;
if (str_starts_with($line, '??')) continue;
$content_dirty = true;
}
$ahead = (int) trim(git_run(['rev-list', '--count', $ref . '..HEAD'], $repo)['output']);
$behind = (int) trim(git_run(['rev-list', '--count', 'HEAD..' . $ref], $repo)['output']);
// Are the local-only commits real, or just committed mode flips?
$real_ahead = false;
$ahead_log = '';
if ($ahead > 0) {
$ahead_log = git_run(['log', '--oneline', '--no-color', $ref . '..HEAD'], $repo)['output'];
// Three-dot: what the local commits introduced since the merge-base.
$diff = trim(git_run(['diff', '--no-color', $ref . '...HEAD'], $repo)['output']);
$real_ahead = $diff !== '' && !git_diff_is_mode_only($diff);
}
return [
'branch' => $branch,
'ahead' => $ahead,
'behind' => $behind,
'content_dirty' => $content_dirty,
'dirty_files' => implode("\n", $dirty_lines),
'real_ahead' => $real_ahead,
'ahead_log' => $ahead_log,
'safe_to_reset' => !$content_dirty && !$real_ahead,
];
}
/**
* The robust "Atualizar plataforma" git step. Non-destructive by default; only
* discards state when the divergence is provably nothing but mode/submodule drift.
*
* 1. git_harden_repo() — kill phantom mode diffs first.
* 2. git pull --ff-only — the happy path.
* 3. on failure: fetch, realign submodules, then classify the local state.
* · safe (mode/submodule only) → fetch + reset --hard origin/<branch>.
* · real changes/commits → STOP, report the divergence, discard nothing.
* 4. git submodule sync + update --init --recursive --force.
*
* @return array{ok:bool,needs_confirm:bool,output:string,state:?array}
*/
function git_safe_update(string $repo): array {
$log = [];
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
$log[] = '── Normalização de permissões (core.fileMode=false) ──';
foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l;
$log[] = '';
$log[] = '── git pull --ff-only ──';
$pull = git_run(['pull', '--ff-only'], $repo);
$log[] = $pull['output'] !== '' ? $pull['output'] : '(sem saída)';
if ($pull['ok']) {
$log[] = '';
$log[] = '── Submódulos ──';
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
return ['ok' => true, 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => null];
}
// Fast-forward refused — diverged or blocked by local state. Fetch, realign
// submodule working trees (so mode-drift inside them isn't mistaken for real
// changes), then classify.
$log[] = '';
$log[] = '── Fast-forward falhou — a analisar estado local ──';
$fetch = git_run(['fetch', 'origin', '--prune'], $repo);
if (trim($fetch['output']) !== '') $log[] = $fetch['output'];
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
$state = git_local_state($repo, $branch);
if ($state['safe_to_reset']) {
$log[] = '';
$log[] = "── Divergência apenas de modo/submódulo — alinhar com origin/{$branch} ──";
$reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo);
$log[] = trim($reset['output']) !== '' ? $reset['output'] : '(reset --hard concluído)';
if ($reset['ok']) {
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
}
return ['ok' => $reset['ok'], 'needs_confirm' => false, 'output' => implode("\n", $log), 'state' => $state];
}
// Real local work — refuse to touch it.
$log[] = '';
$log[] = '── ALTERAÇÕES LOCAIS REAIS — atualização interrompida (nada foi descartado) ──';
if ($state['content_dirty']) {
$log[] = 'Alterações de conteúdo não-comitadas:';
$log[] = $state['dirty_files'];
}
if ($state['real_ahead']) {
$log[] = "Commits locais por enviar (origin/{$branch}..HEAD):";
$log[] = $state['ahead_log'];
}
$log[] = '';
$log[] = 'Resolve manualmente (commit/push/stash) ou usa "Sincronização forçada"';
$log[] = 'com confirmação explícita para descartar o estado local e alinhar com o remoto.';
return ['ok' => false, 'needs_confirm' => true, 'output' => implode("\n", $log), 'state' => $state];
}
/**
* The explicit, user-confirmed destructive escape hatch: discard whatever is
* local and force the working tree to match origin/<branch>. Only ever reached
* from a UI action gated behind an explicit confirmation checkbox.
*
* @return array{ok:bool,output:string}
*/
function git_force_sync(string $repo): array {
$log = [];
$branch = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo)['output']);
foreach (git_harden_repo($repo)['log'] as $l) $log[] = $l;
$fetch = git_run(['fetch', 'origin', '--prune'], $repo);
$log[] = 'git fetch origin --prune → ' . ($fetch['ok'] ? 'OK' : 'FALHOU');
if (trim($fetch['output']) !== '') $log[] = $fetch['output'];
$reset = git_run(['reset', '--hard', 'origin/' . $branch], $repo);
$log[] = "git reset --hard origin/{$branch} → " . ($reset['ok'] ? 'OK' : 'FALHOU');
if (trim($reset['output']) !== '') $log[] = $reset['output'];
if ($reset['ok']) {
foreach (git_sync_submodules($repo)['log'] as $l) $log[] = $l;
}
return ['ok' => $reset['ok'], 'output' => implode("\n", $log)];
}