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