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\MediaWikiServices; 22use Wikimedia\AtEase; 23use Wikimedia\Rdbms\DBConnectionError; 24use Wikimedia\Rdbms\DBExpectedError; 25use Wikimedia\Rdbms\DBReadOnlyError; 26use Wikimedia\RequestTimeout\RequestTimeoutException; 27 28/** 29 * Class to expose exceptions to the client (API bots, users, admins using CLI scripts) 30 * @since 1.28 31 */ 32class MWExceptionRenderer { 33 public const AS_RAW = 1; // show as text 34 public const AS_PRETTY = 2; // show as HTML 35 36 /** 37 * @param Throwable $e Original exception 38 * @param int $mode MWExceptionExposer::AS_* constant 39 * @param Throwable|null $eNew New throwable from attempting to show the first 40 */ 41 public static function output( Throwable $e, $mode, Throwable $eNew = null ) { 42 global $wgMimeType, $wgShowExceptionDetails; 43 44 if ( function_exists( 'apache_setenv' ) ) { 45 // The client should not be blocked on "post-send" updates. If apache decides that 46 // a response should be gzipped, it will wait for PHP to finish since it cannot gzip 47 // anything until it has the full response (even with "Transfer-Encoding: chunked"). 48 AtEase\AtEase::suppressWarnings(); 49 apache_setenv( 'no-gzip', '1' ); 50 AtEase\AtEase::restoreWarnings(); 51 } 52 53 if ( defined( 'MW_API' ) ) { 54 self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) ); 55 } 56 57 if ( self::isCommandLine() ) { 58 self::printError( self::getText( $e ) ); 59 } elseif ( $mode === self::AS_PRETTY ) { 60 self::statusHeader( 500 ); 61 self::header( "Content-Type: $wgMimeType; charset=UTF-8" ); 62 ob_start(); 63 if ( $e instanceof DBConnectionError ) { 64 self::reportOutageHTML( $e ); 65 } else { 66 self::reportHTML( $e ); 67 } 68 self::header( "Content-Length: " . ob_get_length() ); 69 ob_end_flush(); 70 } else { 71 ob_start(); 72 self::statusHeader( 500 ); 73 self::header( "Content-Type: $wgMimeType; charset=UTF-8" ); 74 if ( $eNew ) { 75 $message = "MediaWiki internal error.\n\n"; 76 if ( $wgShowExceptionDetails ) { 77 $message .= 'Original exception: ' . 78 MWExceptionHandler::getLogMessage( $e ) . 79 "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $e ) . 80 "\n\nException caught inside exception handler: " . 81 MWExceptionHandler::getLogMessage( $eNew ) . 82 "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $eNew ); 83 } else { 84 $message .= 'Original exception: ' . 85 MWExceptionHandler::getPublicLogMessage( $e ); 86 $message .= "\n\nException caught inside exception handler.\n\n" . 87 self::getShowBacktraceError( $e ); 88 } 89 $message .= "\n"; 90 } elseif ( $wgShowExceptionDetails ) { 91 $message = MWExceptionHandler::getLogMessage( $e ) . 92 "\nBacktrace:\n" . 93 MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n"; 94 } else { 95 $message = MWExceptionHandler::getPublicLogMessage( $e ); 96 } 97 print nl2br( htmlspecialchars( $message ) ) . "\n"; 98 self::header( "Content-Length: " . ob_get_length() ); 99 ob_end_flush(); 100 } 101 } 102 103 /** 104 * @param Throwable $e 105 * @return bool Should the throwable use $wgOut to output the error? 106 */ 107 private static function useOutputPage( Throwable $e ) { 108 // Can the extension use the Message class/wfMessage to get i18n-ed messages? 109 foreach ( $e->getTrace() as $frame ) { 110 if ( isset( $frame['class'] ) && $frame['class'] === LocalisationCache::class ) { 111 return false; 112 } 113 } 114 115 // Don't even bother with OutputPage if there's no Title context set, 116 // (e.g. we're in RL code on load.php) - the Skin system (and probably 117 // most of MediaWiki) won't work. 118 119 return ( 120 !empty( $GLOBALS['wgFullyInitialised'] ) && 121 !empty( $GLOBALS['wgOut'] ) && 122 RequestContext::getMain()->getTitle() && 123 !defined( 'MEDIAWIKI_INSTALL' ) && 124 // Don't send a skinned HTTP 500 page to API clients. 125 !defined( 'MW_API' ) 126 ); 127 } 128 129 /** 130 * Output the throwable report using HTML 131 * 132 * @param Throwable $e 133 */ 134 private static function reportHTML( Throwable $e ) { 135 global $wgOut, $wgSitename; 136 137 if ( self::useOutputPage( $e ) ) { 138 $wgOut->prepareErrorPage( self::getExceptionTitle( $e ) ); 139 140 // Show any custom GUI message before the details 141 $customMessage = self::getCustomMessage( $e ); 142 if ( $customMessage !== null ) { 143 $wgOut->addHTML( Html::element( 'p', [], $customMessage ) ); 144 } 145 $wgOut->addHTML( self::getHTML( $e ) ); 146 147 $wgOut->output(); 148 } else { 149 self::header( 'Content-Type: text/html; charset=utf-8' ); 150 $pageTitle = self::msg( 'internalerror', 'Internal error' ); 151 echo "<!DOCTYPE html>\n" . 152 '<html><head>' . 153 // Mimick OutputPage::setPageTitle behaviour 154 '<title>' . 155 htmlspecialchars( self::msg( 'pagetitle', "$1 - $wgSitename", $pageTitle ) ) . 156 '</title>' . 157 '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' . 158 "</head><body>\n"; 159 160 echo self::getHTML( $e ); 161 162 echo "</body></html>\n"; 163 } 164 } 165 166 /** 167 * If $wgShowExceptionDetails is true, return a HTML message with a 168 * backtrace to the error, otherwise show a message to ask to set it to true 169 * to show that information. 170 * 171 * @param Throwable $e 172 * @return string Html to output 173 */ 174 public static function getHTML( Throwable $e ) { 175 global $wgShowExceptionDetails; 176 177 if ( $wgShowExceptionDetails ) { 178 $html = "<div class=\"errorbox mw-content-ltr\"><p>" . 179 nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) . 180 '</p><p>Backtrace:</p><p>' . 181 nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) . 182 "</p></div>\n"; 183 } else { 184 $logId = WebRequest::getRequestId(); 185 $html = "<div class=\"errorbox mw-content-ltr\">" . 186 htmlspecialchars( 187 '[' . $logId . '] ' . 188 gmdate( 'Y-m-d H:i:s' ) . ": " . 189 self::msg( "internalerror-fatal-exception", 190 "Fatal exception of type $1", 191 get_class( $e ), 192 $logId, 193 MWExceptionHandler::getURL() 194 ) ) . "</div>\n" . 195 "<!-- " . wordwrap( self::getShowBacktraceError( $e ), 50 ) . " -->"; 196 } 197 198 return $html; 199 } 200 201 /** 202 * Get a message from i18n 203 * 204 * @param string $key Message name 205 * @param string $fallback Default message if the message cache can't be 206 * called by the exception 207 * @param mixed ...$params To pass to wfMessage() 208 * @return string Message with arguments replaced 209 */ 210 private static function msg( $key, $fallback, ...$params ) { 211 global $wgSitename; 212 213 // FIXME: Keep logic in sync with MWException::msg. 214 try { 215 $res = wfMessage( $key, ...$params )->text(); 216 } catch ( Exception $e ) { 217 $res = wfMsgReplaceArgs( $fallback, $params ); 218 // If an exception happens inside message rendering, 219 // {{SITENAME}} sometimes won't be replaced. 220 $res = strtr( $res, [ 221 '{{SITENAME}}' => $wgSitename, 222 ] ); 223 } 224 return $res; 225 } 226 227 /** 228 * @param Throwable $e 229 * @return string 230 */ 231 private static function getText( Throwable $e ) { 232 global $wgShowExceptionDetails; 233 234 if ( $wgShowExceptionDetails ) { 235 return MWExceptionHandler::getLogMessage( $e ) . 236 "\nBacktrace:\n" . 237 MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n"; 238 } else { 239 return self::getShowBacktraceError( $e ) . "\n"; 240 } 241 } 242 243 /** 244 * @param Throwable $e 245 * @return string 246 */ 247 private static function getShowBacktraceError( Throwable $e ) { 248 $var = '$wgShowExceptionDetails = true;'; 249 return "Set $var at the bottom of LocalSettings.php to show detailed debugging information."; 250 } 251 252 /** 253 * Get the page title to be used for a given exception. 254 * 255 * @param Throwable $e 256 * @return string 257 */ 258 private static function getExceptionTitle( Throwable $e ) { 259 if ( $e instanceof MWException ) { 260 return $e->getPageTitle(); 261 } elseif ( $e instanceof DBReadOnlyError ) { 262 return self::msg( 'readonly', 'Database is locked' ); 263 } elseif ( $e instanceof DBExpectedError ) { 264 return self::msg( 'databaseerror', 'Database error' ); 265 } elseif ( $e instanceof RequestTimeoutException ) { 266 return self::msg( 'timeouterror', 'Request timeout' ); 267 } else { 268 return self::msg( 'internalerror', 'Internal error' ); 269 } 270 } 271 272 /** 273 * Extract an additional user-visible message from an exception, or null if 274 * it has none. 275 * 276 * @param Throwable $e 277 * @return string|null 278 */ 279 private static function getCustomMessage( Throwable $e ) { 280 try { 281 if ( $e instanceof MessageSpecifier ) { 282 $msg = Message::newFromSpecifier( $e ); 283 } elseif ( $e instanceof RequestTimeoutException ) { 284 $msg = wfMessage( 'timeouterror-text', $e->getLimit() ); 285 } else { 286 return null; 287 } 288 $text = $msg->text(); 289 } catch ( Exception $e2 ) { 290 return null; 291 } 292 return $text; 293 } 294 295 /** 296 * @return bool 297 */ 298 private static function isCommandLine() { 299 return !empty( $GLOBALS['wgCommandLineMode'] ); 300 } 301 302 /** 303 * @param string $header 304 */ 305 private static function header( $header ) { 306 if ( !headers_sent() ) { 307 header( $header ); 308 } 309 } 310 311 /** 312 * @param int $code 313 */ 314 private static function statusHeader( $code ) { 315 if ( !headers_sent() ) { 316 HttpStatus::header( $code ); 317 } 318 } 319 320 /** 321 * Print a message, if possible to STDERR. 322 * Use this in command line mode only (see isCommandLine) 323 * 324 * @suppress SecurityCheck-XSS 325 * @param string $message Failure text 326 */ 327 private static function printError( $message ) { 328 // NOTE: STDERR may not be available, especially if php-cgi is used from the 329 // command line (T17602). Try to produce meaningful output anyway. Using 330 // echo may corrupt output to STDOUT though. 331 if ( defined( 'STDERR' ) ) { 332 fwrite( STDERR, $message ); 333 } else { 334 echo $message; 335 } 336 } 337 338 /** 339 * @param Throwable $e 340 */ 341 private static function reportOutageHTML( Throwable $e ) { 342 global $wgShowExceptionDetails, $wgShowHostnames, $wgSitename; 343 344 $sorry = htmlspecialchars( self::msg( 345 'dberr-problems', 346 'Sorry! This site is experiencing technical difficulties.' 347 ) ); 348 $again = htmlspecialchars( self::msg( 349 'dberr-again', 350 'Try waiting a few minutes and reloading.' 351 ) ); 352 353 if ( $wgShowHostnames ) { 354 $info = str_replace( 355 '$1', 356 Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ), 357 htmlspecialchars( self::msg( 'dberr-info', '($1)' ) ) 358 ); 359 } else { 360 $info = htmlspecialchars( self::msg( 361 'dberr-info-hidden', 362 '(Cannot access the database)' 363 ) ); 364 } 365 366 MediaWikiServices::getInstance()->getMessageCache()->disable(); // no DB access 367 $html = "<!DOCTYPE html>\n" . 368 '<html><head>' . 369 '<title>' . 370 htmlspecialchars( $wgSitename ) . 371 '</title>' . 372 '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' . 373 "</head><body><h1>$sorry</h1><p>$again</p><p><small>$info</small></p>"; 374 375 if ( $wgShowExceptionDetails ) { 376 $html .= '<p>Backtrace:</p><pre>' . 377 htmlspecialchars( $e->getTraceAsString() ) . '</pre>'; 378 } 379 380 $html .= '</body></html>'; 381 echo $html; 382 } 383} 384