PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ErrorHandler.php

ErrorHandler.php in 404 Solution 4.1.13, at includes/ErrorHandler.php

563 lines 22.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_ErrorHandler {
11
12 /**
13 * Prevent duplicate shutdown fallback output when multiple handlers run.
14 *
15 * @var bool
16 */
17 private static $adminFatalPageRendered = false;
18
19 /** Keep a reference to the original error handler so we can use it later.
20 * @var callable|null
21 */
22 static $originalErrorHandler = null;
23
24 /**
25 * Reserved memory released during fatal shutdown handling so OOM errors can still render fallback output.
26 *
27 * @var string|null
28 */
29 private static $reservedMemory = null;
30
31 /** Setup.
32 * @return void
33 */
34 static function init(): void {
35 // store the original error handler.
36 self::$originalErrorHandler = set_error_handler(function(int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; });
37 restore_error_handler();
38
39 // set to the user defined error handler
40 set_error_handler("ABJ_404_Solution_ErrorHandler::NormalErrorHandler");
41 if (self::$reservedMemory === null) {
42 // Keep a small memory reserve so shutdown handling can render a fallback page on memory exhaustion.
43 self::$reservedMemory = str_repeat('R', 262144);
44 }
45 register_shutdown_function('ABJ_404_Solution_ErrorHandler::FatalErrorHandler');
46 }
47
48 /** Try to capture PHP errors.
49 * @param int $errno
50 * @param string $errstr
51 * @param string $errfile
52 * @param int $errline
53 * @return boolean
54 */
55 static function NormalErrorHandler($errno, $errstr, $errfile, $errline) {
56 // Respect PHP's `@` error-suppression operator. While inside @somefunc(),
57 // PHP sets error_reporting() to 0 for the duration of the call. A custom
58 // handler that ignores this state still escalates intentionally-suppressed
59 // warnings to error-level logging — defeating the suppression.
60 // Concrete case: @opcache_invalidate() at PluginLogic.php:1042 on hosts
61 // with opcache.restrict_api set was producing email-threshold reports
62 // because this guard was missing. Returning false here lets PHP's default
63 // handler honour the suppression.
64 if (error_reporting() === 0) {
65 return false;
66 }
67
68 $abj404logging = abj_service('logging');
69 $f = abj_service('functions');
70 $onlyAWarning = false;
71
72 try {
73 // if the error file does not contain the name of our plugin then we ignore it.
74 $slashPos = $f->strpos(ABJ404_NAME, '/');
75 $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos !== false ? $slashPos : null));
76 if ($f->strpos($errfile, $pluginFolder) === false) {
77 // let the normal error handler handle it.
78
79 // this would display the error for other plugins but show @author user
80 // stacktrace from this plugin.
81 // // try calling the original error handler.
82 // if (is_callable(self::$originalErrorHandler)) {
83 // return call_user_func_array(self::$originalErrorHandler,
84 // array($errno, $errstr, $errfile, $errline));
85 // }
86 return false;
87
88 } else {
89 // for our own plugin errors make sure we see them.
90 if ($GLOBALS['abj404_display_errors']) {
91 error_reporting(E_ALL);
92 ini_set('display_errors', '1');
93 }
94 }
95
96 if ($errno == 2 &&
97 $f->strpos($errstr,
98 "Cannot modify header information - headers already sent by") !== false) {
99
100 $onlyAWarning = true;
101 }
102
103 $extraInfo = "(none)";
104 $ctxDebugInfo = abj_service('request_context')->debug_info;
105 if ($ctxDebugInfo !== '') {
106 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
107 }
108 $errmsg = "ABJ404-SOLUTION Normal error handler error: errno: " .
109 wp_kses_post((string)json_encode($errno)) . ", errstr: " . wp_kses_post((string)json_encode($errstr)) .
110 ", \nerrfile: " . stripcslashes(wp_kses_post((string)json_encode($errfile))) .
111 ", \nerrline: " . wp_kses_post((string)json_encode($errline)) .
112 ', \nAdditional info: ' . $extraInfo . ", mbstring: " .
113 (extension_loaded('mbstring') ? 'true' : 'false');
114
115 if ($abj404logging != null) {
116 if ($errno === E_NOTICE) {
117 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
118 if (in_array($serverName, $GLOBALS['abj404_whitelist'])) {
119 $e = new Exception;
120 $abj404logging->debugMessage($errmsg . ', Trace:' . $e->getTraceAsString());
121 }
122 } elseif ($onlyAWarning) {
123 $abj404logging->debugMessage($errmsg);
124 } else {
125 $abj404logging->errorMessage($errmsg);
126 }
127 } else {
128 echo $errmsg;
129 }
130 } catch (Throwable $ex) {
131 // Last-resort breadcrumb: the inner logging path itself failed,
132 // so we can't go through $abj404logging. Match the pattern used
133 // by WordPress_Connector::reportAdminRuntimeError() and
134 // Ajax_SuggestionCompute::handleShutdown(). Widening from
135 // Exception to Throwable is intentional — Error types are
136 // exactly the case the outer handler exists for.
137 @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage());
138 }
139
140 // show all warnings and errors.
141 if ($GLOBALS['abj404_display_errors']) {
142 error_reporting(E_ALL);
143 ini_set('display_errors', '1');
144 }
145 // let the original error handler handle it.
146 return false;
147 }
148
149 /** @return bool */
150 static function FatalErrorHandler(): bool {
151 $lasterror = error_get_last();
152 return self::processFatalError($lasterror);
153 }
154
155 /**
156 * @param mixed $value
157 * @return string
158 */
159 private static function safeJsonEncode($value): string {
160 $encoded = json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR);
161 if ($encoded === false) {
162 return '(json_encode failed) ' . print_r($value, true);
163 }
164 return $encoded;
165 }
166
167 /**
168 * @param mixed $sql
169 * @return string
170 */
171 private static function redactSqlShape($sql): string {
172 if (!is_string($sql) || $sql === '') {
173 return '';
174 }
175
176 $out = $sql;
177 $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out;
178 $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out;
179 $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out;
180 $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out;
181 $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out;
182 $out = preg_replace('~\\bIN\\s*\\(\\?\\)\\b~i', 'IN (?)', $out) ?? $out;
183 $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out;
184 if (strlen($out) > 4000) {
185 $out = substr($out, 0, 4000) . '...';
186 }
187 return $out;
188 }
189
190 /**
191 * @param string $line
192 * @return bool
193 */
194 private static function safeWriteLine(string $line): bool {
195 $logger = abj_service('logging');
196 if (is_object($logger) && method_exists($logger, 'writeLineToDebugFile')) {
197 $logger->writeLineToDebugFile($line);
198 return true;
199 }
200 if (is_object($logger) && method_exists($logger, 'sanitizeLogLine')) {
201 $line = $logger->sanitizeLogLine($line);
202 }
203 @file_put_contents(ABJ404_PATH . 'abj404_debug_fallback.txt', $line . "\n", FILE_APPEND);
204 return false;
205 }
206
207 /**
208 * @param int $type
209 * @return bool
210 */
211 private static function isFatalType(int $type): bool {
212 $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR);
213 return in_array($type, $fatalTypes, true);
214 }
215
216 /**
217 * Best-effort scalar read from request arrays without depending on WP helpers.
218 *
219 * @param string $key
220 * @return string
221 */
222 private static function getRequestValue(string $key): string {
223 $raw = null;
224 if (array_key_exists($key, $_GET)) {
225 $raw = $_GET[$key];
226 } elseif (array_key_exists($key, $_POST)) {
227 $raw = $_POST[$key];
228 } elseif (array_key_exists($key, $_REQUEST)) {
229 $raw = $_REQUEST[$key];
230 }
231
232 if (!is_scalar($raw)) {
233 return '';
234 }
235
236 return trim((string)$raw);
237 }
238
239 /**
240 * Detect whether the current request is the plugin admin page.
241 *
242 * @return bool
243 */
244 private static function isPluginAdminPageRequest(): bool {
245 if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
246 return false;
247 }
248
249 $page = self::getRequestValue('page');
250 if ($page === '') {
251 return false;
252 }
253
254 $pluginPage = defined('ABJ404_PP') ? (string)ABJ404_PP : 'abj404_solution';
255 return $page === $pluginPage;
256 }
257
258 /**
259 * Persist the last admin fatal so we can show a notice on the next request.
260 *
261 * @param array<string,mixed> $lasterror
262 * @return void
263 */
264 private static function stashAdminFatal(array $lasterror): void {
265 $payload = array(
266 'message' => array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : '') : '',
267 'file' => array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '') : '',
268 'line' => array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0,
269 'type' => array_key_exists('type', $lasterror) ? (is_int($lasterror['type']) ? $lasterror['type'] : 0) : 0,
270 'time' => time(),
271 'page' => self::getRequestValue('page'),
272 'subpage' => self::getRequestValue('subpage'),
273 );
274
275 $ttl = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;
276 if (function_exists('set_transient')) {
277 set_transient('abj404_admin_fatal', $payload, $ttl);
278 return;
279 }
280
281 if (function_exists('update_option')) {
282 update_option('abj404_admin_fatal_fallback', $payload);
283 }
284 }
285
286 /**
287 * Render a small HTML fallback so fatal admin errors do not become a blank page.
288 *
289 * @param array<string,mixed> $lasterror
290 * @return void
291 */
292 private static function renderAdminFatalFallback(array $lasterror): void {
293 if (self::$adminFatalPageRendered) {
294 return;
295 }
296 self::$adminFatalPageRendered = true;
297
298 $canShowDetails = false;
299 try {
300 if (function_exists('current_user_can') && current_user_can('manage_options')) {
301 $canShowDetails = true;
302 } elseif (function_exists('is_super_admin') && is_super_admin()) {
303 $canShowDetails = true;
304 }
305 } catch (Throwable $e) {
306 $canShowDetails = false;
307 }
308
309 if (!(defined('ABJ404_TEST_DISABLE_OB') && ABJ404_TEST_DISABLE_OB)) {
310 while (ob_get_level() > 0) {
311 @ob_end_clean();
312 }
313 }
314
315 if (!headers_sent()) {
316 if (function_exists('status_header')) {
317 status_header(500);
318 } elseif (function_exists('http_response_code')) {
319 http_response_code(500);
320 }
321 header('Content-Type: text/html; charset=UTF-8');
322 }
323
324 $settingsUrl = '?page=' . (defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution') . '&subpage=abj404_options';
325 if (function_exists('admin_url')) {
326 $settingsUrl = admin_url('options-general.php' . $settingsUrl);
327 }
328
329 $message = array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : 'Fatal error') : 'Fatal error';
330 $file = array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '(unknown file)') : '(unknown file)';
331 $line = array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0;
332
333 echo '<!doctype html><html><head><meta charset="utf-8"><title>404 Solution Error</title></head><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;padding:24px;">';
334 echo '<h1 style="margin:0 0 12px 0;">404 Solution</h1>';
335 echo '<p><strong>A fatal error occurred while rendering this admin page.</strong></p>';
336 echo '<p>Open the Options tab to continue: <a href="' . htmlspecialchars($settingsUrl, ENT_QUOTES, 'UTF-8') . '">Options</a></p>';
337
338 if ($canShowDetails) {
339 echo '<details open><summary>Error details</summary>';
340 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;">' .
341 htmlspecialchars($message . "\n" . $file . ':' . (string)$line, ENT_QUOTES, 'UTF-8') .
342 '</pre>';
343 echo '</details>';
344 }
345
346 echo '</body></html>';
347 }
348
349 /**
350 * @param array<string, mixed> $payload
351 * @param int $httpStatus
352 * @return bool
353 */
354 private static function emitJsonAndExit(array $payload, int $httpStatus): bool {
355 if (!headers_sent()) {
356 // Marker headers help support quickly identify that this response came from our AJAX endpoint.
357 // These are safe to expose (no sensitive values).
358 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
359 $ctx = $GLOBALS['abj404_ajax_context'];
360 if (array_key_exists('action', $ctx) && is_string($ctx['action'])) {
361 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $ctx['action']));
362 }
363 if (array_key_exists('subpage', $ctx) && is_string($ctx['subpage']) && $ctx['subpage'] !== '') {
364 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $ctx['subpage']));
365 }
366 }
367 header('Content-type: application/json; charset=UTF-8');
368 if (function_exists('status_header')) {
369 status_header($httpStatus);
370 } else if (function_exists('http_response_code')) {
371 http_response_code($httpStatus);
372 }
373 }
374 echo json_encode($payload);
375 if (defined('ABJ404_TEST_NO_EXIT') && ABJ404_TEST_NO_EXIT) {
376 return true;
377 }
378 exit;
379 }
380
381 /**
382 * Process a fatal error (shutdown handler).
383 * Public for unit tests (allows injecting a fake last error).
384 */
385 /**
386 * @param array<string, mixed>|null $lasterror
387 * @return bool
388 */
389 public static function processFatalError($lasterror): bool {
390 $f = abj_service('functions');
391
392 if ($lasterror == null || !is_array($lasterror) || !array_key_exists('type', $lasterror) ||
393 !array_key_exists('file', $lasterror)) {
394 return false;
395 }
396 $errorType = $lasterror['type'];
397 if (!self::isFatalType(is_int($errorType) ? $errorType : (is_scalar($errorType) ? (int)$errorType : 0))) {
398 return false;
399 }
400
401 $ctx = isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])
402 ? $GLOBALS['abj404_ajax_context'] : null;
403
404 $isPluginAdminPage = self::isPluginAdminPageRequest();
405 if ($isPluginAdminPage) {
406 // Free reserved memory first so fallback rendering can succeed after OOM fatals.
407 self::$reservedMemory = null;
408 self::stashAdminFatal($lasterror);
409 }
410
411 $isAjaxContext = is_array($ctx) &&
412 !empty($ctx['ajax_expected_json']) &&
413 empty($ctx['response_sent']) &&
414 array_key_exists('action', $ctx) &&
415 $ctx['action'] === 'ajaxUpdatePaginationLinks';
416
417 // -------------------------
418 // AJAX context: always log (even if fatal is from another plugin/theme/core),
419 // and emit JSON for admins so WordPress's generic "critical error" page doesn't hide details.
420 if ($isAjaxContext) {
421 // Only handle fatals for this endpoint when the context was created by our handler.
422 // This avoids logging unrelated admin-ajax fatals while still capturing "foreign" plugin/theme fatals
423 // that break our AJAX response.
424 $contextSourceOk = array_key_exists('abj404_context_source', $ctx) &&
425 $ctx['abj404_context_source'] === 'ViewUpdater::getPaginationLinks';
426 if (!$contextSourceOk) {
427 return false;
428 }
429
430 $bufferedOutput = '';
431 if (!(defined('ABJ404_TEST_DISABLE_OB') && ABJ404_TEST_DISABLE_OB)) {
432 if (ob_get_level() > 0) {
433 $bufferedOutput = (string)ob_get_contents();
434 }
435 $minLevel = array_key_exists('ob_level_before', $ctx) ? intval($ctx['ob_level_before']) : 0;
436 while (ob_get_level() > $minLevel) {
437 @ob_end_clean();
438 }
439 }
440
441 $details = array(
442 'fatal_error' => $lasterror,
443 'context' => $ctx,
444 );
445 if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) {
446 $lastQuery = $GLOBALS['wpdb']->last_query ?? '';
447 $details['wpdb'] = array(
448 'last_error' => $GLOBALS['wpdb']->last_error ?? '',
449 'last_query_redacted' => self::redactSqlShape($lastQuery),
450 'last_query_length' => is_string($lastQuery) ? strlen($lastQuery) : 0,
451 );
452 }
453 if ($bufferedOutput !== '') {
454 $details['buffered_output'] = substr($bufferedOutput, 0, 8000);
455 }
456
457 $line = date('c') . ' (ERROR): AJAX fatal error in ajaxUpdatePaginationLinks. Details: ' . self::safeJsonEncode($details);
458 self::safeWriteLine($line);
459
460 $isPluginAdmin = array_key_exists('is_plugin_admin', $ctx) ? (bool)$ctx['is_plugin_admin'] : null;
461 // Only try to compute admin status if it wasn't already determined earlier in the request.
462 if ($isPluginAdmin === null) {
463 try {
464 $logic = abj_service('plugin_logic');
465 if (is_object($logic) && method_exists($logic, 'userIsPluginAdmin')) {
466 $isPluginAdmin = $logic->userIsPluginAdmin();
467 }
468 } catch (Throwable $e) {
469 $isPluginAdmin = null;
470 }
471 }
472 if ($isPluginAdmin === null) {
473 // Best-effort fallback: show details to real WordPress admins if PluginLogic is broken.
474 if (function_exists('wp_get_current_user')) {
475 $user = wp_get_current_user();
476 if (is_object($user) && property_exists($user, 'roles') && is_array($user->roles)) {
477 $isPluginAdmin = in_array('administrator', $user->roles, true);
478 }
479 }
480 if ($isPluginAdmin !== true && function_exists('is_super_admin') && is_super_admin()) {
481 $isPluginAdmin = true;
482 }
483 }
484 if ($isPluginAdmin === null) {
485 $isPluginAdmin = false;
486 }
487
488 $payload = array(
489 'success' => false,
490 'data' => array(
491 'message' => 'Server error while updating the table.',
492 ),
493 );
494 if ($isPluginAdmin) {
495 $payload['data']['details'] = $details;
496 }
497
498 $GLOBALS['abj404_ajax_context']['response_sent'] = true;
499 return self::emitJsonAndExit($payload, 500);
500 }
501
502 // -------------------------
503 // Default behavior: only log plugin-scope fatals (avoid noise from other plugins/themes),
504 // except plugin admin page requests where we deliberately capture foreign fatals too.
505 try {
506 $errno = $lasterror['type'];
507 $errfile = is_string($lasterror['file']) ? $lasterror['file'] : '';
508 $slashPos2 = $f->strpos(ABJ404_NAME, '/');
509 $pluginFolder = $f->substr(ABJ404_NAME, 0, ($slashPos2 !== false ? $slashPos2 : null));
510
511 $isPluginScopeFatal = ($f->strpos($errfile, $pluginFolder) !== false);
512
513 // If the error file does not contain our plugin name, ignore it unless
514 // we are rendering the plugin admin page where blank-page prevention is critical.
515 if (!$isPluginScopeFatal && !$isPluginAdminPage) {
516 return false;
517 }
518
519 $extraInfo = "(none)";
520 $ctxDebugInfo = abj_service('request_context')->debug_info;
521 if ($ctxDebugInfo !== '') {
522 $extraInfo = stripcslashes(wp_kses_post((string)json_encode($ctxDebugInfo)));
523 }
524 $contextPrefix = $isPluginScopeFatal
525 ? 'ABJ404-SOLUTION Fatal error handler: '
526 : 'ABJ404-SOLUTION Fatal error handler (plugin admin page, foreign scope): ';
527
528 $errmsg = $contextPrefix .
529 stripcslashes(wp_kses_post((string)json_encode($lasterror))) .
530 ", \nAdditional info: " . $extraInfo . ", mbstring: " .
531 (extension_loaded('mbstring') ? 'true' : 'false');
532
533 $abj404logging = abj_service('logging');
534 if ($abj404logging != null) {
535 switch ($errno) {
536 case E_NOTICE:
537 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
538 if (in_array($serverName, $GLOBALS['abj404_whitelist'])) {
539 $abj404logging->debugMessage($errmsg);
540 }
541 break;
542
543 default:
544 $abj404logging->errorMessage($errmsg);
545 break;
546 }
547 } else {
548 echo $errmsg;
549 }
550 } catch (Throwable $ex) {
551 // Last-resort breadcrumb: inner logging itself failed during the
552 // fatal-error path. See NormalErrorHandler() above for rationale.
553 @error_log('404 Solution: error handler itself failed: ' . $ex->getMessage());
554 }
555
556 if ($isPluginAdminPage) {
557 self::renderAdminFatalFallback($lasterror);
558 }
559
560 return false;
561 }
562 }
563