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
+ = htmlspecialchars($title) ?>
+ = htmlspecialchars($summary) ?>
+
+
+
+
Exception
+
= htmlspecialchars(get_class($throwable)) ?>
+
Message
+
= htmlspecialchars($throwable->getMessage()) ?>
+
File
+
= htmlspecialchars($throwable->getFile()) ?>
+
Line
+
= (int)$throwable->getLine() ?>
+
Page
+
= htmlspecialchars($page) ?>
+
Request URI
+
= htmlspecialchars($requestUri) ?>
+
+
+
+ Schema hint: = htmlspecialchars($hint) ?>
+
+
+
+ = htmlspecialchars($traceText) ?>
+
+
+
+
+ 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);
+}