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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 07:28:13 +01:00
1 parent 5e300fc341
commit 0e8b53f620
65 files changed
+5852 -455

No files matched your search

@@ -316,8 +316,16 @@ function platform_smtp_config(PDO $pdo): array {
*
* A site overrides the segment by defining PLATFORM_ADMIN_DIR (or setting it in
* .env) to e.g. 'views/admin'. Default keeps the existing behaviour.
*
* A site on the front controller (`'router' => true` in platform_connect())
* gets platform.php?tool=… for every shared page; its own pages keep the dir.
*/
function platform_admin_url(string $page = ''): string {
if (function_exists('platform_router_enabled') && platform_router_enabled()) {
[$file, $query] = array_pad(explode('?', $page, 2), 2, '');
$tool = $file === '' ? 'index' : preg_replace('/\.php$/', '', basename($file));
if (isset(platform_routes()[$tool])) return platform_route_url($tool, $query);
}
$dir = 'views/sysadmin';
if (defined('PLATFORM_ADMIN_DIR') && constant('PLATFORM_ADMIN_DIR') !== '') {
$dir = (string) constant('PLATFORM_ADMIN_DIR');
@@ -329,3 +337,76 @@ function platform_admin_url(string $page = ''): string {
$path = trim($dir, '/') . ($page !== '' ? '/' . ltrim($page, '/') : '');
return function_exists('base_url') ? base_url($path) : '/' . $path;
}
/**
* A stable HMAC key for a named purpose.
*
* Signed form tokens (anti-spam stamps, captcha challenges) are issued in one
* request and verified in another, so the key behind them must be **the same
* across requests** -- a per-request key rejects every visitor with "invalid
* token" and the failure looks like a bot problem rather than a bug.
*
* Three sources, in order, all stable:
*
* 1. platform_secret_key() -- the .env key, the normal case;
* 2. a random value stored in system_settings, when there is no .env key;
* 3. derived from the install's own configuration, for an install with
* neither. It is a weaker secret than the other two, but it is constant,
* and a defence that locks every form is worse than a weaker signature.
*
* Always domain-separated by $purpose, so a stamp minted for one feature can
* never validate in another. Note that functions_antispam_forms.php keeps its
* own older derivation on purpose: changing the key of a live site invalidates
* the forms that are open at that instant, and those submissions are refused
* silently -- leads lost for the sake of tidiness. New callers use this one.
*
* Pass the PDO wherever you have one. Source 3 is a last resort, and a site
* that reaches it from one call site with a PDO and another without would sign
* with two different keys.
*/
function platform_derived_key(string $purpose, ?PDO $pdo = null, string $settingKey = 'shared_hmac_key'): string {
static $bases = [];
if (!isset($bases[$settingKey])) {
$base = '';
$envKey = platform_secret_key();
if ($envKey !== null) {
$base = bin2hex($envKey);
} elseif ($pdo instanceof PDO) {
$base = (string) platform_settings_get($pdo, $settingKey, '');
if ($base === '') {
$fresh = bin2hex(random_bytes(32));
try {
// Stored in the clear on purpose: encrypting it would need
// the key we do not have (that is why we are here), and a
// value that decrypts differently on each read means every
// token minted before this request stops verifying.
platform_settings_set($pdo, $settingKey, $fresh, null, false);
$base = $fresh;
} catch (Throwable $e) {
error_log('[settings] ' . $settingKey . ' not persisted (' . $e->getMessage()
. '); deriving from install config');
}
}
}
if ($base === '') {
$read = static function (string $k): string {
if (defined($k)) return (string) constant($k);
if (function_exists('env')) return (string) env($k, '');
return (string) (getenv($k) ?: ($_ENV[$k] ?? ''));
};
// Not cached: this branch is the fallback, and a later call that
// does carry a PDO must be free to find the real key instead of
// inheriting this one.
return hash('sha256', 'am-key|' . $purpose . '|derived|'
. $read('DB_NAME') . '|' . $read('DB_USER') . '|' . $read('DB_PASS')
. '|' . $read('APP_BASE_URL'), true);
}
$bases[$settingKey] = $base;
}
return hash('sha256', 'am-key|' . $purpose . '|' . $bases[$settingKey], true);
}