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