Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 183 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
| MWExceptionRenderer | |
0.00% |
0 / 182 |
|
0.00% |
0 / 18 |
3080 | |
0.00% |
0 / 1 |
| shouldShowExceptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setShowExceptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| output | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
132 | |||
| useOutputPage | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
90 | |||
| reportHTML | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
| getHTML | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
6 | |||
| msg | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| msgObj | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
| getText | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| getShowBacktraceError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getExceptionTitle | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| getCustomMessage | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| isCommandLine | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| header | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| statusHeader | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| printError | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| reportOutageHTML | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
12 | |||
| cspHeader | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Exception; |
| 8 | |
| 9 | use Exception; |
| 10 | use LocalisationCache; |
| 11 | use MediaWiki\Context\RequestContext; |
| 12 | use MediaWiki\Html\Html; |
| 13 | use MediaWiki\Language\RawMessage; |
| 14 | use MediaWiki\MainConfigNames; |
| 15 | use MediaWiki\MediaWikiServices; |
| 16 | use MediaWiki\Message\Message; |
| 17 | use MediaWiki\Request\ContentSecurityPolicy; |
| 18 | use MediaWiki\Request\WebRequest; |
| 19 | use Throwable; |
| 20 | use Wikimedia\AtEase; |
| 21 | use Wikimedia\Http\HttpStatus; |
| 22 | use Wikimedia\Message\MessageParam; |
| 23 | use Wikimedia\Message\MessageSpecifier; |
| 24 | use Wikimedia\Rdbms\DBConnectionError; |
| 25 | use Wikimedia\Rdbms\DBExpectedError; |
| 26 | use Wikimedia\Rdbms\DBReadOnlyError; |
| 27 | use 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 | */ |
| 33 | class 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 */ |
| 445 | class_alias( MWExceptionRenderer::class, 'MWExceptionRenderer' ); |