diff --git a/index.php b/index.php index b1d23af..4d4751e 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,154 @@ date('H:i:s'), 'stage' => 'boot:script_loaded'], +]; +$GLOBALS['app_runtime_debug_rendered'] = false; + +if (!function_exists('runtime_debug_boot_mark')) { + function runtime_debug_boot_mark(string $stage, array $context = []): void { + $GLOBALS['app_runtime_debug_stage'] = $stage; + $timeline = $GLOBALS['app_runtime_debug_timeline'] ?? []; + $entry = [ + 'time' => date('H:i:s'), + 'stage' => $stage, + ]; + + if ($context !== []) { + $entry['context'] = $context; + } + + $timeline[] = $entry; + if (count($timeline) > 20) { + $timeline = array_slice($timeline, -20); + } + + $GLOBALS['app_runtime_debug_timeline'] = $timeline; + } +} + +if (!function_exists('runtime_debug_boot_force_details')) { + function runtime_debug_boot_force_details(): bool { + $candidates = [ + $_GET['debug'] ?? null, + $_GET['app_debug'] ?? null, + getenv('APP_RUNTIME_DEBUG'), + $_ENV['APP_RUNTIME_DEBUG'] ?? null, + $_SERVER['APP_RUNTIME_DEBUG'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === false || $candidate === null) { + continue; + } + + $value = strtolower(trim((string)$candidate)); + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + } + + return false; + } +} + +if (!defined('APP_RUNTIME_DEBUG_BOOTSTRAP_SHUTDOWN_REGISTERED')) { + define('APP_RUNTIME_DEBUG_BOOTSTRAP_SHUTDOWN_REGISTERED', true); + register_shutdown_function(static function (): void { + if (!empty($GLOBALS['app_runtime_debug_rendered']) || defined('APP_RUNTIME_DEBUG_FATAL_HANDLER_REGISTERED')) { + return; + } + + $error = error_get_last(); + if (!is_array($error)) { + return; + } + + $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; + if (!in_array((int)($error['type'] ?? 0), $fatalTypes, true)) { + return; + } + + $stage = (string)($GLOBALS['app_runtime_debug_stage'] ?? 'unknown'); + $timeline = $GLOBALS['app_runtime_debug_timeline'] ?? []; + $parts = [ + date('Y-m-d H:i:s'), + '[FatalError]', + (string)($error['message'] ?? 'Fatal error'), + 'file=' . (string)($error['file'] ?? 'unknown') . ':' . (int)($error['line'] ?? 0), + 'page=' . (string)($_GET['page'] ?? 'dashboard'), + 'uri=' . (string)($_SERVER['REQUEST_URI'] ?? 'cli'), + 'stage=' . $stage, + ]; + + if ($timeline !== []) { + $parts[] = 'timeline=' . json_encode(array_slice($timeline, -8), JSON_UNESCAPED_UNICODE); + } + + @file_put_contents(__DIR__ . '/runtime_debug.log', implode(' || ', $parts) . PHP_EOL, FILE_APPEND); + + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/html; charset=UTF-8'); + header('X-Robots-Tag: noindex, nofollow'); + } + + $roleName = (string)($_SESSION['user_role_name'] ?? ''); + $showDetails = runtime_debug_boot_force_details() + || PHP_SAPI === 'cli' + || strcasecmp($roleName, 'Administrator') === 0 + || (int)($_SESSION['user_id'] ?? 0) === 1; + + $message = (string)($error['message'] ?? 'Fatal error'); + $safeMessage = $showDetails + ? $message + : 'A fatal application error occurred before the page finished loading.'; + $timelineText = json_encode(array_slice($timeline, -8), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + $GLOBALS['app_runtime_debug_rendered'] = true; + ?> + + + + + + + Application Boot Error + + + +
+
+

Application Boot Error

+

The request failed before the main page finished loading.

+
Stage:
+
Message:
+
A copy of this failure was written to runtime_debug.log.
+ +
+ +
+
+ + + $file]); + require $file; + } +} + +if (!function_exists('runtime_debug_extract_table_name')) { + function runtime_debug_extract_table_name(Throwable $throwable): ?string { + $message = $throwable->getMessage(); + $patterns = [ + "/Table '[^']+\.([^']+)' doesn't exist/i", + '/(?:INSERT\s+INTO|UPDATE|FROM|JOIN|DELETE\s+FROM|ALTER\s+TABLE)\s+`?([a-zA-Z0-9_]+)`?/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $message, $matches)) { + return (string)$matches[1]; + } + } + + return null; + } +} + +if (!function_exists('runtime_debug_extract_column_name')) { + function runtime_debug_extract_column_name(Throwable $throwable): ?string { + $message = $throwable->getMessage(); + + if (preg_match("/Unknown column '([^']+)'/i", $message, $matches)) { + return (string)$matches[1]; + } + + if (preg_match('/Column not found: [0-9]+ ([a-zA-Z0-9_]+)/i', $message, $matches)) { + return (string)$matches[1]; + } + + return null; + } +} + +if (!function_exists('runtime_debug_find_related_migrations')) { + function runtime_debug_find_related_migrations(?string $tableName, ?string $columnName = null, int $limit = 6): array { + $directory = __DIR__ . '/db/migrations'; + if (!is_dir($directory)) { + return []; + } + + $needles = array_values(array_filter([ + $tableName ? strtolower($tableName) : null, + $columnName ? strtolower($columnName) : null, + ])); + + if ($needles === []) { + return []; + } + + $matches = []; + foreach (glob($directory . '/*.{sql,php}', GLOB_BRACE) ?: [] as $migrationPath) { + if (!is_readable($migrationPath)) { + continue; + } + + $contents = @file_get_contents($migrationPath); + if ($contents === false) { + continue; + } + + $haystack = strtolower($contents); + $matched = true; + foreach ($needles as $needle) { + if (!str_contains($haystack, $needle)) { + $matched = false; + break; + } + } + + if ($matched) { + $matches[] = basename($migrationPath); + if (count($matches) >= $limit) { + break; + } + } + } + + return $matches; + } +} + +if (!function_exists('runtime_debug_schema_snapshot')) { + function runtime_debug_schema_snapshot(Throwable $throwable): array { + $tableName = runtime_debug_extract_table_name($throwable); + $columnName = runtime_debug_extract_column_name($throwable); + $snapshot = [ + 'table' => $tableName, + 'column' => $columnName, + 'table_exists' => null, + 'columns' => [], + 'database_error' => null, + 'related_migrations' => runtime_debug_find_related_migrations($tableName, $columnName), + ]; + + if ($tableName === null) { + return $snapshot; + } + + try { + $pdo = db(); + $snapshot['table_exists'] = db_table_exists($tableName); + if ($snapshot['table_exists']) { + $stmt = $pdo->prepare( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION" + ); + $stmt->execute([$tableName]); + $snapshot['columns'] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []); + } + } catch (Throwable $e) { + $snapshot['database_error'] = $e->getMessage(); + } + + return $snapshot; + } +} + +if (!function_exists('runtime_debug_is_fatal_error')) { + function runtime_debug_is_fatal_error($error): bool { + if (!is_array($error)) { + return false; + } + + return in_array((int)($error['type'] ?? 0), [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true); + } +} + if (!function_exists('runtime_debug_can_render_details')) { function runtime_debug_can_render_details(): bool { + if (runtime_debug_force_details_enabled()) { + return true; + } + if (PHP_SAPI === 'cli') { return true; } @@ -412,6 +735,7 @@ if (!function_exists('runtime_debug_log')) { 'page=' . ($_GET['page'] ?? 'dashboard'), 'uri=' . ($_SERVER['REQUEST_URI'] ?? 'cli'), 'user_id=' . (string)($_SESSION['user_id'] ?? 0), + 'stage=' . runtime_debug_current_stage(), ]; $hint = runtime_debug_infer_schema_hint($throwable); @@ -423,8 +747,12 @@ if (!function_exists('runtime_debug_log')) { $parts[] = 'error_info=' . json_encode($throwable->errorInfo, JSON_UNESCAPED_UNICODE); } - $trace = explode(" -", $throwable->getTraceAsString()); + $timeline = runtime_debug_recent_timeline(10); + if ($timeline !== []) { + $parts[] = 'timeline=' . json_encode($timeline, JSON_UNESCAPED_UNICODE); + } + + $trace = explode("\n", $throwable->getTraceAsString()); if ($trace !== []) { $parts[] = 'trace=' . implode(' | ', array_slice($trace, 0, 5)); } @@ -435,6 +763,17 @@ if (!function_exists('runtime_debug_log')) { if (!function_exists('runtime_debug_render_exception')) { function runtime_debug_render_exception(Throwable $throwable): void { + if (!empty($GLOBALS['app_runtime_debug_rendered'])) { + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/plain; charset=UTF-8'); + } + + echo 'Application Error'; + exit; + } + + $GLOBALS['app_runtime_debug_rendered'] = true; runtime_debug_log($throwable); $showDetails = runtime_debug_can_render_details(); @@ -451,14 +790,19 @@ if (!function_exists('runtime_debug_render_exception')) { $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); + $currentStage = runtime_debug_current_stage(); + $timeline = runtime_debug_recent_timeline(10); + $timelineText = json_encode($timeline, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $schemaSnapshot = runtime_debug_schema_snapshot($throwable); + $tableExistsText = $schemaSnapshot['table_exists'] === null ? 'Unknown' : ($schemaSnapshot['table_exists'] ? 'Yes' : 'No'); + $schemaColumnsText = !empty($schemaSnapshot['columns']) ? implode(', ', $schemaSnapshot['columns']) : '—'; + $migrationText = !empty($schemaSnapshot['related_migrations']) ? implode(', ', $schemaSnapshot['related_migrations']) : '—'; + $tracePreview = array_slice(explode("\n", $throwable->getTraceAsString()), 0, 8); + $traceText = implode("\n", $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.'; + ? 'The request failed. The details below should help identify the missing table, column, or view file.' + : 'An unexpected error occurred while loading this page. The diagnostic snapshot below may still point to what is missing.'; ?> @@ -469,9 +813,10 @@ if (!function_exists('runtime_debug_render_exception')) { <?= htmlspecialchars($title) ?> - + 'script', 'page' => (string)$page]); ?>