Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / exception / MWExceptionRenderer.php
blob500a91fe4125109191ebddac07a49101efd1e161
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.
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.
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
18 * @file
21 use MediaWiki\Context\RequestContext;
22 use MediaWiki\Html\Html;
23 use MediaWiki\Language\RawMessage;
24 use MediaWiki\MainConfigNames;
25 use MediaWiki\MediaWikiServices;
26 use MediaWiki\Message\Message;
27 use MediaWiki\Request\WebRequest;
28 use Wikimedia\AtEase;
29 use Wikimedia\Message\MessageParam;
30 use Wikimedia\Message\MessageSpecifier;
31 use Wikimedia\Rdbms\DBConnectionError;
32 use Wikimedia\Rdbms\DBExpectedError;
33 use Wikimedia\Rdbms\DBReadOnlyError;
34 use Wikimedia\RequestTimeout\RequestTimeoutException;
36 /**
37 * Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
38 * @since 1.28
40 class MWExceptionRenderer {
41 public const AS_RAW = 1; // show as text
42 public const AS_PRETTY = 2; // show as HTML
44 /**
45 * Whether to print exception details.
47 * The default is configured by $wgShowExceptionDetails.
48 * May be changed at runtime via MWExceptionRenderer::setShowExceptionDetails().
50 * @see MainConfigNames::ShowExceptionDetails
51 * @var bool
53 private static $showExceptionDetails = false;
55 /**
56 * @internal For use within core wiring only.
57 * @return bool
59 public static function shouldShowExceptionDetails(): bool {
60 return self::$showExceptionDetails;
63 /**
64 * @param bool $showDetails
65 * @internal For use by Setup.php and other internal use cases.
67 public static function setShowExceptionDetails( bool $showDetails ): void {
68 self::$showExceptionDetails = $showDetails;
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
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;
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();
98 if ( defined( 'MW_API' ) ) {
99 self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) );
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 );
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();
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 );
141 print nl2br( htmlspecialchars( $message ) ) . "\n";
142 self::header( "Content-Length: " . ob_get_length() );
143 ob_end_flush();
148 * @param Throwable $e
149 * @return bool Should the throwable use $wgOut to output the error?
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;
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' )
174 * Output the throwable report using HTML
176 * @param Throwable $e
178 private static function reportHTML( Throwable $e ) {
179 if ( self::useOutputPage( $e ) ) {
180 $out = RequestContext::getMain()->getOutput();
181 $out->prepareErrorPage();
182 $out->setPageTitleMsg( self::getExceptionTitle( $e ) );
184 // Show any custom GUI message before the details
185 $customMessage = self::getCustomMessage( $e );
186 if ( $customMessage !== null ) {
187 $out->addHTML( Html::element( 'p', [], $customMessage ) );
189 $out->addHTML( self::getHTML( $e ) );
190 // Content-Type is set by OutputPage::output
191 $out->output();
192 } else {
193 self::header( 'Content-Type: text/html; charset=UTF-8' );
194 $pageTitle = self::msg( 'internalerror', 'Internal error' );
195 echo "<!DOCTYPE html>\n" .
196 '<html><head>' .
197 // Mimic OutputPage::setPageTitle behaviour
198 '<title>' .
199 htmlspecialchars( self::msg( 'pagetitle', '$1 - MediaWiki', $pageTitle ) ) .
200 '</title>' .
201 '<meta name="color-scheme" content="light dark" />' .
202 '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
203 "</head><body>\n";
205 echo self::getHTML( $e );
207 echo "</body></html>\n";
212 * Format an HTML message for the given exception object.
214 * @param Throwable $e
215 * @return string Html to output
217 public static function getHTML( Throwable $e ) {
218 if ( self::shouldShowExceptionDetails() ) {
219 $html = '<div dir=ltr>' . Html::errorBox( "<p>" .
220 nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) .
221 '</p><p>Backtrace:</p><p>' .
222 nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) .
223 "</p>\n"
224 ) . '</div>';
225 } else {
226 $logId = WebRequest::getRequestId();
227 $html = Html::errorBox(
228 htmlspecialchars(
229 '[' . $logId . '] ' .
230 gmdate( 'Y-m-d H:i:s' ) . ": " .
231 self::msg( "internalerror-fatal-exception",
232 "Fatal exception of type $1",
233 get_class( $e ),
234 $logId,
235 MWExceptionHandler::getURL()
237 ) . "<!-- " . wordwrap( self::getShowBacktraceError(), 50 ) . " -->";
240 return $html;
244 * Get a message string from i18n
246 * @param string $key Message name
247 * @param string $fallback Default message if the message cache can't be
248 * called by the exception
249 * @phpcs:ignore Generic.Files.LineLength
250 * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params
251 * See Message::params()
252 * @return string Message with arguments replaced
254 public static function msg( $key, $fallback, ...$params ) {
255 // NOTE: Keep logic in sync with MWException::msg
256 $res = self::msgObj( $key, $fallback, ...$params )->text();
257 return strtr( $res, [
258 '{{SITENAME}}' => 'MediaWiki',
259 ] );
262 /** Get a Message object from i18n.
264 * @param string $key Message name
265 * @param string $fallback Default message if the message cache can't be
266 * called by the exception
267 * @phpcs:ignore Generic.Files.LineLength
268 * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params
269 * See Message::params()
270 * @return Message|RawMessage
272 private static function msgObj( string $key, string $fallback, ...$params ): Message {
273 // NOTE: Keep logic in sync with MWException::msg.
274 try {
275 $res = wfMessage( $key, ...$params );
276 } catch ( Exception $e ) {
277 // Fallback to static message text and generic sitename.
278 // Avoid live config as this must work before Setup/MediaWikiServices finish.
279 $res = new RawMessage( $fallback, $params );
281 // We are in an error state, best to minimize how much work we do.
282 $res->useDatabase( false );
283 $isSafeToLoad = RequestContext::getMain()->getUser()->isSafeToLoad();
284 if ( !$isSafeToLoad ) {
285 $res->inContentLanguage();
287 return $res;
291 * @param Throwable $e
292 * @return string
294 private static function getText( Throwable $e ) {
295 // XXX: do we need a parameter to control inclusion of exception details?
296 if ( self::shouldShowExceptionDetails() ) {
297 return MWExceptionHandler::getLogMessage( $e ) .
298 "\nBacktrace:\n" .
299 MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
300 } else {
301 return self::getShowBacktraceError() . "\n";
306 * @return string
308 private static function getShowBacktraceError() {
309 $var = '$wgShowExceptionDetails = true;';
310 return "Set $var at the bottom of LocalSettings.php to show detailed debugging information.";
314 * Get the page title to be used for a given exception.
316 * @param Throwable $e
317 * @return Message
319 private static function getExceptionTitle( Throwable $e ): Message {
320 if ( $e instanceof DBReadOnlyError ) {
321 return self::msgObj( 'readonly', 'Database is locked' );
322 } elseif ( $e instanceof DBExpectedError ) {
323 return self::msgObj( 'databaseerror', 'Database error' );
324 } elseif ( $e instanceof RequestTimeoutException ) {
325 return self::msgObj( 'timeouterror', 'Request timeout' );
326 } else {
327 return self::msgObj( 'internalerror', 'Internal error' );
332 * Extract an additional user-visible message from an exception, or null if
333 * it has none.
335 * @param Throwable $e
336 * @return string|null
338 private static function getCustomMessage( Throwable $e ) {
339 try {
340 if ( $e instanceof MessageSpecifier ) {
341 $msg = Message::newFromSpecifier( $e );
342 } elseif ( $e instanceof RequestTimeoutException ) {
343 $msg = wfMessage( 'timeouterror-text', $e->getLimit() );
344 } else {
345 return null;
347 $text = $msg->text();
348 } catch ( Exception $e2 ) {
349 return null;
351 return $text;
355 * @return bool
357 private static function isCommandLine() {
358 return MW_ENTRY_POINT === 'cli';
362 * @param string $header
364 private static function header( $header ) {
365 if ( !headers_sent() ) {
366 header( $header );
371 * @param int $code
373 private static function statusHeader( $code ) {
374 if ( !headers_sent() ) {
375 HttpStatus::header( $code );
380 * Print a message, if possible to STDERR.
381 * Use this in command line mode only (see isCommandLine)
383 * @suppress SecurityCheck-XSS
384 * @param string $message Failure text
386 private static function printError( $message ) {
387 // NOTE: STDERR may not be available, especially if php-cgi is used from the
388 // command line (T17602). Try to produce meaningful output anyway. Using
389 // echo may corrupt output to STDOUT though.
390 if ( !defined( 'MW_PHPUNIT_TEST' ) && defined( 'STDERR' ) ) {
391 fwrite( STDERR, $message );
392 } else {
393 echo $message;
398 * @param Throwable $e
400 private static function reportOutageHTML( Throwable $e ) {
401 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
402 $showExceptionDetails = $mainConfig->get( MainConfigNames::ShowExceptionDetails );
403 $showHostnames = $mainConfig->get( MainConfigNames::ShowHostnames );
404 $sorry = htmlspecialchars( self::msg(
405 'dberr-problems',
406 'Sorry! This site is experiencing technical difficulties.'
407 ) );
408 $again = htmlspecialchars( self::msg(
409 'dberr-again',
410 'Try waiting a few minutes and reloading.'
411 ) );
413 if ( $showHostnames ) {
414 $info = str_replace(
415 '$1',
416 Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ),
417 htmlspecialchars( self::msg( 'dberr-info', '($1)' ) )
419 } else {
420 $info = htmlspecialchars( self::msg(
421 'dberr-info-hidden',
422 '(Cannot access the database)'
423 ) );
426 MediaWikiServices::getInstance()->getMessageCache()->disable(); // no DB access
427 $html = "<!DOCTYPE html>\n" .
428 '<html><head>' .
429 '<title>MediaWiki</title>' .
430 '<meta name="color-scheme" content="light dark" />' .
431 '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
432 "</head><body><h1>$sorry</h1><p>$again</p><p><small>$info</small></p>";
434 if ( $showExceptionDetails ) {
435 $html .= '<p>Backtrace:</p><pre>' .
436 htmlspecialchars( $e->getTraceAsString() ) . '</pre>';
439 $html .= '</body></html>';
440 self::header( 'Content-Type: text/html; charset=UTF-8' );
441 echo $html;