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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 07:28:13 +01:00
1 parent 5e300fc341
commit 0e8b53f620
65 files changed
+5852 -455

No files matched your search

+155 -9
View File
@@ -15,6 +15,11 @@
// backup_trigger(string $type): bool ($type in {files, db})
// backup_type_label(string): string
// backup_type_badge(string): string
// backup_retention_policy(): array
// backup_retention_summary(?array $policy = null): string
// backup_created_at(string $filename, int $fallback): int
// backup_gfs_keep(array $items, array $policy, int $now): array
// backup_prune(bool $dryRun = false): array
function backup_dir(): string {
$dir = dirname(__DIR__, 2) . '/backups';
@@ -113,26 +118,21 @@ function backup_trigger(string $type): bool {
// Where the self-call lands. Default assumes shared/ is inside the document
// root. Under a public/ docroot it is not, so hosts point this at a thin
// wrapper instead. Constant wins (fotografia defines it in
// config/functions_backup.php); .env covers hosts with no such file (surf).
// config/functions_backup.php); .env covers hosts with no such file.
$prefix = defined('BACKUP_TRIGGER_URL_PREFIX')
? BACKUP_TRIGGER_URL_PREFIX
: (function_exists('env')
? ((string) (env('BACKUP_TRIGGER_URL_PREFIX') ?: 'shared/platform-tools/backups/'))
: 'shared/platform-tools/backups/');
// Host base-URL helpers differ: fotografia and the config/-stack sites
// expose base_url(); surf exposes getBaseUrl(). Calling base_url()
// unconditionally fataled with "undefined function" on the latter, before
// any backup could be triggered. Fall back to APP_BASE_URL from .env so a
// host with neither helper still works.
// base_url() when the host has one; APP_BASE_URL from .env otherwise, so a
// host without the helper still works.
$path = ltrim($prefix . $script, '/');
if (function_exists('base_url')) {
$url = base_url($path);
} elseif (function_exists('getBaseUrl')) {
$url = rtrim(getBaseUrl(), '/') . '/' . $path;
} else {
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : (string) getenv('APP_BASE_URL');
if ($base === '') {
error_log('backup_trigger: no base URL available (no base_url(), no getBaseUrl(), no APP_BASE_URL).');
error_log('backup_trigger: no base URL available (no base_url(), no APP_BASE_URL).');
return false;
}
$url = rtrim($base, '/') . '/' . $path;
@@ -165,6 +165,152 @@ function backup_trigger(string $type): bool {
return $ok;
}
// ─── Retenção avô-pai-filho ─────────────────────────────────────────────────
//
// Antes: tudo o que tivesse mais de BACKUP_KEEP_DAYS dias era apagado, e tudo o
// que tivesse menos ficava — incluindo cinco cópias do mesmo dia (a da noite, a
// de antes de atualizar, as do hub, as de segurança de um restauro). Num site
// com galerias são gigabytes por cópia.
//
// Agora, por tipo (db e ficheiros contam em separado):
// • tudo das últimas 24 h — são os pontos de restauro de uma atualização;
// • a mais recente de cada um dos últimos BACKUP_KEEP_DAYS dias com cópia;
// • a mais recente de cada uma das últimas BACKUP_KEEP_WEEKS semanas;
// • a mais recente de cada um dos últimos BACKUP_KEEP_MONTHS meses;
// • e a mais recente de todas, sempre.
//
// Cada escalão começa onde o anterior acaba: as semanais são semanas mais
// antigas do que a diária mais antiga, e as mensais meses mais antigos do que a
// semanal mais antiga. Sem isto a «mensal» era a cópia mais recente do mês
// corrente — que já é a diária de hoje — e BACKUP_KEEP_MONTHS=1 não guardava
// nada. Com 7/4/1 cobre-se cerca de dois meses.
//
// «Dias com cópia», e não «dias de calendário»: se as cópias pararem durante um
// mês, a poda não pode ficar sem nada para guardar — é exactamente quando as
// antigas são as únicas que há.
function backup_retention_policy(): array {
$read = static function (string $key, int $default): int {
if (defined($key)) return max(0, (int) constant($key));
$v = function_exists('env') ? env($key, null) : (getenv($key) !== false ? getenv($key) : null);
return ($v === null || $v === '') ? $default : max(0, (int) $v);
};
return [
'recent_hours' => 24,
'days' => $read('BACKUP_KEEP_DAYS', 7),
'weeks' => $read('BACKUP_KEEP_WEEKS', 4),
'months' => $read('BACKUP_KEEP_MONTHS', 1),
];
}
/**
* A data da cópia pelo nome do ficheiro, que não muda quando o ficheiro é
* copiado ou restaurado de outro disco — ao contrário do mtime.
*/
function backup_created_at(string $filename, int $fallback): int {
if (preg_match('/_(\d{2})_(\d{2})_(\d{4})_(\d{2})_(\d{2})(?:_(\d{2}))?(?:_[0-9a-f]{6})?\.zip$/', $filename, $m)) {
$ts = mktime((int) $m[4], (int) $m[5], (int) ($m[6] ?? 0), (int) $m[2], (int) $m[1], (int) $m[3]);
if ($ts !== false) return $ts;
}
return $fallback;
}
/**
* Que cópias ficam. Função pura: recebe a lista (filename, type, created_at) e
* devolve [filename => motivo]. O que não vier na resposta é para apagar.
*/
function backup_gfs_keep(array $items, array $policy, int $now): array {
$tiers = [
'days' => ['Y-m-d', 'diária'],
'weeks' => ['o-\WW', 'semanal'],
'months' => ['Y-m', 'mensal'],
];
$byType = [];
foreach ($items as $it) $byType[$it['type']][] = $it;
$keep = [];
foreach ($byType as $list) {
usort($list, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
$keep[$list[0]['filename']] = 'mais recente';
foreach ($list as $it) {
if ($now - (int) $it['created_at'] < $policy['recent_hours'] * 3600) {
$keep[$it['filename']] ??= 'últimas ' . $policy['recent_hours'] . ' h';
}
}
// $floor: a cópia mais antiga guardada pelo escalão anterior. O escalão
// seguinte só olha para o que é mais antigo, e salta o balde dela — esse
// período já está representado.
$floor = null;
foreach ($tiers as $tier => [$fmt, $label]) {
$skip = $floor === null ? null : date($fmt, $floor);
$buckets = [];
$oldest = null;
// Da mais recente para a mais antiga: a primeira que cai num balde é
// a mais recente desse dia/semana/mês.
foreach ($list as $it) {
$t = (int) $it['created_at'];
if ($floor !== null && $t >= $floor) continue;
if (count($buckets) >= $policy[$tier]) break;
$key = date($fmt, $t);
if ($key === $skip || isset($buckets[$key])) continue;
$buckets[$key] = true;
$keep[$it['filename']] ??= $label;
$oldest = $t;
}
if ($oldest !== null) $floor = $oldest;
}
}
return $keep;
}
/**
* Aplica a retenção à pasta de cópias. Com $dryRun não apaga nada — diz o que
* apagaria. Devolve ['policy', 'kept' => [[filename, reason]], 'deleted', 'errors'].
*/
function backup_prune(bool $dryRun = false): array {
$dir = backup_dir();
$policy = backup_retention_policy();
$now = time();
$items = [];
foreach (backup_list() as $b) {
$b['created_at'] = backup_created_at($b['filename'], (int) $b['created_at']);
$items[] = $b;
}
$keep = backup_gfs_keep($items, $policy, $now);
$out = ['policy' => $policy, 'kept' => [], 'deleted' => [], 'errors' => []];
foreach ($items as $b) {
if (isset($keep[$b['filename']])) {
$out['kept'][] = ['filename' => $b['filename'], 'reason' => $keep[$b['filename']]];
} elseif ($dryRun || @unlink($dir . '/' . $b['filename'])) {
$out['deleted'][] = $b['filename'];
} else {
$out['errors'][] = $b['filename'];
}
}
// .sql de dumps interrompidos antes de irem para o ZIP: texto em claro que
// não aparece na lista. Um dia chega para não apanhar um dump a decorrer.
foreach (glob($dir . '/db_backup_*.sql') ?: [] as $f) {
if (filemtime($f) < $now - 86400) {
if ($dryRun || @unlink($f)) $out['deleted'][] = basename($f);
else $out['errors'][] = basename($f);
}
}
return $out;
}
/** Linha legível da política, para a página e para o log dos workers. */
function backup_retention_summary(?array $policy = null): string {
$p = $policy ?? backup_retention_policy();
return sprintf('tudo das últimas %d h, e a mais recente de cada um dos últimos %d dias, %d semanas e %d meses',
$p['recent_hours'], $p['days'], $p['weeks'], $p['months']);
}
function backup_type_label(string $type): string {
return match($type) {
'db' => 'Base de dados',