0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'None', ]); } } function canViewSchemaDebug(): bool { if (PHP_SAPI === 'cli') { return true; } configureSchemaDebugSession(); if (session_status() === PHP_SESSION_NONE) { @session_start(); } $roleName = (string) ($_SESSION['user_role_name'] ?? ''); if (strcasecmp($roleName, 'Administrator') === 0 || (int) ($_SESSION['user_id'] ?? 0) === 1) { return true; } $remoteAddress = $_SERVER['REMOTE_ADDR'] ?? ''; return in_array($remoteAddress, ['127.0.0.1', '::1'], true); } function getSchemaSourceFile(): string { $complete = __DIR__ . '/complete_schema.sql'; if (is_file($complete)) { return $complete; } return __DIR__ . '/db/schema.sql'; } function parseExpectedSchema(string $filePath): array { if (!is_file($filePath)) { throw new RuntimeException('Schema file not found: ' . basename($filePath)); } $sql = file_get_contents($filePath); if ($sql === false) { throw new RuntimeException('Unable to read schema file: ' . basename($filePath)); } $lines = preg_split('/\R/', $sql) ?: []; $schema = []; $currentTable = null; foreach ($lines as $line) { if (preg_match('/^\s*CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([a-zA-Z0-9_]+)`?/i', $line, $matches)) { $currentTable = strtolower((string) $matches[1]); $schema[$currentTable] = []; continue; } if ($currentTable === null) { continue; } if (preg_match('/^\s*\)\s*(ENGINE|DEFAULT|COMMENT|CHARSET|COLLATE|ROW_FORMAT|AUTO_INCREMENT|PARTITION|;)/i', $line)) { $currentTable = null; continue; } if (preg_match('/^\s*`([^`]+)`\s+/u', $line, $matches)) { $schema[$currentTable][] = (string) $matches[1]; } } foreach ($schema as $tableName => $columns) { $columns = array_values(array_unique($columns)); natcasesort($columns); $schema[$tableName] = array_values($columns); } ksort($schema, SORT_NATURAL | SORT_FLAG_CASE); return $schema; } function fetchActualSchema(PDO $pdo): array { $stmt = $pdo->query( "SELECT TABLE_NAME, COLUMN_NAME\n" . "FROM information_schema.COLUMNS\n" . "WHERE TABLE_SCHEMA = DATABASE()\n" . "ORDER BY TABLE_NAME ASC, ORDINAL_POSITION ASC" ); $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; $schema = []; foreach ($rows as $row) { $tableName = strtolower((string) ($row['TABLE_NAME'] ?? '')); $columnName = (string) ($row['COLUMN_NAME'] ?? ''); if ($tableName === '' || $columnName === '') { continue; } $schema[$tableName] ??= []; $schema[$tableName][] = $columnName; } foreach ($schema as $tableName => $columns) { $columns = array_values(array_unique($columns)); natcasesort($columns); $schema[$tableName] = array_values($columns); } ksort($schema, SORT_NATURAL | SORT_FLAG_CASE); return $schema; } function tailRuntimeDebugLog(int $maxLines = 10): array { $logFile = __DIR__ . '/runtime_debug.log'; if (!is_file($logFile)) { return []; } $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if (!is_array($lines) || $lines === []) { return []; } return array_slice($lines, -$maxLines); } if (!canViewSchemaDebug()) { http_response_code(403); schemaDebugOutput('Forbidden'); exit; } try { $pdo = db(); $schemaFile = getSchemaSourceFile(); $expectedSchema = parseExpectedSchema($schemaFile); $actualSchema = fetchActualSchema($pdo); $expectedTables = array_keys($expectedSchema); $actualTables = array_keys($actualSchema); $missingTables = array_values(array_diff($expectedTables, $actualTables)); $unexpectedTables = array_values(array_diff($actualTables, $expectedTables)); natcasesort($missingTables); natcasesort($unexpectedTables); $missingTables = array_values($missingTables); $unexpectedTables = array_values($unexpectedTables); $tablesWithMissingColumns = []; foreach ($expectedSchema as $tableName => $expectedColumns) { if (!isset($actualSchema[$tableName])) { continue; } $missingColumns = array_values(array_diff($expectedColumns, $actualSchema[$tableName])); if ($missingColumns !== []) { natcasesort($missingColumns); $tablesWithMissingColumns[$tableName] = array_values($missingColumns); } } schemaDebugOutput('Schema Debug Report'); schemaDebugOutput('==================='); schemaDebugOutput('Database: ' . DB_NAME); schemaDebugOutput('Schema source: ' . basename($schemaFile)); schemaDebugOutput('Expected tables: ' . count($expectedTables)); schemaDebugOutput('Actual tables: ' . count($actualTables)); schemaDebugOutput('Missing tables: ' . count($missingTables)); schemaDebugOutput('Tables with missing columns: ' . count($tablesWithMissingColumns)); schemaDebugOutput('Unexpected tables: ' . count($unexpectedTables)); if ($missingTables === [] && $tablesWithMissingColumns === []) { schemaDebugOutput(''); schemaDebugOutput('OK: current database matches the schema snapshot for table and column presence.'); } if ($missingTables !== []) { schemaDebugOutput(''); schemaDebugOutput('Missing tables:'); foreach ($missingTables as $tableName) { schemaDebugOutput('- ' . $tableName); } } if ($tablesWithMissingColumns !== []) { schemaDebugOutput(''); schemaDebugOutput('Missing columns by table:'); foreach ($tablesWithMissingColumns as $tableName => $columns) { schemaDebugOutput('- ' . $tableName . ': ' . implode(', ', $columns)); } } if ($unexpectedTables !== []) { schemaDebugOutput(''); schemaDebugOutput('Unexpected tables (present in DB but not in schema snapshot):'); foreach ($unexpectedTables as $tableName) { schemaDebugOutput('- ' . $tableName); } } $recentErrors = tailRuntimeDebugLog(); if ($recentErrors !== []) { schemaDebugOutput(''); schemaDebugOutput('Recent runtime_debug.log entries:'); foreach ($recentErrors as $line) { schemaDebugOutput('- ' . $line); } } } catch (Throwable $throwable) { http_response_code(500); schemaDebugOutput('Schema debug failed: ' . $throwable->getMessage()); exit(1); }