- 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>
445 lines
19 KiB
PHP
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)];
|
|
}
|