- 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>
428 lines
20 KiB
PHP
428 lines
20 KiB
PHP
<?php
|
|
// ─── Conector: uma chamada para ligar uma app ao platform-tools ─────────────
|
|
//
|
|
// Até aqui o platform-tools adivinhava em que site estava: procurava um $pdo
|
|
// global, depois \App\Database (surf), depois um base_url(), um getBaseUrl(), um
|
|
// APP_BASE_URL. Cada ferramenta nova tinha de se lembrar das variantes. Com o
|
|
// conector é o site que DECLARA, uma vez, no seu arranque:
|
|
//
|
|
// require_once __DIR__ . '/../shared/platform-tools/functions_connector.php';
|
|
// platform_connect($pdo, [
|
|
// 'base_url' => APP_BASE_URL,
|
|
// 'schema' => ['name' => 'full_name'], // só o que difere
|
|
// 'roles' => ['staff' => 40, 'client' => 20],
|
|
// 'remember' => true,
|
|
// 'contact_email' => 'geral@exemplo.pt',
|
|
// ]);
|
|
//
|
|
// e o conector liga a autenticação partilhada, o «manter sessão», o bloqueio de
|
|
// IP e os eventos de segurança, e fica com o que as outras ferramentas precisam
|
|
// de saber (PDO, URL, ambiente, guarda, layout).
|
|
//
|
|
// CONFORMIDADE
|
|
//
|
|
// platform_conformance() compara a base de dados com o que o código partilhado
|
|
// espera. Só procura avarias SILENCIOSAS — as que não dão erro nenhum e deixam
|
|
// uma defesa desligada. O exemplo que o motivou: o auth_attempts da fotografia
|
|
// tem `ip, scope` e não `identifier`; o throttle partilhado falharia a consulta
|
|
// e, por desenho, deixaria entrar — o throttle desligava-se sem ninguém saber.
|
|
//
|
|
// Em desenvolvimento, platform_connect() recusa-se a arrancar com um erro de
|
|
// conformidade (excepto nas páginas de sistema e na API, onde está o runner de
|
|
// migrações que o corrige). Em produção nunca: vai para o health do hub, como o
|
|
// check `conformance`.
|
|
//
|
|
// Este ficheiro só declara funções — o functions_auth.php inclui-o para ler os
|
|
// nomes padrão das colunas, e não pode arrastar nada consigo.
|
|
|
|
require_once __DIR__ . '/functions_routes.php';
|
|
// platform_audit() fica disponível a todo o site ligado (e às páginas e à API
|
|
// do platform-tools). Só declara funções — nada corre ao incluir.
|
|
require_once __DIR__ . '/functions_audit.php';
|
|
|
|
const PLATFORM_DEV_ENVS = ['dev', 'development', 'local', 'staging', 'test'];
|
|
|
|
class PlatformConformanceError extends RuntimeException {}
|
|
|
|
/** Os nomes padrão da tabela de contas. auth_schema() parte daqui. */
|
|
function platform_auth_schema_defaults(): array {
|
|
return [
|
|
'table' => 'users',
|
|
'id' => 'id',
|
|
'email' => 'email',
|
|
// A coluna do formulário de login. null => `email`. A quack entra por
|
|
// `username` e continua a precisar de `email` para a recuperação.
|
|
'login' => null,
|
|
'password' => 'password_hash',
|
|
'name' => 'name',
|
|
'role' => 'role',
|
|
'active' => 'is_active', // null => o site não tem esta coluna
|
|
'deleted_at' => null, // o site apaga por soft-delete
|
|
'last_login' => 'last_login_at',
|
|
'last_ip' => 'last_login_ip',
|
|
];
|
|
}
|
|
|
|
// ─── Registo ─────────────────────────────────────────────────────────────────
|
|
|
|
function platform_config(?array $set = null): array {
|
|
static $cfg = [];
|
|
if ($set !== null) $cfg = $set;
|
|
return $cfg;
|
|
}
|
|
|
|
function platform_connected(): bool {
|
|
return platform_config() !== [];
|
|
}
|
|
|
|
function platform_pdo(): ?PDO {
|
|
$pdo = platform_config()['pdo'] ?? null;
|
|
return $pdo instanceof PDO ? $pdo : null;
|
|
}
|
|
|
|
function platform_env(): string {
|
|
$env = platform_config()['env'] ?? null;
|
|
if ($env === null || $env === '') {
|
|
$env = function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: '');
|
|
}
|
|
// Sem APP_ENV é produção: o lado seguro, em que nada rebenta de propósito.
|
|
return strtolower(trim((string) $env)) ?: 'prod';
|
|
}
|
|
|
|
function platform_is_dev(): bool {
|
|
return in_array(platform_env(), PLATFORM_DEV_ENVS, true);
|
|
}
|
|
|
|
// ─── Ligação ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Liga o site ao platform-tools. Chamar uma vez, no arranque, depois de abrir o
|
|
* PDO. $pdo null é uma montra estática: não há auth, bloqueio nem eventos.
|
|
*
|
|
* Opções (todas opcionais):
|
|
* base_url URL absoluto da app. Por omissão APP_BASE_URL.
|
|
* env 'prod' | 'dev' | … Por omissão APP_ENV; sem ele, 'prod'.
|
|
* auth 'shared' (functions_auth.php) ou 'own' (o site tem a sua).
|
|
* schema o que difere de platform_auth_schema_defaults().
|
|
* roles papéis próprios => nível (os de base são sysadmin/admin/user).
|
|
* session opções de auth_session_start(); false = o site abre a sua.
|
|
* remember true para repor sessões do «manter sessão iniciada»; ou as
|
|
* opções de auth_remember_resume() ('login', 'allow').
|
|
* A caixa e o olho do formulário: functions_login_ui.php.
|
|
* ip_guard false desliga o bloqueio de IP à porta.
|
|
* contact_email o email mostrado a quem for bloqueado por engano.
|
|
* guard callable que exige sysadmin (as páginas partilhadas).
|
|
* layout ['header' => …, 'navbar' => …, 'footer' => …].
|
|
* conformance 'auto' (recusa em dev) ou 'report' (só reporta).
|
|
* conformance_exempt fragmentos de caminho onde dev não recusa.
|
|
* router true quando o site tem o public/platform.php (router.php):
|
|
* os links para as páginas partilhadas passam a ir por lá.
|
|
* (O public/api/platform.php — api/router.php — é o mesmo
|
|
* para a API.)
|
|
* clean_urls true quando o .htaccess do site serve `/platform` a partir de
|
|
* `platform.php`: os URLs do router saem sem `.php`.
|
|
* api_files implementação própria de um endpoint: [nome => ficheiro].
|
|
* audit o registo de auditoria (functions_audit.php): ['table' =>
|
|
* 'audit_log', 'map' => [campo canónico => coluna do site],
|
|
* 'keep_days' => 730]. Só o que difere; sem nada, a tabela
|
|
* canónica `audit_log`, criada no primeiro uso.
|
|
* dashboard_url, migrations_dir, sysadmin_tools, hide_tools, page_vars —
|
|
* o que o router passa às páginas; ver router.php.
|
|
*/
|
|
function platform_connect(?PDO $pdo, array $cfg = []): void {
|
|
if (platform_connected()) return;
|
|
|
|
$cfg += [
|
|
'base_url' => null,
|
|
'env' => null,
|
|
'auth' => 'shared',
|
|
'schema' => [],
|
|
'roles' => [],
|
|
'session' => [],
|
|
'remember' => false,
|
|
'ip_guard' => true,
|
|
'contact_email' => null,
|
|
'guard' => null,
|
|
'layout' => [],
|
|
'conformance' => 'auto',
|
|
// O runner de migrações vive nas páginas de sistema, e o health na API:
|
|
// recusar lá deixava o site sem a ferramenta que o conserta.
|
|
'conformance_exempt' => ['/api/', '/sysadmin/', '/setup.php', '/platform.php'],
|
|
'router' => false,
|
|
'clean_urls' => false,
|
|
];
|
|
if (!in_array($cfg['auth'], ['shared', 'own'], true)) {
|
|
throw new InvalidArgumentException("platform_connect: auth tem de ser 'shared' ou 'own'");
|
|
}
|
|
$cfg['pdo'] = $pdo;
|
|
platform_config($cfg);
|
|
|
|
if ($cfg['auth'] === 'shared') {
|
|
require_once __DIR__ . '/functions_auth.php';
|
|
if ($cfg['schema']) auth_schema($cfg['schema']);
|
|
if ($cfg['roles']) auth_roles_register($cfg['roles']);
|
|
if ($cfg['session'] !== false) auth_session_start((array) $cfg['session']);
|
|
}
|
|
|
|
if (!$pdo instanceof PDO) return;
|
|
|
|
if ($cfg['conformance'] === 'auto' && platform_is_dev()) {
|
|
platform_conformance_enforce($pdo);
|
|
}
|
|
if ($cfg['ip_guard']) {
|
|
require_once __DIR__ . '/functions_ip_ban.php';
|
|
platform_ip_guard($pdo, $cfg['contact_email']);
|
|
}
|
|
if ($cfg['auth'] === 'shared' && $cfg['remember']) {
|
|
require_once __DIR__ . '/functions_remember.php';
|
|
// Um array em vez de true são as opções de auth_remember_resume() — o
|
|
// gancho 'login' de um site que guarda mais na sessão (a silver:
|
|
// full_name e avatar), ou o 'allow' de quem tem regras próprias.
|
|
auth_remember_resume($pdo, is_array($cfg['remember']) ? $cfg['remember'] : []);
|
|
}
|
|
}
|
|
|
|
function platform_conformance_enforce(PDO $pdo): void {
|
|
if (PHP_SAPI === 'cli') return;
|
|
|
|
$r = platform_conformance($pdo);
|
|
if ($r['ok']) return;
|
|
|
|
$errors = array_values(array_filter($r['issues'], fn($i) => $i['level'] === 'error'));
|
|
$lines = [];
|
|
foreach ($errors as $i) {
|
|
$lines[] = '- ' . $i['message'] . ($i['fix'] !== null ? "\n " . $i['fix'] : '');
|
|
}
|
|
$msg = "platform-tools: a base de dados não está conforme (" . count($errors) . " erro(s)).\n"
|
|
. implode("\n", $lines)
|
|
. "\nAplique as correcções numa migração (ou mapeie a coluna em auth_schema()).";
|
|
|
|
$path = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? '');
|
|
foreach ((array) (platform_config()['conformance_exempt'] ?? []) as $frag) {
|
|
if ($frag !== '' && str_contains($path, (string) $frag)) {
|
|
error_log('[conformance] ' . str_replace("\n", ' | ', $msg));
|
|
return;
|
|
}
|
|
}
|
|
throw new PlatformConformanceError($msg);
|
|
}
|
|
|
|
// ─── Conformidade ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* As tabelas que o código partilhado escreve. `required`: colunas que ele lê ou
|
|
* escreve, com a definição para as acrescentar. `optional`: detectadas em tempo
|
|
* de execução, a falta não parte nada. `insert`: o que ele põe num INSERT — uma
|
|
* coluna NOT NULL sem valor por omissão fora desta lista faz o INSERT falhar.
|
|
*
|
|
* Todas são criadas no primeiro uso quando não existem; o problema é só quando
|
|
* existem com outra forma.
|
|
*/
|
|
function platform_standard_tables(): array {
|
|
return [
|
|
'auth_attempts' => [
|
|
'required' => [
|
|
'identifier' => "VARCHAR(190) NOT NULL DEFAULT ''",
|
|
'ip' => 'VARCHAR(45) NULL',
|
|
'attempted_at' => 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
|
],
|
|
'optional' => ['success' => 'TINYINT(1) NOT NULL DEFAULT 0'],
|
|
'insert' => ['identifier', 'ip'],
|
|
'why' => 'o throttle de login partilhado deixa entrar quando a consulta falha',
|
|
],
|
|
'auth_remember_tokens' => [
|
|
'required' => [
|
|
'user_id' => 'BIGINT UNSIGNED NOT NULL',
|
|
'selector' => 'CHAR(32) NOT NULL',
|
|
'validator_hash' => 'CHAR(64) NOT NULL',
|
|
'expires_at' => 'DATETIME NOT NULL',
|
|
'last_used_at' => 'DATETIME NULL',
|
|
'last_ip' => 'VARCHAR(45) NULL',
|
|
'user_agent' => 'VARCHAR(255) NULL',
|
|
],
|
|
'optional' => [],
|
|
'insert' => ['user_id', 'selector', 'validator_hash', 'expires_at', 'last_ip', 'user_agent'],
|
|
'why' => '«manter sessão iniciada» não emite nem repõe sessões',
|
|
],
|
|
'ip_bans' => [
|
|
'required' => [
|
|
'ip' => 'VARCHAR(45) NOT NULL',
|
|
'reason' => 'VARCHAR(190) NOT NULL',
|
|
'source' => "VARCHAR(40) NOT NULL DEFAULT 'auto'",
|
|
'expires_at' => 'DATETIME NULL',
|
|
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
|
'created_by' => 'INT NULL',
|
|
'hits' => 'INT NOT NULL DEFAULT 0',
|
|
],
|
|
'optional' => [],
|
|
'insert' => ['ip', 'reason', 'source', 'expires_at', 'created_by'],
|
|
'why' => 'o bloqueio de IP falha aberto — ninguém é banido',
|
|
],
|
|
'ip_offences' => [
|
|
'required' => [
|
|
'ip' => 'VARCHAR(45) NOT NULL',
|
|
'kind' => 'VARCHAR(40) NOT NULL',
|
|
'detail' => 'VARCHAR(190) NULL',
|
|
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
|
],
|
|
'optional' => [],
|
|
'insert' => ['ip', 'kind', 'detail'],
|
|
'why' => 'as infracções não se registam e a escalada de banimento nunca começa',
|
|
],
|
|
'system_settings' => [
|
|
'required' => [
|
|
'setting_key' => 'VARCHAR(100) NOT NULL',
|
|
'setting_value' => 'TEXT NULL',
|
|
'updated_by' => 'INT NULL',
|
|
],
|
|
'optional' => [],
|
|
'insert' => ['setting_key', 'setting_value', 'updated_by'],
|
|
'why' => 'as definições partilhadas e o emparelhamento com o hub não gravam',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Lê o esquema e avalia. Só leitura — nunca cria nem altera nada. Uma consulta.
|
|
* Devolve ['ok' => bool, 'issues' => [['level','table','message','fix'], …]].
|
|
*/
|
|
function platform_conformance(PDO $pdo): array {
|
|
static $cache = null;
|
|
if ($cache !== null) return $cache;
|
|
|
|
$schema = function_exists('auth_schema') ? auth_schema() : platform_auth_schema_defaults();
|
|
$tables = array_merge(array_keys(platform_standard_tables()), [$schema['table']]);
|
|
|
|
$in = implode(',', array_fill(0, count($tables), '?'));
|
|
$st = $pdo->prepare(
|
|
"SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
|
|
FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ($in)"
|
|
);
|
|
$st->execute($tables);
|
|
|
|
$columns = [];
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
|
$columns[$c['TABLE_NAME']][$c['COLUMN_NAME']] = $c;
|
|
}
|
|
|
|
$cfg = platform_config();
|
|
return $cache = platform_conformance_evaluate($columns, $schema, [
|
|
'connected' => $cfg !== [],
|
|
'auth' => $cfg['auth'] ?? null,
|
|
'app_env' => function_exists('env') ? (string) env('APP_ENV', '') : (string) ($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: ''),
|
|
'env' => platform_env(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* A avaliação, sem base de dados. $columns: [tabela => [coluna => linha do
|
|
* information_schema]]. Separada para se poder testar com o esquema de cada site.
|
|
*/
|
|
function platform_conformance_evaluate(array $columns, array $schema, array $ctx): array {
|
|
$issues = [];
|
|
$add = static function (string $level, string $table, string $message, ?string $fix = null) use (&$issues): void {
|
|
$issues[] = ['level' => $level, 'table' => $table, 'message' => $message, 'fix' => $fix];
|
|
};
|
|
|
|
// ── Tabelas partilhadas ─────────────────────────────────────────────────
|
|
foreach (platform_standard_tables() as $table => $spec) {
|
|
if (!isset($columns[$table])) continue; // criada no primeiro uso, já com a forma certa
|
|
$have = $columns[$table];
|
|
|
|
foreach ($spec['required'] as $col => $def) {
|
|
if (!isset($have[$col])) {
|
|
$add('error', $table, "$table sem a coluna `$col` — {$spec['why']}",
|
|
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
|
|
}
|
|
}
|
|
foreach ($spec['optional'] as $col => $def) {
|
|
if (!isset($have[$col])) {
|
|
$add('info', $table, "$table sem a coluna opcional `$col`",
|
|
"ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` $def;");
|
|
}
|
|
}
|
|
// Uma coluna antiga NOT NULL e sem valor por omissão faz falhar os INSERT
|
|
// do código partilhado — em modo estrito, um erro que ele engole.
|
|
foreach ($have as $col => $c) {
|
|
if (in_array($col, $spec['insert'], true)) continue;
|
|
$extra = strtolower((string) $c['EXTRA']);
|
|
if ($c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null
|
|
&& !str_contains($extra, 'auto_increment') && !str_contains($extra, 'generated')) {
|
|
$add('error', $table, "$table.`$col` é NOT NULL sem valor por omissão — os INSERT partilhados falham ({$spec['why']})",
|
|
"ALTER TABLE `$table` MODIFY `$col` {$c['COLUMN_TYPE']} NULL;");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tabela de contas ────────────────────────────────────────────────────
|
|
$defaults = platform_auth_schema_defaults();
|
|
$table = (string) $schema['table'];
|
|
$users = $columns[$table] ?? null;
|
|
|
|
if ($users === null) {
|
|
$add('error', $table, "tabela de contas `$table` não existe", null);
|
|
} else {
|
|
// O que se pode acrescentar sem mexer em código; o resto mapeia-se.
|
|
$addable = [
|
|
'active' => 'TINYINT(1) NOT NULL DEFAULT 1',
|
|
'last_login' => 'DATETIME NULL',
|
|
'last_ip' => 'VARCHAR(45) NULL',
|
|
];
|
|
foreach (['id', 'email', 'login', 'password', 'name', 'role', 'active', 'deleted_at', 'last_login', 'last_ip'] as $key) {
|
|
$col = $schema[$key] ?? null;
|
|
if ($col === null || isset($users[$col])) continue;
|
|
$fix = isset($addable[$key]) && $col === $defaults[$key]
|
|
? "ALTER TABLE `$table` ADD COLUMN IF NOT EXISTS `$col` {$addable[$key]}; -- ou auth_schema(['$key' => null])"
|
|
: "auth_schema(['$key' => '<coluna deste site>'])";
|
|
$add('error', $table, "$table sem a coluna `$col` (chave `$key`)", $fix);
|
|
}
|
|
}
|
|
|
|
// Os desvios mapeados não partem nada: ficam à vista para convergirem.
|
|
foreach ($defaults as $key => $std) {
|
|
$mine = $schema[$key] ?? null;
|
|
if ($mine === $std) continue;
|
|
if ($mine === null) {
|
|
$add('info', $table, "sem `$std` (desligado em auth_schema(['$key' => null]))");
|
|
} elseif ($std === null) {
|
|
$add('info', $table, "`$key` => `$mine` (extensão deste site)");
|
|
} else {
|
|
$add('info', $table, "`$mine` no lugar de `$std` (mapeado em auth_schema())");
|
|
}
|
|
}
|
|
|
|
// ── Ligação ─────────────────────────────────────────────────────────────
|
|
if (empty($ctx['connected'])) {
|
|
$add('info', '', 'ainda não chama platform_connect()');
|
|
} elseif (($ctx['auth'] ?? null) === 'own') {
|
|
$add('warn', '', 'autenticação própria — sem o endurecimento de sessão, o rehash e o throttle partilhados');
|
|
}
|
|
if (($ctx['app_env'] ?? '') === '') {
|
|
$add('warn', '', 'APP_ENV não definido — tratado como produção');
|
|
}
|
|
|
|
$ok = !array_filter($issues, fn($i) => $i['level'] === 'error');
|
|
return ['ok' => $ok, 'issues' => $issues];
|
|
}
|
|
|
|
/** `checks.conformance` para o health do hub. */
|
|
function platform_conformance_check(PDO $pdo): array {
|
|
$r = platform_conformance($pdo);
|
|
$count = ['error' => 0, 'warn' => 0, 'info' => 0];
|
|
foreach ($r['issues'] as $i) $count[$i['level']]++;
|
|
|
|
$first = null;
|
|
foreach ($r['issues'] as $i) {
|
|
if ($i['level'] === 'error') { $first = $i['message']; break; }
|
|
}
|
|
return [
|
|
'ok' => $r['ok'],
|
|
'detail' => $count['error'] . ' erro(s), ' . $count['warn'] . ' aviso(s), ' . $count['info'] . ' nota(s)'
|
|
. ($first !== null ? ' — ' . $first : ''),
|
|
'errors' => $count['error'],
|
|
'warnings' => $count['warn'],
|
|
'issues' => $r['issues'],
|
|
];
|
|
}
|