strpos(ABJ404_NAME, '/'); $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos !== false ? $slashPos : null)); if ($f->strpos($errfile, $pluginFolder) === false) { // let the normal error handler handle it. // this would display the error for other plugins but show @author user // stacktrace from this plugin. // // try calling the original error handler. // if (is_callable(self::$originalErrorHandler)) { // return call_user_func_array(self::$originalErrorHandler, // array($errno, $errstr, $errfile, $errline)); // } return false; } else { // for our own plugin errors make sure we see them. if ($GLOBALS['abj404_display_errors']) { error_reporting(E_ALL); ini_set('display_errors', '1'); } } if ($errno == 2 && $f->strpos($errstr, "Cannot modify header information - headers already sent by") !== false) { $onlyAWarning = true; } $extraInfo = "(none)"; $ctxDebugInfo = abj_service('request_context')->debug_info; if ($ctxDebugInfo !== '') { $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo))); } $errmsg = "ABJ404-SOLUTION Normal error handler error: errno: " . wp_kses_post((string)json_encode($errno)) . ", errstr: " . wp_kses_post((string)json_encode($errstr)) . ", \nerrfile: " . stripcslashes(wp_kses_post((string)json_encode($errfile))) . ", \nerrline: " . wp_kses_post((string)json_encode($errline)) . ', \nAdditional info: ' . $extraInfo . ", mbstring: " . (extension_loaded('mbstring') ? 'true' : 'false'); if ($abj404logging != null) { if ($errno === E_NOTICE) { $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)'); if (in_array($serverName, $GLOBALS['abj404_whitelist'])) { $e = new Exception; $abj404logging->debugMessage($errmsg . ', Trace:' . $e->getTraceAsString()); } } elseif ($onlyAWarning) { $abj404logging->debugMessage($errmsg); } else { $abj404logging->errorMessage($errmsg); } } else { echo $errmsg; } } catch (Throwable $ex) { // Last-resort breadcrumb: the inner logging path itself failed, // so we can't go through $abj404logging. Match the pattern used // by WordPress_Connector::reportAdminRuntimeError() and // Ajax_SuggestionCompute::handleShutdown(). Widening from // Exception to Throwable is intentional — Error types are // exactly the case the outer handler exists for. @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage()); } // show all warnings and errors. if ($GLOBALS['abj404_display_errors']) { error_reporting(E_ALL); ini_set('display_errors', '1'); } // let the original error handler handle it. return false; } /** @return bool */ static function FatalErrorHandler(): bool { $lasterror = error_get_last(); return self::processFatalError($lasterror); } /** * @param mixed $value * @return string */ private static function safeJsonEncode($value): string { $encoded = json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR); if ($encoded === false) { return '(json_encode failed) ' . print_r($value, true); } return $encoded; } /** * @param mixed $sql * @return string */ private static function redactSqlShape($sql): string { if (!is_string($sql) || $sql === '') { return ''; } $out = $sql; $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out; $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out; $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out; $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out; $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out; $out = preg_replace('~\\bIN\\s*\\(\\?\\)\\b~i', 'IN (?)', $out) ?? $out; $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out; if (strlen($out) > 4000) { $out = substr($out, 0, 4000) . '...'; } return $out; } /** * @param string $line * @return bool */ private static function safeWriteLine(string $line): bool { $logger = abj_service('logging'); if (is_object($logger) && method_exists($logger, 'writeLineToDebugFile')) { $logger->writeLineToDebugFile($line); return true; } if (is_object($logger) && method_exists($logger, 'sanitizeLogLine')) { $line = $logger->sanitizeLogLine($line); } @file_put_contents(ABJ404_PATH . 'abj404_debug_fallback.txt', $line . "\n", FILE_APPEND); return false; } /** * @param int $type * @return bool */ private static function isFatalType(int $type): bool { $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR); return in_array($type, $fatalTypes, true); } /** * Best-effort scalar read from request arrays without depending on WP helpers. * * @param string $key * @return string */ private static function getRequestValue(string $key): string { $raw = null; if (array_key_exists($key, $_GET)) { $raw = $_GET[$key]; } elseif (array_key_exists($key, $_POST)) { $raw = $_POST[$key]; } elseif (array_key_exists($key, $_REQUEST)) { $raw = $_REQUEST[$key]; } if (!is_scalar($raw)) { return ''; } return trim((string)$raw); } /** * Detect whether the current request is the plugin admin page. * * @return bool */ private static function isPluginAdminPageRequest(): bool { if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { return false; } $page = self::getRequestValue('page'); if ($page === '') { return false; } $pluginPage = defined('ABJ404_PP') ? (string)ABJ404_PP : 'abj404_solution'; return $page === $pluginPage; } /** * Persist the last admin fatal so we can show a notice on the next request. * * @param array $lasterror * @return void */ private static function stashAdminFatal(array $lasterror): void { $payload = array( 'message' => array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : '') : '', 'file' => array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '') : '', 'line' => array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0, 'type' => array_key_exists('type', $lasterror) ? (is_int($lasterror['type']) ? $lasterror['type'] : 0) : 0, 'time' => time(), 'page' => self::getRequestValue('page'), 'subpage' => self::getRequestValue('subpage'), ); $ttl = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600; if (function_exists('set_transient')) { set_transient('abj404_admin_fatal', $payload, $ttl); return; } if (function_exists('update_option')) { update_option('abj404_admin_fatal_fallback', $payload); } } /** * Render a small HTML fallback so fatal admin errors do not become a blank page. * * @param array $lasterror * @return void */ private static function renderAdminFatalFallback(array $lasterror): void { if (self::$adminFatalPageRendered) { return; } self::$adminFatalPageRendered = true; $canShowDetails = false; try { if (function_exists('current_user_can') && current_user_can('manage_options')) { $canShowDetails = true; } elseif (function_exists('is_super_admin') && is_super_admin()) { $canShowDetails = true; } } catch (Throwable $e) { // allow-silent-catch: admin-status detection in fatal handler; WP capability API may itself be broken, default to hiding details $canShowDetails = false; } $shouldManageOb = function_exists('apply_filters') ? apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'renderAdminFatalFallback')) : true; if ($shouldManageOb) { while (ob_get_level() > 0) { @ob_end_clean(); } } if (!headers_sent()) { if (function_exists('status_header')) { status_header(500); } elseif (function_exists('http_response_code')) { http_response_code(500); } header('Content-Type: text/html; charset=UTF-8'); } $settingsUrl = '?page=' . (defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution') . '&subpage=abj404_options'; if (function_exists('admin_url')) { $settingsUrl = admin_url('options-general.php' . $settingsUrl); } $message = array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : 'Fatal error') : 'Fatal error'; $file = array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '(unknown file)') : '(unknown file)'; $line = array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0; echo '404 Solution Error'; echo '

404 Solution

'; echo '

A fatal error occurred while rendering this admin page.

'; echo '

Open the Options tab to continue: Options

'; if ($canShowDetails) { echo '
Error details'; echo '
' .
				htmlspecialchars($message . "\n" . $file . ':' . (string)$line, ENT_QUOTES, 'UTF-8') .
				'
'; echo '
'; } echo ''; } /** * @param array $payload * @param int $httpStatus * @return bool */ private static function emitJsonAndExit(array $payload, int $httpStatus): bool { if (!headers_sent()) { // Marker headers help support quickly identify that this response came from our AJAX endpoint. // These are safe to expose (no sensitive values). if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) { $ctx = $GLOBALS['abj404_ajax_context']; if (array_key_exists('action', $ctx) && is_string($ctx['action'])) { header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $ctx['action'])); } if (array_key_exists('subpage', $ctx) && is_string($ctx['subpage']) && $ctx['subpage'] !== '') { header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $ctx['subpage'])); } } header('Content-type: application/json; charset=UTF-8'); if (function_exists('status_header')) { status_header($httpStatus); } else if (function_exists('http_response_code')) { http_response_code($httpStatus); } } echo json_encode($payload); $shouldExit = function_exists('apply_filters') ? apply_filters('abj404_should_exit', true, array('source' => 'errorHandler_emitJson')) : true; if (!$shouldExit) { return true; } exit; } /** * Process a fatal error (shutdown handler). * Public for unit tests (allows injecting a fake last error). */ /** * @param array|null $lasterror * @return bool */ public static function processFatalError($lasterror): bool { $f = abj_service('functions'); if ($lasterror == null || !is_array($lasterror) || !array_key_exists('type', $lasterror) || !array_key_exists('file', $lasterror)) { return false; } $errorType = $lasterror['type']; if (!self::isFatalType(is_int($errorType) ? $errorType : (is_scalar($errorType) ? (int)$errorType : 0))) { return false; } // Defensive: error_get_last() during an OOM fatal can return a 'message' // field that contains the full crash context (gigabytes on a runaway // memory exhaustion). json_encoding that downstream then OOMs the // shutdown handler itself. Cap the message length so the handler // never fails recursively due to its own logging path. if (isset($lasterror['message']) && is_string($lasterror['message']) && strlen($lasterror['message']) > 8192) { $lasterror['message'] = substr($lasterror['message'], 0, 8192) . '... (truncated; original length ' . strlen($lasterror['message']) . ' bytes)'; } $ctx = isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context']) ? $GLOBALS['abj404_ajax_context'] : null; $isPluginAdminPage = self::isPluginAdminPageRequest(); if ($isPluginAdminPage) { // Free reserved memory first so fallback rendering can succeed after OOM fatals. self::$reservedMemory = null; self::stashAdminFatal($lasterror); } $isAjaxContext = is_array($ctx) && !empty($ctx['ajax_expected_json']) && empty($ctx['response_sent']) && array_key_exists('action', $ctx) && $ctx['action'] === 'ajaxUpdatePaginationLinks'; // ------------------------- // AJAX context: always log (even if fatal is from another plugin/theme/core), // and emit JSON for admins so WordPress's generic "critical error" page doesn't hide details. if ($isAjaxContext) { // Only handle fatals for this endpoint when the context was created by our handler. // This avoids logging unrelated admin-ajax fatals while still capturing "foreign" plugin/theme fatals // that break our AJAX response. $contextSourceOk = array_key_exists('abj404_context_source', $ctx) && $ctx['abj404_context_source'] === 'ViewUpdater::getPaginationLinks'; if (!$contextSourceOk) { return false; } $bufferedOutput = ''; $shouldManageOb = function_exists('apply_filters') ? apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'errorHandler_processFatalError')) : true; if ($shouldManageOb) { if (ob_get_level() > 0) { $bufferedOutput = (string)ob_get_contents(); } $minLevel = array_key_exists('ob_level_before', $ctx) ? intval($ctx['ob_level_before']) : 0; while (ob_get_level() > $minLevel) { @ob_end_clean(); } } $details = array( 'fatal_error' => $lasterror, 'context' => $ctx, ); if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) { $lastQuery = $GLOBALS['wpdb']->last_query ?? ''; $details['wpdb'] = array( 'last_error' => $GLOBALS['wpdb']->last_error ?? '', 'last_query_redacted' => self::redactSqlShape($lastQuery), 'last_query_length' => is_string($lastQuery) ? strlen($lastQuery) : 0, ); } if ($bufferedOutput !== '') { $details['buffered_output'] = substr($bufferedOutput, 0, 8000); } $line = date('c') . ' (ERROR): AJAX fatal error in ajaxUpdatePaginationLinks. Details: ' . self::safeJsonEncode($details); self::safeWriteLine($line); $isPluginAdmin = array_key_exists('is_plugin_admin', $ctx) ? (bool)$ctx['is_plugin_admin'] : null; // Only try to compute admin status if it wasn't already determined earlier in the request. if ($isPluginAdmin === null) { try { $logic = abj_service('plugin_logic'); if (is_object($logic) && method_exists($logic, 'userIsPluginAdmin')) { $isPluginAdmin = $logic->userIsPluginAdmin(); } } catch (Throwable $e) { // allow-silent-catch: admin-status detection; PluginLogic may be the broken component, fall through to WP capability check $isPluginAdmin = null; } } if ($isPluginAdmin === null) { // Best-effort fallback: show details to real WordPress admins if PluginLogic is broken. if (function_exists('wp_get_current_user')) { $user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user()); if ($user !== null) { $isPluginAdmin = $user->isAdministrator(); } } if ($isPluginAdmin !== true && function_exists('is_super_admin') && is_super_admin()) { $isPluginAdmin = true; } } if ($isPluginAdmin === null) { $isPluginAdmin = false; } $payload = array( 'success' => false, 'data' => array( 'message' => 'Server error while updating the table.', ), ); if ($isPluginAdmin) { $payload['data']['details'] = $details; } $GLOBALS['abj404_ajax_context']['response_sent'] = true; return self::emitJsonAndExit($payload, 500); } // ------------------------- // Default behavior: only log plugin-scope fatals (avoid noise from other plugins/themes), // except plugin admin page requests where we deliberately capture foreign fatals too. try { $errno = $lasterror['type']; $errfile = is_string($lasterror['file']) ? $lasterror['file'] : ''; $slashPos2 = $f->strpos(ABJ404_NAME, '/'); $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos2 !== false ? $slashPos2 : null)); $isPluginScopeFatal = ($f->strpos($errfile, $pluginFolder) !== false); // If the error file does not contain our plugin name, ignore it unless // we are rendering the plugin admin page where blank-page prevention is critical. if (!$isPluginScopeFatal && !$isPluginAdminPage) { return false; } $extraInfo = "(none)"; $ctxDebugInfo = abj_service('request_context')->debug_info; if ($ctxDebugInfo !== '') { $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo))); } $contextPrefix = $isPluginScopeFatal ? 'ABJ404-SOLUTION Fatal error handler: ' : 'ABJ404-SOLUTION Fatal error handler (plugin admin page, foreign scope): '; $errmsg = $contextPrefix . stripcslashes(wp_kses_post((string)json_encode($lasterror))) . ", \nAdditional info: " . $extraInfo . ", mbstring: " . (extension_loaded('mbstring') ? 'true' : 'false'); $abj404logging = abj_service('logging'); if ($abj404logging != null) { switch ($errno) { case E_NOTICE: $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)'); if (in_array($serverName, $GLOBALS['abj404_whitelist'])) { $abj404logging->debugMessage($errmsg); } break; default: $abj404logging->errorMessage($errmsg); break; } } else { echo $errmsg; } } catch (Throwable $ex) { // Last-resort breadcrumb: inner logging itself failed during the // fatal-error path. See NormalErrorHandler() above for rationale. @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage()); } if ($isPluginAdminPage) { self::renderAdminFatalFallback($lasterror); } return false; } }