- 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>
349 lines
16 KiB
PHP
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);
|
|
}
|