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' => ''])"; $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'], ]; }