- 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>
155 lines
10 KiB
PHP
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])) ?>">← 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 →</a><?php else: ?><span></span><?php endif; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|