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

349 lines
16 KiB
PHP

<?php
// ─── Registo de auditoria (partilhado) ──────────────────────────────────────
//
// Quem fez o quê, a quê, quando e de onde — numa tabela `audit_log` de cada
// site, escrita por uma função só:
//
// platform_audit($pdo, 'user.delete', [
// 'target_type' => 'user',
// 'target_id' => 42,
// 'target_label' => 'Maria Silva',
// 'summary' => 'Enviada para a reciclagem',
// 'changes' => platform_audit_diff($antes, $depois),
// ]);
//
// Porque existe: a frota tinha TRÊS registos incompatíveis (hub, dance, quack,
// cada um com as suas colunas e a sua função) e cinco sites sem nenhum. O
// mesmo argumento do anti-spam e dos segredos — cópias não ficam iguais,
// ficam parecidas.
//
// ─── As tabelas que já existiam ─────────────────────────────────────────────
//
// Não se renomeia nada. O escritor LÊ as colunas da tabela do site
// (information_schema, uma vez por pedido) e escreve cada campo canónico na
// coluna que o site declarou para ele — `platform_connect(['audit' => ['map' =>
// ['actor_id' => 'user_id', ...]]])`, como o `schema` da autenticação. Um campo
// sem coluna é simplesmente omitido; uma coluna NOT NULL sem valor por omissão
// recebe um vazio (a do quack, `summary`, rebentava o INSERT); os textos são
// cortados ao tamanho da coluna. As migrações de cada site acrescentam as
// colunas que faltam, e daí em diante nada se perde.
//
// Um site sem tabela recebe-a no primeiro uso, já na forma canónica
// (platform_audit_ensure_table()).
//
// ─── As regras ──────────────────────────────────────────────────────────────
//
// - **Nunca parte a ação.** Uma falha a escrever regista-se no error_log e o
// pedido segue: perde-se a linha, não a operação.
// - **Segredos nunca entram.** Qualquer chave de `changes` com cara de segredo
// (password, token, secret, key, hash, cookie, csrf) é substituída por
// «(oculto)», a qualquer profundidade.
// - **O autor vem da sessão**, em qualquer das formas da frota
// (current_user(), $_SESSION['user'], ou as chaves soltas do quack), e o IP é
// o verdadeiro (platform_client_ip()). Quem chama pode passar 'actor' quando
// sabe melhor — o hub a agir por ops, o webhook do deploy, o PIN do dance.
require_once __DIR__ . '/functions_client_ip.php';
/** Os campos canónicos, e a definição de cada um numa tabela nova. */
function platform_audit_columns(): array
{
return [
'actor_id' => 'BIGINT UNSIGNED NULL',
'actor_name' => 'VARCHAR(150) NULL',
'actor_role' => 'VARCHAR(30) NULL',
'action' => "VARCHAR(80) NOT NULL DEFAULT ''",
'target_type' => "VARCHAR(60) NOT NULL DEFAULT ''",
'target_id' => 'VARCHAR(64) NULL',
'target_label' => 'VARCHAR(250) NULL',
'summary' => 'VARCHAR(1000) NULL',
'changes' => 'LONGTEXT NULL',
'ip' => 'VARCHAR(45) NULL',
'user_agent' => 'VARCHAR(255) NULL',
];
}
/** Nome da tabela e mapa de colunas deste site (platform_connect 'audit'). */
function platform_audit_config(): array
{
$cfg = function_exists('platform_config') ? (array) (platform_config()['audit'] ?? []) : [];
return [
'table' => (string) ($cfg['table'] ?? 'audit_log'),
'map' => (array) ($cfg['map'] ?? []),
// Dias que uma linha vive; 0 = para sempre.
'keep_days' => (int) ($cfg['keep_days'] ?? 730),
];
}
function platform_audit_ensure_table(PDO $pdo, string $table): void
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) return;
$cols = '';
foreach (platform_audit_columns() as $name => $def) $cols .= " `$name` $def,\n";
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `$table` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
$cols `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_audit_created` (`created_at`),
KEY `idx_audit_actor` (`actor_id`, `created_at`),
KEY `idx_audit_action` (`action`, `created_at`),
KEY `idx_audit_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
/**
* As colunas reais da tabela: [nome => ['type' => …, 'max' => int|null,
* 'required' => bool]]. Uma consulta por pedido. Cria a tabela quando não há.
*/
function platform_audit_table_columns(PDO $pdo): array
{
static $cache = [];
$table = platform_audit_config()['table'];
if (isset($cache[$table])) return $cache[$table];
$read = function () use ($pdo, $table): array {
$st = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$st->execute([$table]);
$out = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$out[$c['COLUMN_NAME']] = [
'type' => strtolower((string) $c['DATA_TYPE']),
'max' => $c['CHARACTER_MAXIMUM_LENGTH'] !== null ? (int) $c['CHARACTER_MAXIMUM_LENGTH'] : null,
'required' => $c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
&& !str_contains((string) $c['EXTRA'], 'auto_increment'),
'not_null' => $c['IS_NULLABLE'] === 'NO',
];
}
return $out;
};
$cols = $read();
if (!$cols) {
platform_audit_ensure_table($pdo, $table);
$cols = $read();
}
return $cache[$table] = $cols;
}
/** A coluna do site para um campo canónico (o mapa, ou o próprio nome). */
function platform_audit_column(string $field): string
{
return (string) (platform_audit_config()['map'][$field] ?? $field);
}
/**
* Quem está a agir, a partir da sessão, em qualquer das formas da frota:
* current_user(), $_SESSION['user'], ou as chaves soltas user_id/username/role.
*/
function platform_audit_actor(): ?array
{
$u = null;
if (function_exists('current_user')) {
try { $u = current_user(); } catch (Throwable) { $u = null; }
}
if (!is_array($u) && session_status() === PHP_SESSION_ACTIVE && is_array($_SESSION['user'] ?? null)) {
$u = $_SESSION['user'];
}
if (is_array($u) && !empty($u['id'])) {
return [
'id' => (int) $u['id'],
'name' => (string) ($u['full_name'] ?? $u['name'] ?? $u['display_name'] ?? $u['username'] ?? $u['email'] ?? ''),
'role' => (string) ($u['role'] ?? ''),
];
}
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION['user_id'])) {
return [
'id' => (int) $_SESSION['user_id'],
'name' => (string) ($_SESSION['full_name'] ?? $_SESSION['username'] ?? ''),
'role' => (string) ($_SESSION['role'] ?? ''),
];
}
return null;
}
/** Tira os segredos de uma estrutura antes de ela ir para o registo. */
function platform_audit_redact(mixed $v, string $key = ''): mixed
{
if ($key !== '' && preg_match('/pass|token|secret|api_?key|ops_?key|hash|cookie|csrf|validator|pairing_code/i', $key)) {
// Oculta os valores mas mantém a forma: um {from, to} continua a ser um
// {from, to}, e a página continua a mostrá-lo como alteração.
if (is_array($v)) return array_map(fn($x) => ($x === null || $x === '') ? $x : '(oculto)', $v);
return ($v === null || $v === '') ? $v : '(oculto)';
}
if (is_array($v)) {
$out = [];
foreach ($v as $k => $x) $out[$k] = platform_audit_redact($x, is_string($k) ? $k : $key);
return $out;
}
return $v;
}
/**
* O que mudou entre duas versões de um registo: [campo => ['from' => …, 'to' => …]].
* Compara como texto, para um '1' da base de dados e um 1 do formulário não
* contarem como mudança. $ignore: campos a não comparar (updated_at, …).
*/
function platform_audit_diff(array $before, array $after, array $ignore = ['updated_at', 'created_at']): array
{
$out = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $k) {
if (in_array($k, $ignore, true)) continue;
$a = $before[$k] ?? null;
$b = $after[$k] ?? null;
if ((string) (is_scalar($a) || $a === null ? $a : json_encode($a)) === (string) (is_scalar($b) || $b === null ? $b : json_encode($b))) continue;
$out[$k] = ['from' => $a, 'to' => $b];
}
return $out;
}
/** Os campos de um registo novo, na forma do diff (a partir de nada). Vazios ficam de fora. */
function platform_audit_created(array $fields): array
{
$out = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '') continue;
$out[$k] = ['from' => null, 'to' => $v];
}
return $out;
}
/**
* Regista uma ação. Nunca lança.
*
* $d: target_type, target_id, target_label, summary, changes (array), e
* opcionalmente actor (['id','name','role'], ou false para «sistema»).
*/
function platform_audit(?PDO $pdo, string $action, array $d = []): void
{
if (!$pdo instanceof PDO) {
$pdo = function_exists('platform_pdo') ? platform_pdo() : null;
if (!$pdo instanceof PDO) return;
}
try {
$actor = array_key_exists('actor', $d) ? ($d['actor'] ?: null) : platform_audit_actor();
$changes = $d['changes'] ?? null;
if (is_array($changes)) {
$changes = $changes ? json_encode(platform_audit_redact($changes), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null;
}
$values = [
'actor_id' => $actor['id'] ?? null,
'actor_name' => $actor['name'] ?? null,
'actor_role' => $actor['role'] ?? null,
'action' => $action,
'target_type' => (string) ($d['target_type'] ?? ''),
'target_id' => isset($d['target_id']) ? (string) $d['target_id'] : null,
'target_label' => $d['target_label'] ?? null,
'summary' => $d['summary'] ?? null,
'changes' => $changes,
'ip' => platform_client_ip(),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : null,
];
$cols = platform_audit_table_columns($pdo);
if (!$cols) return;
$insert = [];
foreach ($values as $field => $v) {
$col = platform_audit_column($field);
if (!isset($cols[$col])) continue; // o site não tem coluna para isto
$c = $cols[$col];
if ($v !== null && in_array($c['type'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
$v = is_numeric($v) ? (int) $v : null; // um id que não é número não cabe numa coluna INT
}
if (is_string($v) && $c['max'] !== null && mb_strlen($v) > $c['max']) {
$v = mb_substr($v, 0, $c['max']);
}
if ($v === null && $c['not_null']) {
// NOT NULL: com valor por omissão, omite-se e vale o dela; sem ele, vazio.
if (!$c['required']) continue;
$v = '';
}
$insert[$col] = $v;
}
// Colunas obrigatórias do site que nada preencheu (NOT NULL sem valor por omissão).
foreach ($cols as $col => $c) {
if ($c['required'] && !array_key_exists($col, $insert) && $col !== 'id') $insert[$col] = '';
}
$table = platform_audit_config()['table'];
$names = implode(', ', array_map(fn($c) => "`$c`", array_keys($insert)));
$marks = implode(', ', array_fill(0, count($insert), '?'));
$pdo->prepare("INSERT INTO `$table` ($names) VALUES ($marks)")->execute(array_values($insert));
platform_audit_maybe_prune($pdo);
} catch (Throwable $e) {
error_log('[audit] não registado (' . $action . '): ' . $e->getMessage());
}
}
/** Apaga as linhas mais velhas do que keep_days — no máximo uma vez por dia e por pedido. */
function platform_audit_maybe_prune(PDO $pdo): void
{
static $done = false;
if ($done) return;
$done = true;
$keep = platform_audit_config()['keep_days'];
if ($keep <= 0 || random_int(1, 200) !== 1) return; // barato: ~1 em 200 escritas
try {
$table = platform_audit_config()['table'];
$col = 'created_at';
$pdo->prepare("DELETE FROM `$table` WHERE `$col` < DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 5000")->execute([$keep]);
} catch (Throwable $e) {
error_log('[audit] poda falhou: ' . $e->getMessage());
}
}
// ─── Leitura (a página audit.php) ───────────────────────────────────────────
/**
* Linhas do registo com os nomes canónicos, seja qual for a forma da tabela do
* site. Filtros: actor (texto), action (prefixo), target_type, target_id,
* from/to (Y-m-d), q (texto livre).
*/
function platform_audit_query(PDO $pdo, array $f = [], int $limit = 100, int $offset = 0): array
{
$cols = platform_audit_table_columns($pdo);
$table = platform_audit_config()['table'];
$sel = ['`id`', '`created_at`'];
$has = [];
foreach (array_keys(platform_audit_columns()) as $field) {
$col = platform_audit_column($field);
if (isset($cols[$col])) { $sel[] = "`$col` AS `$field`"; $has[$field] = "`$col`"; }
else { $sel[] = "NULL AS `$field`"; }
}
$where = []; $params = [];
if (($f['actor'] ?? '') !== '' && isset($has['actor_name'])) { $where[] = "{$has['actor_name']} LIKE ?"; $params[] = '%' . $f['actor'] . '%'; }
if (($f['action'] ?? '') !== '') { $where[] = "{$has['action']} LIKE ?"; $params[] = $f['action'] . '%'; }
if (($f['target_type'] ?? '') !== '' && isset($has['target_type'])) { $where[] = "{$has['target_type']} = ?"; $params[] = $f['target_type']; }
if (($f['target_id'] ?? '') !== '' && isset($has['target_id'])) { $where[] = "{$has['target_id']} = ?"; $params[] = $f['target_id']; }
if (($f['from'] ?? '') !== '') { $where[] = '`created_at` >= ?'; $params[] = $f['from'] . ' 00:00:00'; }
if (($f['to'] ?? '') !== '') { $where[] = '`created_at` <= ?'; $params[] = $f['to'] . ' 23:59:59'; }
if (($f['q'] ?? '') !== '') {
$like = [];
foreach (['target_label', 'summary', 'changes'] as $field) if (isset($has[$field])) $like[] = "{$has[$field]} LIKE ?";
if ($like) { $where[] = '(' . implode(' OR ', $like) . ')'; foreach ($like as $_) $params[] = '%' . $f['q'] . '%'; }
}
$sql = 'SELECT ' . implode(', ', $sel) . " FROM `$table`"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY `created_at` DESC, `id` DESC LIMIT ' . max(1, min(1000, $limit)) . ' OFFSET ' . max(0, $offset);
$st = $pdo->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Os valores distintos de um campo, para os filtros (ações, tipos). */
function platform_audit_distinct(PDO $pdo, string $field, int $limit = 200): array
{
$cols = platform_audit_table_columns($pdo);
$col = platform_audit_column($field);
if (!isset($cols[$col])) return [];
$table = platform_audit_config()['table'];
return $pdo->query("SELECT DISTINCT `$col` FROM `$table` WHERE `$col` IS NOT NULL AND `$col` <> '' ORDER BY `$col` LIMIT " . (int) $limit)
->fetchAll(PDO::FETCH_COLUMN);
}