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