Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWExceptionRenderer
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 17
2862
0.00% covered (danger)
0.00%
0 / 1
 shouldShowExceptionDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setShowExceptionDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
132
 useOutputPage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
90
 reportHTML
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getHTML
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 msg
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 msgObj
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getShowBacktraceError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExceptionTitle
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getCustomMessage
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 isCommandLine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 header
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 statusHeader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 printError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 reportOutageHTML
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\Context\RequestContext;
22use MediaWiki\Html\Html;
23use MediaWiki\Language\RawMessage;
24use MediaWiki\MainConfigNames;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Message\Message;
27use MediaWiki\Request\WebRequest;
28use Wikimedia\AtEase;
29use Wikimedia\Rdbms\DBConnectionError;
30use Wikimedia\Rdbms\DBExpectedError;
31use Wikimedia\Rdbms\DBReadOnlyError;
32use Wikimedia\RequestTimeout\RequestTimeoutException;
33
34/**
35 * Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
36 * @since 1.28
37 */
38class MWExceptionRenderer {
39    public const AS_RAW = 1; // show as text
40    public const AS_PRETTY = 2; // show as HTML
41
42    /**
43     * Whether to print exception details.
44     *
45     * The default is configured by $wgShowExceptionDetails.
46     * May be changed at runtime via MWExceptionRenderer::setShowExceptionDetails().
47     *
48     * @see MainConfigNames::ShowExceptionDetails
49     */
50    private static $showExceptionDetails = false;
51
52    /**
53     * @internal For use within core wiring only.
54     * @return bool
55     */
56    public static function shouldShowExceptionDetails(): bool {
57        return self::$showExceptionDetails;
58    }
59
60    /**
61     * @param bool $showDetails
62     * @internal For use by Setup.php and other internal use cases.
63     */
64    public static function setShowExceptionDetails( bool $showDetails ): void {
65        self::$showExceptionDetails = $showDetails;
66    }
67
68    /**
69     * @param Throwable $e Original exception
70     * @param int $mode MWExceptionExposer::AS_* constant
71     * @param Throwable|null $eNew New throwable from attempting to show the first
72     */
73    public static function output( Throwable $e, $mode, Throwable $eNew = null ) {
74        $showExceptionDetails = self::shouldShowExceptionDetails();
75        if ( $e instanceof RequestTimeoutException && headers_sent() ) {
76            // Excimer's flag check happens on function return, so, a timeout
77            // can be thrown after exiting, say, `doPostOutputShutdown`, where
78            // headers are sent.  In which case, it's probably fine not to
79            // report this in any user visible way.  The general question of
80            // what to do about reporting an exception when headers have been
81            // sent is still unclear, but you probably don't want to
82            // `useOutputPage`.
83            return;
84        }
85
86        if ( function_exists( 'apache_setenv' ) ) {
87            // The client should not be blocked on "post-send" updates. If apache decides that
88            // a response should be gzipped, it will wait for PHP to finish since it cannot gzip
89            // anything until it has the full response (even with "Transfer-Encoding: chunked").
90            AtEase\AtEase::suppressWarnings();
91            apache_setenv( 'no-gzip', '1' );
92            AtEase\AtEase::restoreWarnings();
93        }
94
95        if ( defined( 'MW_API' ) ) {
96            self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) );
97        }
98
99        if ( self::isCommandLine() ) {
100            self::printError( self::getText( $e ) );
101        } elseif ( $mode === self::AS_PRETTY ) {
102            self::statusHeader( 500 );
103            ob_start();
104            if ( $e instanceof DBConnectionError ) {
105                self::reportOutageHTML( $e );
106            } else {
107                self::reportHTML( $e );
108            }
109            self::header( "Content-Length: " . ob_get_length() );
110            ob_end_flush();
111        } else {
112            ob_start();
113            self::statusHeader( 500 );
114            self::header( 'Content-Type: text/html; charset=UTF-8' );
115            if ( $eNew ) {
116                $message = "MediaWiki internal error.\n\n";
117                if ( $showExceptionDetails ) {
118                    $message .= 'Original exception: ' .
119                        MWExceptionHandler::getLogMessage( $e ) .
120                        "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $e ) .
121                        "\n\nException caught inside exception handler: " .
122                            MWExceptionHandler::getLogMessage( $eNew ) .
123                        "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $eNew );
124                } else {
125                    $message .= 'Original exception: ' .
126                        MWExceptionHandler::getPublicLogMessage( $e );
127                    $message .= "\n\nException caught inside exception handler.\n\n" .
128                        self::getShowBacktraceError();
129                }
130                $message .= "\n";
131            } elseif ( $showExceptionDetails ) {
132                $message = MWExceptionHandler::getLogMessage( $e ) .
133                    "\nBacktrace:\n" .
134                    MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
135            } else {
136                $message = MWExceptionHandler::getPublicLogMessage( $e );
137            }
138            print nl2br( htmlspecialchars( $message ) ) . "\n";
139            self::header( "Content-Length: " . ob_get_length() );
140            ob_end_flush();
141        }
142    }
143
144    /**
145     * @param Throwable $e
146     * @return bool Should the throwable use $wgOut to output the error?
147     */
148    private static function useOutputPage( Throwable $e ) {
149        // Can the exception use the Message class/wfMessage to get i18n-ed messages?
150        foreach ( $e->getTrace() as $frame ) {
151            if ( isset( $frame['class'] ) && $frame['class'] === LocalisationCache::class ) {
152                return false;
153            }
154        }
155
156        // Don't even bother with OutputPage if there's no Title context set,
157        // (e.g. we're in RL code on load.php) - the Skin system (and probably
158        // most of MediaWiki) won't work.
159        return (
160            !empty( $GLOBALS['wgFullyInitialised'] ) &&
161            !empty( $GLOBALS['wgOut'] ) &&
162            RequestContext::getMain()->getTitle() &&
163            !defined( 'MEDIAWIKI_INSTALL' ) &&
164            // Don't send a skinned HTTP 500 page to API clients.
165            !defined( 'MW_API' ) &&
166            !defined( 'MW_REST_API' )
167        );
168    }
169
170    /**
171     * Output the throwable report using HTML
172     *
173     * @param Throwable $e
174     */
175    private static function reportHTML( Throwable $e ) {
176        if ( self::useOutputPage( $e ) ) {
177            $out = RequestContext::getMain()->getOutput();
178            $out->prepareErrorPage();
179            $out->setPageTitleMsg( self::getExceptionTitle( $e ) );
180
181            // Show any custom GUI message before the details
182            $customMessage = self::getCustomMessage( $e );
183            if ( $customMessage !== null ) {
184                $out->addHTML( Html::element( 'p', [], $customMessage ) );
185            }
186            $out->addHTML( self::getHTML( $e ) );
187            // Content-Type is set by OutputPage::output
188            $out->output();
189        } else {
190            self::header( 'Content-Type: text/html; charset=UTF-8' );
191            $pageTitle = self::msg( 'internalerror', 'Internal error' );
192            echo "<!DOCTYPE html>\n" .
193                '<html><head>' .
194                // Mimic OutputPage::setPageTitle behaviour
195                '<title>' .
196                htmlspecialchars( self::msg( 'pagetitle', '$1 - MediaWiki', $pageTitle ) ) .
197                '</title>' .
198                '<meta name="color-scheme" content="light dark" />' .
199                '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
200                "</head><body>\n";
201
202            echo self::getHTML( $e );
203
204            echo "</body></html>\n";
205        }
206    }
207
208    /**
209     * Format an HTML message for the given exception object.
210     *
211     * @param Throwable $e
212     * @return string Html to output
213     */
214    public static function getHTML( Throwable $e ) {
215        if ( self::shouldShowExceptionDetails() ) {
216            $html = Html::errorBox( "<p>" .
217                nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) .
218                '</p><p>Backtrace:</p><p>' .
219                nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) .
220                "</p>\n",
221                '',
222                'mw-content-ltr'
223            );
224        } else {
225            $logId = WebRequest::getRequestId();
226            $html = Html::errorBox(
227                htmlspecialchars(
228                    '[' . $logId . '] ' .
229                    gmdate( 'Y-m-d H:i:s' ) . ": " .
230                    self::msg( "internalerror-fatal-exception",
231                        "Fatal exception of type $1",
232                        get_class( $e ),
233                        $logId,
234                        MWExceptionHandler::getURL()
235                ) ),
236                '',
237                'mw-content-ltr'
238            ) . "<!-- " . wordwrap( self::getShowBacktraceError(), 50 ) . " -->";
239        }
240
241        return $html;
242    }
243
244    /**
245     * Get a message string from i18n
246     *
247     * @param string $key Message name
248     * @param string $fallback Default message if the message cache can't be
249     *                  called by the exception
250     * @param mixed ...$params To pass to wfMessage()
251     * @return string Message with arguments replaced
252     */
253    public static function msg( $key, $fallback, ...$params ) {
254        // NOTE: Keep logic in sync with MWException::msg
255        $res = self::msgObj( $key, $fallback, ...$params )->text();
256        return strtr( $res, [
257            '{{SITENAME}}' => 'MediaWiki',
258        ] );
259    }
260
261    /** Get a Message object from i18n.
262     *
263     * @param string $key Message name
264     * @param string $fallback Default message if the message cache can't be
265     *                  called by the exception
266     * @param mixed ...$params To pass to wfMessage()
267     * @return Message|RawMessage
268     */
269    private static function msgObj( string $key, string $fallback, ...$params ): Message {
270        // NOTE: Keep logic in sync with MWException::msg.
271        try {
272            $res = wfMessage( $key, ...$params );
273        } catch ( Exception $e ) {
274            // Fallback to static message text and generic sitename.
275            // Avoid live config as this must work before Setup/MediaWikiServices finish.
276            $res = new RawMessage( $fallback, $params );
277        }
278        // We are in an error state, best to minimize how much work we do.
279        $res->useDatabase( false );
280        $isSafeToLoad = RequestContext::getMain()->getUser()->isSafeToLoad();
281        if ( !$isSafeToLoad ) {
282            $res->inContentLanguage();
283        }
284        return $res;
285    }
286
287    /**
288     * @param Throwable $e
289     * @return string
290     */
291    private static function getText( Throwable $e ) {
292        // XXX: do we need a parameter to control inclusion of exception details?
293        if ( self::shouldShowExceptionDetails() ) {
294            return MWExceptionHandler::getLogMessage( $e ) .
295                "\nBacktrace:\n" .
296                MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
297        } else {
298            return self::getShowBacktraceError() . "\n";
299        }
300    }
301
302    /**
303     * @return string
304     */
305    private static function getShowBacktraceError() {
306        $var = '$wgShowExceptionDetails = true;';
307        return "Set $var at the bottom of LocalSettings.php to show detailed debugging information.";
308    }
309
310    /**
311     * Get the page title to be used for a given exception.
312     *
313     * @param Throwable $e
314     * @return Message
315     */
316    private static function getExceptionTitle( Throwable $e ): Message {
317        if ( $e instanceof DBReadOnlyError ) {
318            return self::msgObj( 'readonly', 'Database is locked' );
319        } elseif ( $e instanceof DBExpectedError ) {
320            return self::msgObj( 'databaseerror', 'Database error' );
321        } elseif ( $e instanceof RequestTimeoutException ) {
322            return self::msgObj( 'timeouterror', 'Request timeout' );
323        } else {
324            return self::msgObj( 'internalerror', 'Internal error' );
325        }
326    }
327
328    /**
329     * Extract an additional user-visible message from an exception, or null if
330     * it has none.
331     *
332     * @param Throwable $e
333     * @return string|null
334     */
335    private static function getCustomMessage( Throwable $e ) {
336        try {
337            if ( $e instanceof MessageSpecifier ) {
338                $msg = Message::newFromSpecifier( $e );
339            } elseif ( $e instanceof RequestTimeoutException ) {
340                $msg = wfMessage( 'timeouterror-text', $e->getLimit() );
341            } else {
342                return null;
343            }
344            $text = $msg->text();
345        } catch ( Exception $e2 ) {
346            return null;
347        }
348        return $text;
349    }
350
351    /**
352     * @return bool
353     */
354    private static function isCommandLine() {
355        return MW_ENTRY_POINT === 'cli';
356    }
357
358    /**
359     * @param string $header
360     */
361    private static function header( $header ) {
362        if ( !headers_sent() ) {
363            header( $header );
364        }
365    }
366
367    /**
368     * @param int $code
369     */
370    private static function statusHeader( $code ) {
371        if ( !headers_sent() ) {
372            HttpStatus::header( $code );
373        }
374    }
375
376    /**
377     * Print a message, if possible to STDERR.
378     * Use this in command line mode only (see isCommandLine)
379     *
380     * @suppress SecurityCheck-XSS
381     * @param string $message Failure text
382     */
383    private static function printError( $message ) {
384        // NOTE: STDERR may not be available, especially if php-cgi is used from the
385        // command line (T17602). Try to produce meaningful output anyway. Using
386        // echo may corrupt output to STDOUT though.
387        if ( !defined( 'MW_PHPUNIT_TEST' ) && defined( 'STDERR' ) ) {
388            fwrite( STDERR, $message );
389        } else {
390            echo $message;
391        }
392    }
393
394    /**
395     * @param Throwable $e
396     */
397    private static function reportOutageHTML( Throwable $e ) {
398        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
399        $showExceptionDetails = $mainConfig->get( MainConfigNames::ShowExceptionDetails );
400        $showHostnames = $mainConfig->get( MainConfigNames::ShowHostnames );
401        $sorry = htmlspecialchars( self::msg(
402            'dberr-problems',
403            'Sorry! This site is experiencing technical difficulties.'
404        ) );
405        $again = htmlspecialchars( self::msg(
406            'dberr-again',
407            'Try waiting a few minutes and reloading.'
408        ) );
409
410        if ( $showHostnames ) {
411            $info = str_replace(
412                '$1',
413                Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ),
414                htmlspecialchars( self::msg( 'dberr-info', '($1)' ) )
415            );
416        } else {
417            $info = htmlspecialchars( self::msg(
418                'dberr-info-hidden',
419                '(Cannot access the database)'
420            ) );
421        }
422
423        MediaWikiServices::getInstance()->getMessageCache()->disable(); // no DB access
424        $html = "<!DOCTYPE html>\n" .
425                '<html><head>' .
426                '<title>MediaWiki</title>' .
427                '<meta name="color-scheme" content="light dark" />' .
428                '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
429                "</head><body><h1>$sorry</h1><p>$again</p><p><small>$info</small></p>";
430
431        if ( $showExceptionDetails ) {
432            $html .= '<p>Backtrace:</p><pre>' .
433                htmlspecialchars( $e->getTraceAsString() ) . '</pre>';
434        }
435
436        $html .= '</body></html>';
437        self::header( 'Content-Type: text/html; charset=UTF-8' );
438        echo $html;
439    }
440}