ErrorHandler.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. <?php
  2. declare(strict_types=1);
  3. namespace PhpMyAdmin;
  4. use ErrorException;
  5. use Throwable;
  6. use function __;
  7. use function array_splice;
  8. use function count;
  9. use function defined;
  10. use function error_reporting;
  11. use function get_class;
  12. use function htmlspecialchars;
  13. use function set_error_handler;
  14. use function set_exception_handler;
  15. use function trigger_error;
  16. use const E_COMPILE_ERROR;
  17. use const E_COMPILE_WARNING;
  18. use const E_CORE_ERROR;
  19. use const E_CORE_WARNING;
  20. use const E_DEPRECATED;
  21. use const E_ERROR;
  22. use const E_NOTICE;
  23. use const E_PARSE;
  24. use const E_RECOVERABLE_ERROR;
  25. use const E_USER_DEPRECATED;
  26. use const E_USER_ERROR;
  27. use const E_USER_NOTICE;
  28. use const E_USER_WARNING;
  29. use const E_WARNING;
  30. use const PHP_VERSION_ID;
  31. /**
  32. * handling errors
  33. */
  34. class ErrorHandler
  35. {
  36. /**
  37. * holds errors to be displayed or reported later ...
  38. *
  39. * @var Error[]
  40. */
  41. protected $errors = [];
  42. /**
  43. * Hide location of errors
  44. *
  45. * @var bool
  46. */
  47. protected $hideLocation = false;
  48. /**
  49. * Initial error reporting state
  50. *
  51. * @var int
  52. */
  53. protected $errorReporting = 0;
  54. public function __construct()
  55. {
  56. /**
  57. * Do not set ourselves as error handler in case of testsuite.
  58. *
  59. * This behavior is not tested there and breaks other tests as they
  60. * rely on PHPUnit doing it's own error handling which we break here.
  61. */
  62. if (! defined('TESTSUITE')) {
  63. set_exception_handler([$this, 'handleException']);
  64. set_error_handler([$this, 'handleError']);
  65. }
  66. if (! Util::isErrorReportingAvailable()) {
  67. return;
  68. }
  69. $this->errorReporting = error_reporting();
  70. }
  71. /**
  72. * Destructor
  73. *
  74. * stores errors in session
  75. */
  76. public function __destruct()
  77. {
  78. if (! isset($_SESSION['errors'])) {
  79. $_SESSION['errors'] = [];
  80. }
  81. // remember only not displayed errors
  82. foreach ($this->errors as $key => $error) {
  83. /**
  84. * We don't want to store all errors here as it would
  85. * explode user session.
  86. */
  87. if (count($_SESSION['errors']) >= 10) {
  88. $error = new Error(
  89. 0,
  90. __('Too many error messages, some are not displayed.'),
  91. __FILE__,
  92. __LINE__
  93. );
  94. $_SESSION['errors'][$error->getHash()] = $error;
  95. break;
  96. }
  97. if ((! ($error instanceof Error)) || $error->isDisplayed()) {
  98. continue;
  99. }
  100. $_SESSION['errors'][$key] = $error;
  101. }
  102. }
  103. /**
  104. * Toggles location hiding
  105. *
  106. * @param bool $hide Whether to hide
  107. */
  108. public function setHideLocation(bool $hide): void
  109. {
  110. $this->hideLocation = $hide;
  111. }
  112. /**
  113. * returns array with all errors
  114. *
  115. * @param bool $check Whether to check for session errors
  116. *
  117. * @return Error[]
  118. */
  119. public function getErrors(bool $check = true): array
  120. {
  121. if ($check) {
  122. $this->checkSavedErrors();
  123. }
  124. return $this->errors;
  125. }
  126. /**
  127. * returns the errors occurred in the current run only.
  128. * Does not include the errors saved in the SESSION
  129. *
  130. * @return Error[]
  131. */
  132. public function getCurrentErrors(): array
  133. {
  134. return $this->errors;
  135. }
  136. /**
  137. * Pops recent errors from the storage
  138. *
  139. * @param int $count Old error count (amount of errors to splice)
  140. *
  141. * @return Error[] The non spliced elements (total-$count)
  142. */
  143. public function sliceErrors(int $count): array
  144. {
  145. // store the errors before any operation, example number of items: 10
  146. $errors = $this->getErrors(false);
  147. // before array_splice $this->errors has 10 elements
  148. // cut out $count items out, let's say $count = 9
  149. // $errors will now contain 10 - 9 = 1 elements
  150. // $this->errors will contain the 9 elements left
  151. $this->errors = array_splice($errors, 0, $count);
  152. return $errors;
  153. }
  154. /**
  155. * Error handler - called when errors are triggered/occurred
  156. *
  157. * This calls the addError() function, escaping the error string
  158. * Ignores the errors wherever Error Control Operator (@) is used.
  159. *
  160. * @param int $errno error number
  161. * @param string $errstr error string
  162. * @param string $errfile error file
  163. * @param int $errline error line
  164. *
  165. * @throws ErrorException
  166. */
  167. public function handleError(
  168. int $errno,
  169. string $errstr,
  170. string $errfile,
  171. int $errline
  172. ): void {
  173. global $cfg;
  174. if (Util::isErrorReportingAvailable()) {
  175. /**
  176. * Check if Error Control Operator (@) was used, but still show
  177. * user errors even in this case.
  178. * See: https://github.com/phpmyadmin/phpmyadmin/issues/16729
  179. */
  180. $isSilenced = ! (error_reporting() & $errno);
  181. if (PHP_VERSION_ID < 80000) {
  182. $isSilenced = error_reporting() == 0;
  183. }
  184. if (isset($cfg['environment']) && $cfg['environment'] === 'development' && ! $isSilenced) {
  185. throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
  186. }
  187. if (
  188. $isSilenced &&
  189. $this->errorReporting != 0 &&
  190. ($errno & (E_USER_WARNING | E_USER_ERROR | E_USER_NOTICE | E_USER_DEPRECATED)) == 0
  191. ) {
  192. return;
  193. }
  194. } else {
  195. if (($errno & (E_USER_WARNING | E_USER_ERROR | E_USER_NOTICE | E_USER_DEPRECATED)) == 0) {
  196. return;
  197. }
  198. }
  199. $this->addError($errstr, $errno, $errfile, $errline, true);
  200. }
  201. /**
  202. * Hides exception if it's not in the development environment.
  203. */
  204. public function handleException(Throwable $exception): void
  205. {
  206. $config = $GLOBALS['config'] ?? null;
  207. $this->hideLocation = ! $config instanceof Config || $config->get('environment') !== 'development';
  208. $message = get_class($exception);
  209. if (! ($exception instanceof \Error) || ! $this->hideLocation) {
  210. $message .= ': ' . $exception->getMessage();
  211. }
  212. $this->addError(
  213. $message,
  214. (int) $exception->getCode(),
  215. $exception->getFile(),
  216. $exception->getLine()
  217. );
  218. }
  219. /**
  220. * Add an error; can also be called directly (with or without escaping)
  221. *
  222. * The following error types cannot be handled with a user defined function:
  223. * E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR,
  224. * E_COMPILE_WARNING,
  225. * and most of E_STRICT raised in the file where set_error_handler() is called.
  226. *
  227. * Do not use the context parameter as we want to avoid storing the
  228. * complete $GLOBALS inside $_SESSION['errors']
  229. *
  230. * @param string $errstr error string
  231. * @param int $errno error number
  232. * @param string $errfile error file
  233. * @param int $errline error line
  234. * @param bool $escape whether to escape the error string
  235. */
  236. public function addError(
  237. string $errstr,
  238. int $errno,
  239. string $errfile,
  240. int $errline,
  241. bool $escape = true
  242. ): void {
  243. if ($escape) {
  244. $errstr = htmlspecialchars($errstr);
  245. }
  246. // create error object
  247. $error = new Error($errno, $errstr, $errfile, $errline);
  248. $error->setHideLocation($this->hideLocation);
  249. // Deprecation errors will be shown in development environment, as they will have a different number.
  250. if ($error->getNumber() !== E_DEPRECATED) {
  251. // do not repeat errors
  252. $this->errors[$error->getHash()] = $error;
  253. }
  254. switch ($error->getNumber()) {
  255. case 2048: // E_STRICT
  256. case E_DEPRECATED:
  257. case E_NOTICE:
  258. case E_WARNING:
  259. case E_CORE_WARNING:
  260. case E_COMPILE_WARNING:
  261. case E_RECOVERABLE_ERROR:
  262. /* Avoid rendering BB code in PHP errors */
  263. $error->setBBCode(false);
  264. break;
  265. case E_USER_NOTICE:
  266. case E_USER_WARNING:
  267. case E_USER_ERROR:
  268. case E_USER_DEPRECATED:
  269. // just collect the error
  270. // display is called from outside
  271. break;
  272. case E_ERROR:
  273. case E_PARSE:
  274. case E_CORE_ERROR:
  275. case E_COMPILE_ERROR:
  276. default:
  277. // FATAL error, display it and exit
  278. $this->dispFatalError($error);
  279. if (! defined('TESTSUITE')) {
  280. exit; // @codeCoverageIgnore
  281. }
  282. }
  283. }
  284. /**
  285. * trigger a custom error
  286. *
  287. * @param string $errorInfo error message
  288. * @param int $errorNumber error number
  289. * @psalm-param 256|512|1024|16384 $errorNumber
  290. */
  291. public function triggerError(string $errorInfo, int $errorNumber = E_USER_NOTICE): void
  292. {
  293. // we could also extract file and line from backtrace
  294. // and call handleError() directly
  295. trigger_error($errorInfo, $errorNumber);
  296. }
  297. /**
  298. * display fatal error and exit
  299. *
  300. * @param Error $error the error
  301. */
  302. protected function dispFatalError(Error $error): void
  303. {
  304. $response = ResponseRenderer::getInstance();
  305. if (! $response->headersSent()) {
  306. $response->disable();
  307. $response->addHTML('<html><head><title>');
  308. $response->addHTML($error->getTitle());
  309. $response->addHTML('</title></head>' . "\n");
  310. }
  311. $response->addHTML($error->getDisplay());
  312. $response->addHTML('</body></html>');
  313. if (! defined('TESTSUITE')) {
  314. exit;
  315. }
  316. }
  317. /**
  318. * Displays user errors not displayed
  319. */
  320. public function dispUserErrors(): void
  321. {
  322. echo $this->getDispUserErrors();
  323. }
  324. /**
  325. * Renders user errors not displayed
  326. */
  327. public function getDispUserErrors(): string
  328. {
  329. $retval = '';
  330. foreach ($this->getErrors() as $error) {
  331. if (! $error->isUserError() || $error->isDisplayed()) {
  332. continue;
  333. }
  334. $retval .= $error->getDisplay();
  335. }
  336. return $retval;
  337. }
  338. /**
  339. * renders errors not displayed
  340. */
  341. public function getDispErrors(): string
  342. {
  343. $retval = '';
  344. // display errors if SendErrorReports is set to 'ask'.
  345. if ($GLOBALS['cfg']['SendErrorReports'] !== 'never') {
  346. foreach ($this->getErrors() as $error) {
  347. if ($error->isDisplayed()) {
  348. continue;
  349. }
  350. $retval .= $error->getDisplay();
  351. }
  352. } else {
  353. $retval .= $this->getDispUserErrors();
  354. }
  355. // if preference is not 'never' and
  356. // there are 'actual' errors to be reported
  357. if ($GLOBALS['cfg']['SendErrorReports'] !== 'never' && $this->countErrors() != $this->countUserErrors()) {
  358. // add report button.
  359. $retval .= '<form method="post" action="' . Url::getFromRoute('/error-report')
  360. . '" id="pma_report_errors_form"';
  361. if ($GLOBALS['cfg']['SendErrorReports'] === 'always') {
  362. // in case of 'always', generate 'invisible' form.
  363. $retval .= ' class="hide"';
  364. }
  365. $retval .= '>';
  366. $retval .= Url::getHiddenFields([
  367. 'exception_type' => 'php',
  368. 'send_error_report' => '1',
  369. 'server' => $GLOBALS['server'],
  370. ]);
  371. $retval .= '<input type="submit" value="'
  372. . __('Report')
  373. . '" id="pma_report_errors" class="btn btn-primary float-end">'
  374. . '<input type="checkbox" name="always_send"'
  375. . ' id="errorReportAlwaysSendCheckbox" value="true">'
  376. . '<label for="errorReportAlwaysSendCheckbox">'
  377. . __('Automatically send report next time')
  378. . '</label>';
  379. if ($GLOBALS['cfg']['SendErrorReports'] === 'ask') {
  380. // add ignore buttons
  381. $retval .= '<input type="submit" value="'
  382. . __('Ignore')
  383. . '" id="pma_ignore_errors_bottom" class="btn btn-secondary float-end">';
  384. }
  385. $retval .= '<input type="submit" value="'
  386. . __('Ignore All')
  387. . '" id="pma_ignore_all_errors_bottom" class="btn btn-secondary float-end">';
  388. $retval .= '</form>';
  389. }
  390. return $retval;
  391. }
  392. /**
  393. * look in session for saved errors
  394. */
  395. protected function checkSavedErrors(): void
  396. {
  397. if (! isset($_SESSION['errors'])) {
  398. return;
  399. }
  400. // restore saved errors
  401. foreach ($_SESSION['errors'] as $hash => $error) {
  402. if (! ($error instanceof Error) || isset($this->errors[$hash])) {
  403. continue;
  404. }
  405. $this->errors[$hash] = $error;
  406. }
  407. // delete stored errors
  408. $_SESSION['errors'] = [];
  409. unset($_SESSION['errors']);
  410. }
  411. /**
  412. * return count of errors
  413. *
  414. * @param bool $check Whether to check for session errors
  415. *
  416. * @return int number of errors occurred
  417. */
  418. public function countErrors(bool $check = true): int
  419. {
  420. return count($this->getErrors($check));
  421. }
  422. /**
  423. * return count of user errors
  424. *
  425. * @return int number of user errors occurred
  426. */
  427. public function countUserErrors(): int
  428. {
  429. $count = 0;
  430. if ($this->countErrors()) {
  431. foreach ($this->getErrors() as $error) {
  432. if (! $error->isUserError()) {
  433. continue;
  434. }
  435. $count++;
  436. }
  437. }
  438. return $count;
  439. }
  440. /**
  441. * whether use errors occurred or not
  442. */
  443. public function hasUserErrors(): bool
  444. {
  445. return (bool) $this->countUserErrors();
  446. }
  447. /**
  448. * whether errors occurred or not
  449. */
  450. public function hasErrors(): bool
  451. {
  452. return (bool) $this->countErrors();
  453. }
  454. /**
  455. * number of errors to be displayed
  456. *
  457. * @return int number of errors to be displayed
  458. */
  459. public function countDisplayErrors(): int
  460. {
  461. if ($GLOBALS['cfg']['SendErrorReports'] !== 'never') {
  462. return $this->countErrors();
  463. }
  464. return $this->countUserErrors();
  465. }
  466. /**
  467. * whether there are errors to display or not
  468. */
  469. public function hasDisplayErrors(): bool
  470. {
  471. return (bool) $this->countDisplayErrors();
  472. }
  473. /**
  474. * Deletes previously stored errors in SESSION.
  475. * Saves current errors in session as previous errors.
  476. * Required to save current errors in case 'ask'
  477. */
  478. public function savePreviousErrors(): void
  479. {
  480. unset($_SESSION['prev_errors']);
  481. $_SESSION['prev_errors'] = $GLOBALS['errorHandler']->getCurrentErrors();
  482. }
  483. /**
  484. * Function to check if there are any errors to be prompted.
  485. * Needed because user warnings raised are
  486. * also collected by global error handler.
  487. * This distinguishes between the actual errors
  488. * and user errors raised to warn user.
  489. */
  490. public function hasErrorsForPrompt(): bool
  491. {
  492. return $GLOBALS['cfg']['SendErrorReports'] !== 'never'
  493. && $this->countErrors() != $this->countUserErrors();
  494. }
  495. /**
  496. * Function to report all the collected php errors.
  497. * Must be called at the end of each script
  498. * by the $GLOBALS['errorHandler'] only.
  499. */
  500. public function reportErrors(): void
  501. {
  502. // if there're no actual errors,
  503. if (! $this->hasErrors() || $this->countErrors() == $this->countUserErrors()) {
  504. // then simply return.
  505. return;
  506. }
  507. // Delete all the prev_errors in session & store new prev_errors in session
  508. $this->savePreviousErrors();
  509. $response = ResponseRenderer::getInstance();
  510. $jsCode = '';
  511. if ($GLOBALS['cfg']['SendErrorReports'] === 'always') {
  512. if ($response->isAjax()) {
  513. // set flag for automatic report submission.
  514. $response->addJSON('sendErrorAlways', '1');
  515. } else {
  516. // send the error reports asynchronously & without asking user
  517. $jsCode .= '$("#pma_report_errors_form").submit();'
  518. . 'Functions.ajaxShowMessage(
  519. Messages.phpErrorsBeingSubmitted, false
  520. );';
  521. // js code to appropriate focusing,
  522. $jsCode .= '$("html, body").animate({
  523. scrollTop:$(document).height()
  524. }, "slow");';
  525. }
  526. } elseif ($GLOBALS['cfg']['SendErrorReports'] === 'ask') {
  527. //ask user whether to submit errors or not.
  528. if (! $response->isAjax()) {
  529. // js code to show appropriate msgs, event binding & focusing.
  530. $jsCode = 'Functions.ajaxShowMessage(Messages.phpErrorsFound);'
  531. . '$("#pma_ignore_errors_popup").on("click", function() {
  532. Functions.ignorePhpErrors()
  533. });'
  534. . '$("#pma_ignore_all_errors_popup").on("click",
  535. function() {
  536. Functions.ignorePhpErrors(false)
  537. });'
  538. . '$("#pma_ignore_errors_bottom").on("click", function(e) {
  539. e.preventDefault();
  540. Functions.ignorePhpErrors()
  541. });'
  542. . '$("#pma_ignore_all_errors_bottom").on("click",
  543. function(e) {
  544. e.preventDefault();
  545. Functions.ignorePhpErrors(false)
  546. });'
  547. . '$("html, body").animate({
  548. scrollTop:$(document).height()
  549. }, "slow");';
  550. }
  551. }
  552. // The errors are already sent from the response.
  553. // Just focus on errors division upon load event.
  554. $response->getFooter()->getScripts()->addCode($jsCode);
  555. }
  556. }