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

155 lines
10 KiB
PHP

<?php
// ─── Registo de auditoria (página partilhada) ───────────────────────────────
//
// /platform?tool=audit. Lê o `audit_log` do site pela forma canónica
// (platform_audit_query(), functions_audit.php), seja qual for a forma da
// tabela, e mostra quem fez o quê, a quê e quando, com o antes e o depois de
// cada alteração. Só leitura. Exporta o filtro em CSV.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('audit.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_audit.php';
$filters = [
'actor' => trim((string) ($_GET['actor'] ?? '')),
'action' => trim((string) ($_GET['action'] ?? '')),
'target_type' => trim((string) ($_GET['target_type'] ?? '')),
'target_id' => trim((string) ($_GET['target_id'] ?? '')),
'q' => trim((string) ($_GET['q'] ?? '')),
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['from'] ?? '')) ? (string) $_GET['from'] : '',
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['to'] ?? '')) ? (string) $_GET['to'] : '',
];
$perPage = 50;
$page = max(1, (int) ($_GET['p'] ?? 1));
// Exportação: o filtro inteiro (até 5000 linhas), em CSV que o Excel abre em pt.
if (($_GET['export'] ?? '') === 'csv') {
$rows = platform_audit_query($pdo, $filters, 5000);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="auditoria-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Quando', 'Quem', 'Papel', 'Ação', 'Tipo', 'Id', 'Alvo', 'Resumo', 'Alterações', 'IP'], ';');
foreach ($rows as $r) {
fputcsv($out, [$r['created_at'], $r['actor_name'], $r['actor_role'], $r['action'], $r['target_type'],
$r['target_id'], $r['target_label'], $r['summary'], $r['changes'], $r['ip']], ';');
}
fclose($out);
exit;
}
$rows = platform_audit_query($pdo, $filters, $perPage + 1, ($page - 1) * $perPage);
$hasNext = count($rows) > $perPage;
$rows = array_slice($rows, 0, $perPage);
$actions = platform_audit_distinct($pdo, 'action');
$types = platform_audit_distinct($pdo, 'target_type');
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$link = function (array $q) use ($selfUrl, $filters): string {
$base = isset($_GET['tool']) ? ['tool' => (string) $_GET['tool']] : [];
return $selfUrl . '?' . http_build_query($base + array_filter($q + $filters, fn($v) => $v !== '' && $v !== null));
};
/** Uma alteração em HTML: tabela campo / antes / depois, ou o texto tal como veio. */
$renderChanges = function (?string $json): string {
if ($json === null || $json === '') return '';
$data = json_decode($json, true);
if (!is_array($data)) return '<pre class="small mb-0">' . htmlspecialchars($json) . '</pre>';
$fmt = fn($v) => $v === null ? '<span class="text-muted">—</span>'
: htmlspecialchars(is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
$isDiff = $data && array_reduce($data, fn($ok, $v) => $ok && is_array($v) && (array_key_exists('from', $v) || array_key_exists('to', $v)), true);
if (!$isDiff) return '<pre class="small mb-0" style="white-space:pre-wrap">' . htmlspecialchars(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
$h = '<table class="table table-sm mb-0 small"><thead><tr><th>Campo</th><th>Antes</th><th>Depois</th></tr></thead><tbody>';
foreach ($data as $k => $v) {
$h .= '<tr><td>' . htmlspecialchars((string) $k) . '</td><td class="text-danger">' . $fmt($v['from'] ?? null) . '</td><td class="text-success">' . $fmt($v['to'] ?? null) . '</td></tr>';
}
return $h . '</tbody></table>';
};
$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('platform_admin_url') ? platform_admin_url('index.php') : 'index.php');
$pageTitle = 'Registo de auditoria';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
?>
<div class="container py-4" style="max-width:1200px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-journal-text me-2"></i>Registo de auditoria</h2>
<p class="text-muted small mb-0">Quem fez o quê, a quê e quando. Os segredos nunca entram no registo.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['export' => 'csv'])) ?>"><i class="bi bi-download me-1"></i>Exportar CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($dashboard_url) ?>">Voltar</a>
</div>
</div>
<form method="get" class="card card-body mb-3">
<?php if (isset($_GET['tool'])): ?><input type="hidden" name="tool" value="<?= htmlspecialchars((string) $_GET['tool']) ?>"><?php endif; ?>
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small mb-1">Quem</label>
<input class="form-control form-control-sm" name="actor" value="<?= htmlspecialchars($filters['actor']) ?>" placeholder="nome"></div>
<div class="col-md-2"><label class="form-label small mb-1">Ação</label>
<input class="form-control form-control-sm" name="action" list="audit-actions" value="<?= htmlspecialchars($filters['action']) ?>" placeholder="ex.: user.">
<datalist id="audit-actions"><?php foreach ($actions as $a): ?><option value="<?= htmlspecialchars((string) $a) ?>"><?php endforeach; ?></datalist></div>
<div class="col-md-2"><label class="form-label small mb-1">Tipo</label>
<select class="form-select form-select-sm" name="target_type"><option value="">Todos</option>
<?php foreach ($types as $t): ?><option value="<?= htmlspecialchars((string) $t) ?>" <?= $filters['target_type'] === (string) $t ? 'selected' : '' ?>><?= htmlspecialchars((string) $t) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-2"><label class="form-label small mb-1">Texto</label>
<input class="form-control form-control-sm" name="q" value="<?= htmlspecialchars($filters['q']) ?>" placeholder="alvo, resumo…"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">De</label>
<input type="date" class="form-control form-control-sm" name="from" value="<?= htmlspecialchars($filters['from']) ?>"></div>
<div class="col-6 col-md-1"><label class="form-label small mb-1">Até</label>
<input type="date" class="form-control form-control-sm" name="to" value="<?= htmlspecialchars($filters['to']) ?>"></div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1">Filtrar</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['actor' => '', 'action' => '', 'target_type' => '', 'target_id' => '', 'q' => '', 'from' => '', 'to' => ''])) ?>">Limpar</a>
</div>
</div>
</form>
<div class="card">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead><tr><th>Quando</th><th>Quem</th><th>Ação</th><th>Alvo</th><th>Resumo</th><th>IP</th></tr></thead>
<tbody>
<?php if (!$rows): ?><tr><td colspan="6" class="text-muted p-4 text-center">Nada no registo com este filtro.</td></tr><?php endif; ?>
<?php foreach ($rows as $r): $ch = $renderChanges($r['changes']); ?>
<tr>
<td class="small text-nowrap"><?= htmlspecialchars(date('d/m/Y H:i:s', strtotime((string) $r['created_at']))) ?></td>
<td class="small"><?= $r['actor_name'] !== null && $r['actor_name'] !== '' ? htmlspecialchars((string) $r['actor_name']) : '<span class="text-muted">sistema</span>' ?>
<?php if (!empty($r['actor_role'])): ?><div class="text-muted"><?= htmlspecialchars((string) $r['actor_role']) ?></div><?php endif; ?></td>
<td class="small"><a href="<?= htmlspecialchars($link(['action' => (string) $r['action'], 'p' => null])) ?>"><code><?= htmlspecialchars((string) $r['action']) ?></code></a></td>
<td class="small">
<?php if (!empty($r['target_type'])): ?><a class="text-muted" href="<?= htmlspecialchars($link(['target_type' => (string) $r['target_type'], 'target_id' => (string) ($r['target_id'] ?? ''), 'p' => null])) ?>"><?= htmlspecialchars((string) $r['target_type']) ?><?= $r['target_id'] !== null && $r['target_id'] !== '' ? ' #' . htmlspecialchars((string) $r['target_id']) : '' ?></a><?php endif; ?>
<?php if (!empty($r['target_label'])): ?><div><?= htmlspecialchars((string) $r['target_label']) ?></div><?php endif; ?>
</td>
<td class="small">
<?= htmlspecialchars((string) ($r['summary'] ?? '')) ?>
<?php if ($ch !== ''): ?>
<details class="mt-1"><summary class="text-muted" style="cursor:pointer">alterações</summary><?= $ch ?></details>
<?php endif; ?>
</td>
<td class="small text-muted"><?= htmlspecialchars((string) ($r['ip'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($page > 1 || $hasNext): ?>
<div class="card-footer d-flex justify-content-between">
<?php if ($page > 1): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page - 1])) ?>">&larr; Mais recentes</a><?php else: ?><span></span><?php endif; ?>
<span class="small text-muted align-self-center">página <?= $page ?></span>
<?php if ($hasNext): ?><a class="btn btn-sm btn-outline-secondary" href="<?= htmlspecialchars($link(['p' => $page + 1])) ?>">Mais antigas &rarr;</a><?php else: ?><span></span><?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>