257 lines
7.5 KiB
PHP
257 lines
7.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/db/config.php';
|
|
|
|
if (PHP_SAPI !== 'cli') {
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
header('X-Robots-Tag: noindex, nofollow');
|
|
}
|
|
|
|
@set_time_limit(0);
|
|
|
|
function schemaDebugOutput(string $message = ''): void
|
|
{
|
|
echo $message . PHP_EOL;
|
|
}
|
|
|
|
function configureSchemaDebugSession(): void
|
|
{
|
|
if (PHP_SAPI === 'cli') {
|
|
return;
|
|
}
|
|
|
|
$sessionsDir = __DIR__ . '/sessions';
|
|
if (!is_dir($sessionsDir)) {
|
|
@mkdir($sessionsDir, 0777, true);
|
|
}
|
|
|
|
if (is_writable($sessionsDir)) {
|
|
session_save_path($sessionsDir);
|
|
}
|
|
|
|
if (
|
|
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
|
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
|
|
) {
|
|
session_set_cookie_params([
|
|
'lifetime' => 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);
|
|
}
|