diff options
Diffstat (limited to 'includes/exception')
-rw-r--r-- | includes/exception/BadTitleError.php | 16 | ||||
-rw-r--r-- | includes/exception/HttpError.php | 20 | ||||
-rw-r--r-- | includes/exception/MWException.php | 8 | ||||
-rw-r--r-- | includes/exception/MWExceptionHandler.php | 367 |
4 files changed, 280 insertions, 131 deletions
diff --git a/includes/exception/BadTitleError.php b/includes/exception/BadTitleError.php index e62f8bd6..039b9c60 100644 --- a/includes/exception/BadTitleError.php +++ b/includes/exception/BadTitleError.php @@ -28,11 +28,22 @@ */ class BadTitleError extends ErrorPageError { /** - * @param string|Message $msg A message key (default: 'badtitletext') + * @param string|Message|MalformedTitleException $msg A message key (default: 'badtitletext'), or + * a MalformedTitleException to figure out things from * @param array $params Parameter to wfMessage() */ public function __construct( $msg = 'badtitletext', $params = array() ) { - parent::__construct( 'badtitle', $msg, $params ); + if ( $msg instanceof MalformedTitleException ) { + $errorMessage = $msg->getErrorMessage(); + if ( !$errorMessage ) { + parent::__construct( 'badtitle', 'badtitletext', array() ); + } else { + $errorMessageParams = $msg->getErrorMessageParameters(); + parent::__construct( 'badtitle', $errorMessage, $errorMessageParams ); + } + } else { + parent::__construct( 'badtitle', $msg, $params ); + } } /** @@ -47,5 +58,4 @@ class BadTitleError extends ErrorPageError { $wgOut->setStatusCode( 400 ); parent::report(); } - } diff --git a/includes/exception/HttpError.php b/includes/exception/HttpError.php index b81c5731..9211bf82 100644 --- a/includes/exception/HttpError.php +++ b/includes/exception/HttpError.php @@ -35,7 +35,7 @@ class HttpError extends MWException { * * @param int $httpCode HTTP status code to send to the client * @param string|Message $content Content of the message - * @param string|Message $header Content of the header (\<title\> and \<h1\>) + * @param string|Message|null $header Content of the header (\<title\> and \<h1\>) */ public function __construct( $httpCode, $content, $header = null ) { parent::__construct( $content ); @@ -74,9 +74,7 @@ class HttpError extends MWException { public function report() { $this->doLog(); - $httpMessage = HttpStatus::getMessage( $this->httpCode ); - - header( "Status: {$this->httpCode} {$httpMessage}", true, $this->httpCode ); + HttpStatus::header( $this->httpCode ); header( 'Content-type: text/html; charset=utf-8' ); print $this->getHTML(); @@ -113,21 +111,21 @@ class HttpError extends MWException { */ public function getHTML() { if ( $this->header === null ) { - $header = HttpStatus::getMessage( $this->httpCode ); + $titleHtml = htmlspecialchars( HttpStatus::getMessage( $this->httpCode ) ); } elseif ( $this->header instanceof Message ) { - $header = $this->header->escaped(); + $titleHtml = $this->header->escaped(); } else { - $header = htmlspecialchars( $this->header ); + $titleHtml = htmlspecialchars( $this->header ); } if ( $this->content instanceof Message ) { - $content = $this->content->escaped(); + $contentHtml = $this->content->escaped(); } else { - $content = htmlspecialchars( $this->content ); + $contentHtml = nl2br( htmlspecialchars( $this->content ) ); } return "<!DOCTYPE html>\n" . - "<html><head><title>$header</title></head>\n" . - "<body><h1>$header</h1><p>$content</p></body></html>\n"; + "<html><head><title>$titleHtml</title></head>\n" . + "<body><h1>$titleHtml</h1><p>$contentHtml</p></body></html>\n"; } } diff --git a/includes/exception/MWException.php b/includes/exception/MWException.php index 478fead1..c0186f9f 100644 --- a/includes/exception/MWException.php +++ b/includes/exception/MWException.php @@ -238,8 +238,7 @@ class MWException extends Exception { } elseif ( self::isCommandLine() ) { MWExceptionHandler::printError( $this->getText() ); } else { - self::header( 'HTTP/1.1 500 MediaWiki exception' ); - self::header( 'Status: 500 MediaWiki exception' ); + self::statusHeader( 500 ); self::header( "Content-Type: $wgMimeType; charset=utf-8" ); $this->reportHTML(); @@ -266,4 +265,9 @@ class MWException extends Exception { header( $header ); } } + private static function statusHeader( $code ) { + if ( !headers_sent() ) { + HttpStatus::header( $code ); + } + } } diff --git a/includes/exception/MWExceptionHandler.php b/includes/exception/MWExceptionHandler.php index a58705f6..d25f1a82 100644 --- a/includes/exception/MWExceptionHandler.php +++ b/includes/exception/MWExceptionHandler.php @@ -18,37 +18,47 @@ * @file */ +use MediaWiki\Logger\LoggerFactory; + /** * Handler class for MWExceptions * @ingroup Exception */ class MWExceptionHandler { + /** + * @var string $reservedMemory + */ protected static $reservedMemory; + /** + * @var array $fatalErrorTypes + */ protected static $fatalErrorTypes = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, /* HHVM's FATAL_ERROR level */ 16777217, ); + /** + * @var bool $handledFatalCallback + */ + protected static $handledFatalCallback = false; /** * Install handlers with PHP. */ public static function installHandler() { - set_exception_handler( array( 'MWExceptionHandler', 'handleException' ) ); - set_error_handler( array( 'MWExceptionHandler', 'handleError' ) ); + set_exception_handler( 'MWExceptionHandler::handleException' ); + set_error_handler( 'MWExceptionHandler::handleError' ); // Reserve 16k of memory so we can report OOM fatals self::$reservedMemory = str_repeat( ' ', 16384 ); - register_shutdown_function( - array( 'MWExceptionHandler', 'handleFatalError' ) - ); + register_shutdown_function( 'MWExceptionHandler::handleFatalError' ); } /** * Report an exception to the user - * @param Exception $e + * @param Exception|Throwable $e */ - protected static function report( Exception $e ) { + protected static function report( $e ) { global $wgShowExceptionDetails; $cmdLine = MWException::isCommandLine(); @@ -86,7 +96,7 @@ class MWExceptionHandler { $message = "Exception encountered, of type \"" . get_class( $e ) . "\""; if ( $wgShowExceptionDetails ) { - $message .= "\n" . MWExceptionHandler::getLogMessage( $e ) . "\nBacktrace:\n" . + $message .= "\n" . self::getLogMessage( $e ) . "\nBacktrace:\n" . self::getRedactedTraceAsString( $e ) . "\n"; } @@ -122,15 +132,16 @@ class MWExceptionHandler { * transaction could be aborted properly. * * @since 1.23 - * @param Exception $e + * @param Exception|Throwable $e */ - public static function rollbackMasterChangesAndLog( Exception $e ) { + public static function rollbackMasterChangesAndLog( $e ) { $factory = wfGetLBFactory(); if ( $factory->hasMasterChanges() ) { - wfDebugLog( 'Bug56269', + $logger = LoggerFactory::getInstance( 'Bug56269' ); + $logger->warning( 'Exception thrown with an uncommited database transaction: ' . - MWExceptionHandler::getLogMessage( $e ) . "\n" . - $e->getTraceAsString() + self::getLogMessage( $e ), + self::getLogContext( $e ) ); $factory->rollbackMasterChanges(); } @@ -148,9 +159,9 @@ class MWExceptionHandler { * } * * @since 1.25 - * @param Exception $e + * @param Exception|Throwable $e */ - public static function handleException( Exception $e ) { + public static function handleException( $e ) { try { // Rollback DBs to avoid transaction notices. This may fail // to rollback some DB due to connection issues or exceptions. @@ -173,24 +184,36 @@ class MWExceptionHandler { } /** + * Handler for set_error_handler() callback notifications. + * + * Receive a callback from the interpreter for a raised error, create an + * ErrorException, and log the exception to the 'error' logging + * channel(s). If the raised error is a fatal error type (only under HHVM) + * delegate to handleFatalError() instead. + * * @since 1.25 + * * @param int $level Error level raised * @param string $message * @param string $file * @param int $line + * + * @see logError() */ - public static function handleError( $level, $message, $file = null, $line = null ) { - // Map error constant to error name (reverse-engineer PHP error reporting) - $channel = 'error'; + public static function handleError( + $level, $message, $file = null, $line = null + ) { + if ( in_array( $level, self::$fatalErrorTypes ) ) { + return call_user_func_array( + 'MWExceptionHandler::handleFatalError', func_get_args() + ); + } + + // Map error constant to error name (reverse-engineer PHP error + // reporting) switch ( $level ) { - case E_ERROR: - case E_CORE_ERROR: - case E_COMPILE_ERROR: - case E_USER_ERROR: case E_RECOVERABLE_ERROR: - case E_PARSE: $levelName = 'Error'; - $channel = 'fatal'; break; case E_WARNING: case E_CORE_WARNING: @@ -209,17 +232,13 @@ class MWExceptionHandler { case E_USER_DEPRECATED: $levelName = 'Deprecated'; break; - case /* HHVM's FATAL_ERROR */ 16777217: - $levelName = 'Fatal'; - $channel = 'fatal'; - break; default: $levelName = 'Unknown error'; break; } $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line ); - self::logError( $e, $channel ); + self::logError( $e, 'error' ); // This handler is for logging only. Return false will instruct PHP // to continue regular handling. @@ -228,42 +247,101 @@ class MWExceptionHandler { /** - * Look for a fatal error as the cause of the request termination and log - * as an exception. + * Dual purpose callback used as both a set_error_handler() callback and + * a registered shutdown function. Receive a callback from the interpreter + * for a raised error or system shutdown, check for a fatal error, and log + * to the 'fatal' logging channel. * * Special handling is included for missing class errors as they may * indicate that the user needs to install 3rd-party libraries via * Composer or other means. * * @since 1.25 + * + * @param int $level Error level raised + * @param string $message Error message + * @param string $file File that error was raised in + * @param int $line Line number error was raised at + * @param array $context Active symbol table point of error + * @param array $trace Backtrace at point of error (undocumented HHVM + * feature) + * @return bool Always returns false */ - public static function handleFatalError() { + public static function handleFatalError( + $level = null, $message = null, $file = null, $line = null, + $context = null, $trace = null + ) { + // Free reserved memory so that we have space to process OOM + // errors self::$reservedMemory = null; - $lastError = error_get_last(); - if ( $lastError && - isset( $lastError['type'] ) && - in_array( $lastError['type'], self::$fatalErrorTypes ) - ) { - $msg = "Fatal Error: {$lastError['message']}"; - // HHVM: Class undefined: foo - // PHP5: Class 'foo' not found - if ( preg_match( "/Class (undefined: \w+|'\w+' not found)/", - $lastError['message'] - ) ) { - // @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong - $msg = <<<TXT + if ( $level === null ) { + // Called as a shutdown handler, get data from error_get_last() + if ( static::$handledFatalCallback ) { + // Already called once (probably as an error handler callback + // under HHVM) so don't log again. + return false; + } + + $lastError = error_get_last(); + if ( $lastError !== null ) { + $level = $lastError['type']; + $message = $lastError['message']; + $file = $lastError['file']; + $line = $lastError['line']; + } else { + $level = 0; + $message = ''; + } + } + + if ( !in_array( $level, self::$fatalErrorTypes ) ) { + // Only interested in fatal errors, others should have been + // handled by MWExceptionHandler::handleError + return false; + } + + $msg = "[{exception_id}] PHP Fatal Error: {$message}"; + + // Look at message to see if this is a class not found failure + // HHVM: Class undefined: foo + // PHP5: Class 'foo' not found + if ( preg_match( "/Class (undefined: \w+|'\w+' not found)/", $msg ) ) { + // @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong + $msg = <<<TXT {$msg} MediaWiki or an installed extension requires this class but it is not embedded directly in MediaWiki's git repository and must be installed separately by the end user. Please see <a href="https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries">mediawiki.org</a> for help on installing the required components. TXT; - // @codingStandardsIgnoreEnd - } - $e = new ErrorException( $msg, 0, $lastError['type'] ); - self::logError( $e, 'fatal' ); + // @codingStandardsIgnoreEnd } + + // We can't just create an exception and log it as it is likely that + // the interpreter has unwound the stack already. If that is true the + // stacktrace we would get would be functionally empty. If however we + // have been called as an error handler callback *and* HHVM is in use + // we will have been provided with a useful stacktrace that we can + // log. + $trace = $trace ?: debug_backtrace(); + $logger = LoggerFactory::getInstance( 'fatal' ); + $logger->error( $msg, array( + 'exception' => array( + 'class' => 'ErrorException', + 'message' => "PHP Fatal Error: {$message}", + 'code' => $level, + 'file' => $file, + 'line' => $line, + 'trace' => static::redactTrace( $trace ), + ), + 'exception_id' => wfRandomString( 8 ), + ) ); + + // Remember call so we don't double process via HHVM's fatal + // notifications and the shutdown hook behavior + static::$handledFatalCallback = true; + return false; } /** @@ -272,20 +350,34 @@ TXT; * Like Exception::getTraceAsString, but replaces argument values with * argument type or class name. * - * @param Exception $e + * @param Exception|Throwable $e * @return string + * @see prettyPrintTrace() */ - public static function getRedactedTraceAsString( Exception $e ) { + public static function getRedactedTraceAsString( $e ) { + return self::prettyPrintTrace( self::getRedactedTrace( $e ) ); + } + + /** + * Generate a string representation of a stacktrace. + * + * @param array $trace + * @param string $pad Constant padding to add to each line of trace + * @return string + * @since 1.26 + */ + public static function prettyPrintTrace( array $trace, $pad = '' ) { $text = ''; - foreach ( self::getRedactedTrace( $e ) as $level => $frame ) { + foreach ( $trace as $level => $frame ) { if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) { - $text .= "#{$level} {$frame['file']}({$frame['line']}): "; + $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): "; } else { - // 'file' and 'line' are unset for calls via call_user_func (bug 55634) - // This matches behaviour of Exception::getTraceAsString to instead - // display "[internal function]". - $text .= "#{$level} [internal function]: "; + // 'file' and 'line' are unset for calls via call_user_func + // (bug 55634) This matches behaviour of + // Exception::getTraceAsString to instead display "[internal + // function]". + $text .= "{$pad}#{$level} [internal function]: "; } if ( isset( $frame['class'] ) ) { @@ -302,7 +394,7 @@ TXT; } $level = $level + 1; - $text .= "#{$level} {main}"; + $text .= "{$pad}#{$level} {main}"; return $text; } @@ -315,10 +407,24 @@ TXT; * or its type (if the element is a PHP primitive). * * @since 1.22 - * @param Exception $e + * @param Exception|Throwable $e * @return array */ - public static function getRedactedTrace( Exception $e ) { + public static function getRedactedTrace( $e ) { + return static::redactTrace( $e->getTrace() ); + } + + /** + * Redact a stacktrace generated by Exception::getTrace(), + * debug_backtrace() or similar means. Replaces each element in each + * frame's argument array with the name of its class (if the element is an + * object) or its type (if the element is a PHP primitive). + * + * @since 1.26 + * @param array $trace Stacktrace + * @return array Stacktrace with arugment values converted to data types + */ + public static function redactTrace( array $trace ) { return array_map( function ( $frame ) { if ( isset( $frame['args'] ) ) { $frame['args'] = array_map( function ( $arg ) { @@ -326,7 +432,7 @@ TXT; }, $frame['args'] ); } return $frame; - }, $e->getTrace() ); + }, $trace ); } /** @@ -336,10 +442,10 @@ TXT; * $wgShowExceptionDetails is set to false), to the entry in the debug log. * * @since 1.22 - * @param Exception $e + * @param Exception|Throwable $e * @return string */ - public static function getLogId( Exception $e ) { + public static function getLogId( $e ) { if ( !isset( $e->_mwLogId ) ) { $e->_mwLogId = wfRandomString( 8 ); } @@ -365,10 +471,10 @@ TXT; * Get a message formatting the exception message and its origin. * * @since 1.22 - * @param Exception $e + * @param Exception|Throwable $e * @return string */ - public static function getLogMessage( Exception $e ) { + public static function getLogMessage( $e ) { $id = self::getLogId( $e ); $type = get_class( $e ); $file = $e->getFile(); @@ -380,6 +486,65 @@ TXT; } /** + * Get a PSR-3 log event context from an Exception. + * + * Creates a structured array containing information about the provided + * exception that can be used to augment a log message sent to a PSR-3 + * logger. + * + * @param Exception|Throwable $e + * @return array + */ + public static function getLogContext( $e ) { + return array( + 'exception' => $e, + 'exception_id' => static::getLogId( $e ), + ); + } + + /** + * Get a structured representation of an Exception. + * + * Returns an array of structured data (class, message, code, file, + * backtrace) derived from the given exception. The backtrace information + * will be redacted as per getRedactedTraceAsArray(). + * + * @param Exception|Throwable $e + * @return array + * @since 1.26 + */ + public static function getStructuredExceptionData( $e ) { + global $wgLogExceptionBacktrace; + $data = array( + 'id' => self::getLogId( $e ), + 'type' => get_class( $e ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + 'url' => self::getURL() ?: null, + ); + + if ( $e instanceof ErrorException && + ( error_reporting() & $e->getSeverity() ) === 0 + ) { + // Flag surpressed errors + $data['suppressed'] = true; + } + + if ( $wgLogExceptionBacktrace ) { + $data['backtrace'] = self::getRedactedTrace( $e ); + } + + $previous = $e->getPrevious(); + if ( $previous !== null ) { + $data['previous'] = self::getStructuredExceptionData( $previous ); + } + + return $data; + } + + /** * Serialize an Exception object to JSON. * * The JSON object will have keys 'id', 'file', 'line', 'message', and @@ -427,39 +592,14 @@ TXT; * @endcode * * @since 1.23 - * @param Exception $e + * @param Exception|Throwable $e * @param bool $pretty Add non-significant whitespace to improve readability (default: false). * @param int $escaping Bitfield consisting of FormatJson::.*_OK class constants. * @return string|false JSON string if successful; false upon failure */ - public static function jsonSerializeException( Exception $e, $pretty = false, $escaping = 0 ) { - global $wgLogExceptionBacktrace; - - $exceptionData = array( - 'id' => self::getLogId( $e ), - 'type' => get_class( $e ), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'message' => $e->getMessage(), - ); - - if ( $e instanceof ErrorException && ( error_reporting() & $e->getSeverity() ) === 0 ) { - // Flag surpressed errors - $exceptionData['suppressed'] = true; - } - - // Because MediaWiki is first and foremost a web application, we set a - // 'url' key unconditionally, but set it to null if the exception does - // not occur in the context of a web request, as a way of making that - // fact visible and explicit. - $exceptionData['url'] = self::getURL() ?: null; - - if ( $wgLogExceptionBacktrace ) { - // Argument values may not be serializable, so redact them. - $exceptionData['backtrace'] = self::getRedactedTrace( $e ); - } - - return FormatJson::encode( $exceptionData, $pretty, $escaping ); + public static function jsonSerializeException( $e, $pretty = false, $escaping = 0 ) { + $data = self::getStructuredExceptionData( $e ); + return FormatJson::encode( $data, $pretty, $escaping ); } /** @@ -469,22 +609,20 @@ TXT; * it is also used to handle PHP exceptions or exceptions from other libraries. * * @since 1.22 - * @param Exception $e + * @param Exception|Throwable $e */ - public static function logException( Exception $e ) { - global $wgLogExceptionBacktrace; - + public static function logException( $e ) { if ( !( $e instanceof MWException ) || $e->isLoggable() ) { - $log = self::getLogMessage( $e ); - if ( $wgLogExceptionBacktrace ) { - wfDebugLog( 'exception', $log . "\n" . $e->getTraceAsString() ); - } else { - wfDebugLog( 'exception', $log ); - } + $logger = LoggerFactory::getInstance( 'exception' ); + $logger->error( + self::getLogMessage( $e ), + self::getLogContext( $e ) + ); $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK ); if ( $json !== false ) { - wfDebugLog( 'exception-json', $json, 'private' ); + $logger = LoggerFactory::getInstance( 'exception-json' ); + $logger->error( $json, array( 'private' => true ) ); } Hooks::run( 'LogException', array( $e, false ) ); @@ -499,24 +637,23 @@ TXT; * @param string $channel */ protected static function logError( ErrorException $e, $channel ) { - global $wgLogExceptionBacktrace; - // The set_error_handler callback is independent from error_reporting. - // Filter out unwanted errors manually (e.g. when wfSuppressWarnings is active). + // Filter out unwanted errors manually (e.g. when + // MediaWiki\suppressWarnings is active). $suppressed = ( error_reporting() & $e->getSeverity() ) === 0; if ( !$suppressed ) { - $log = self::getLogMessage( $e ); - if ( $wgLogExceptionBacktrace ) { - wfDebugLog( $channel, $log . "\n" . $e->getTraceAsString() ); - } else { - wfDebugLog( $channel, $log ); - } + $logger = LoggerFactory::getInstance( $channel ); + $logger->error( + self::getLogMessage( $e ), + self::getLogContext( $e ) + ); } // Include all errors in the json log (surpressed errors will be flagged) $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK ); if ( $json !== false ) { - wfDebugLog( "$channel-json", $json, 'private' ); + $logger = LoggerFactory::getInstance( "{$channel}-json" ); + $logger->error( $json, array( 'private' => true ) ); } Hooks::run( 'LogException', array( $e, $suppressed ) ); |