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