diff --git a/cookies_repro.txt b/cookies_repro.txt new file mode 100644 index 0000000..64a675f --- /dev/null +++ b/cookies_repro.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +127.0.0.1 FALSE / FALSE 0 PHPSESSID f3c2q95r0m6iaptq2skotkpemo diff --git a/index.php b/index.php index 147d98f..04121e8 100644 --- a/index.php +++ b/index.php @@ -82,6 +82,184 @@ if (!function_exists('db_table_exists')) { } } +if (!function_exists('runtime_debug_can_render_details')) { + function runtime_debug_can_render_details(): bool { + if (PHP_SAPI === 'cli') { + return true; + } + + $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); + } +} + +if (!function_exists('runtime_debug_infer_schema_hint')) { + function runtime_debug_infer_schema_hint(Throwable $throwable): ?string { + $message = $throwable->getMessage(); + + if (preg_match("/Table '[^']+\.([^']+)' doesn't exist/i", $message, $matches)) { + return 'Missing table: ' . $matches[1]; + } + + if (preg_match("/Unknown column '([^']+)'/i", $message, $matches)) { + return 'Missing column: ' . $matches[1]; + } + + if (preg_match('/Base table or view not found: [0-9]+ ([a-zA-Z0-9_]+)/i', $message, $matches)) { + return 'Missing table or view: ' . $matches[1]; + } + + return null; + } +} + +if (!function_exists('runtime_debug_log')) { + function runtime_debug_log(Throwable $throwable): void { + $parts = [ + date('Y-m-d H:i:s'), + '[' . get_class($throwable) . ']', + $throwable->getMessage(), + 'file=' . $throwable->getFile() . ':' . $throwable->getLine(), + 'page=' . ($_GET['page'] ?? 'dashboard'), + 'uri=' . ($_SERVER['REQUEST_URI'] ?? 'cli'), + 'user_id=' . (string)($_SESSION['user_id'] ?? 0), + ]; + + $hint = runtime_debug_infer_schema_hint($throwable); + if ($hint !== null) { + $parts[] = 'hint=' . $hint; + } + + if ($throwable instanceof PDOException && isset($throwable->errorInfo) && is_array($throwable->errorInfo)) { + $parts[] = 'error_info=' . json_encode($throwable->errorInfo, JSON_UNESCAPED_UNICODE); + } + + $trace = explode(" +", $throwable->getTraceAsString()); + if ($trace !== []) { + $parts[] = 'trace=' . implode(' | ', array_slice($trace, 0, 5)); + } + + @file_put_contents(__DIR__ . '/runtime_debug.log', implode(' || ', $parts) . PHP_EOL, FILE_APPEND); + } +} + +if (!function_exists('runtime_debug_render_exception')) { + function runtime_debug_render_exception(Throwable $throwable): void { + runtime_debug_log($throwable); + $showDetails = runtime_debug_can_render_details(); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/html; charset=UTF-8'); + header('X-Robots-Tag: noindex, nofollow'); + } + + $hint = runtime_debug_infer_schema_hint($throwable); + $requestUri = (string)($_SERVER['REQUEST_URI'] ?? 'cli'); + $page = (string)($_GET['page'] ?? 'dashboard'); + $tracePreview = array_slice(explode(" +", $throwable->getTraceAsString()), 0, 8); + $traceText = implode(" +", $tracePreview); + $title = $showDetails ? 'Application Debug' : 'Application Error'; + $summary = $showDetails + ? 'The request failed. The details below should help identify the missing table or column.' + : 'An unexpected error occurred while loading this page.'; + ?> + + + + + + + <?= htmlspecialchars($title) ?> + + + +
+
+ HTTP 500 +

+

+ + +
+
Exception
+
+
Message
+
getMessage()) ?>
+
File
+
getFile()) ?>
+
Line
+
getLine() ?>
+
Page
+
+
Request URI
+
+
+ + +
Schema hint:
+ + + +
+ + + + +

A copy of this failure was written to runtime_debug.log.

+ + + +
+
+ + + 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); +}