'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); }