]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - includes/exception/MWExceptionHandler.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / exception / MWExceptionHandler.php
diff --git a/includes/exception/MWExceptionHandler.php b/includes/exception/MWExceptionHandler.php
new file mode 100644 (file)
index 0000000..a2ec391
--- /dev/null
@@ -0,0 +1,665 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LogLevel;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * Handler class for MWExceptions
+ * @ingroup Exception
+ */
+class MWExceptionHandler {
+       const CAUGHT_BY_HANDLER = 'mwe_handler'; // error reported by this exception handler
+       const CAUGHT_BY_OTHER = 'other'; // error reported by direct logException() call
+
+       /**
+        * @var string $reservedMemory
+        */
+       protected static $reservedMemory;
+       /**
+        * @var array $fatalErrorTypes
+        */
+       protected static $fatalErrorTypes = [
+               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( '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( 'MWExceptionHandler::handleFatalError' );
+       }
+
+       /**
+        * Report an exception to the user
+        * @param Exception|Throwable $e
+        */
+       protected static function report( $e ) {
+               try {
+                       // Try and show the exception prettily, with the normal skin infrastructure
+                       if ( $e instanceof MWException ) {
+                               // Delegate to MWException until all subclasses are handled by
+                               // MWExceptionRenderer and MWException::report() has been
+                               // removed.
+                               $e->report();
+                       } else {
+                               MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_PRETTY );
+                       }
+               } catch ( Exception $e2 ) {
+                       // Exception occurred from within exception handler
+                       // Show a simpler message for the original exception,
+                       // don't try to invoke report()
+                       MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW, $e2 );
+               }
+       }
+
+       /**
+        * Roll back any open database transactions and log the stack trace of the exception
+        *
+        * This method is used to attempt to recover from exceptions
+        *
+        * @since 1.23
+        * @param Exception|Throwable $e
+        */
+       public static function rollbackMasterChangesAndLog( $e ) {
+               $services = MediaWikiServices::getInstance();
+               if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
+                       // Rollback DBs to avoid transaction notices. This might fail
+                       // to rollback some databases due to connection issues or exceptions.
+                       // However, any sane DB driver will rollback implicitly anyway.
+                       try {
+                               $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
+                       } catch ( DBError $e2 ) {
+                               // If the DB is unreacheable, rollback() will throw an error
+                               // and the error report() method might need messages from the DB,
+                               // which would result in an exception loop. PHP may escalate such
+                               // errors to "Exception thrown without a stack frame" fatals, but
+                               // it's better to be explicit here.
+                               self::logException( $e2, self::CAUGHT_BY_HANDLER );
+                       }
+               }
+
+               self::logException( $e, self::CAUGHT_BY_HANDLER );
+       }
+
+       /**
+        * Exception handler which simulates the appropriate catch() handling:
+        *
+        *   try {
+        *       ...
+        *   } catch ( Exception $e ) {
+        *       $e->report();
+        *   } catch ( Exception $e ) {
+        *       echo $e->__toString();
+        *   }
+        *
+        * @since 1.25
+        * @param Exception|Throwable $e
+        */
+       public static function handleException( $e ) {
+               self::rollbackMasterChangesAndLog( $e );
+               self::report( $e );
+       }
+
+       /**
+        * 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
+        * @return bool
+        *
+        * @see logError()
+        */
+       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_RECOVERABLE_ERROR:
+                               $levelName = 'Error';
+                               $severity = LogLevel::ERROR;
+                               break;
+                       case E_WARNING:
+                       case E_CORE_WARNING:
+                       case E_COMPILE_WARNING:
+                       case E_USER_WARNING:
+                               $levelName = 'Warning';
+                               $severity = LogLevel::WARNING;
+                               break;
+                       case E_NOTICE:
+                       case E_USER_NOTICE:
+                               $levelName = 'Notice';
+                               $severity = LogLevel::INFO;
+                               break;
+                       case E_STRICT:
+                               $levelName = 'Strict Standards';
+                               $severity = LogLevel::DEBUG;
+                               break;
+                       case E_DEPRECATED:
+                       case E_USER_DEPRECATED:
+                               $levelName = 'Deprecated';
+                               $severity = LogLevel::INFO;
+                               break;
+                       default:
+                               $levelName = 'Unknown error';
+                               $severity = LogLevel::ERROR;
+                               break;
+               }
+
+               $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line );
+               self::logError( $e, 'error', $severity );
+
+               // This handler is for logging only. Return false will instruct PHP
+               // to continue regular handling.
+               return false;
+       }
+
+       /**
+        * 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(
+               $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;
+
+               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
+               }
+
+               // 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, [
+                       'fatal_exception' => [
+                               'class' => 'ErrorException',
+                               'message' => "PHP Fatal Error: {$message}",
+                               'code' => $level,
+                               'file' => $file,
+                               'line' => $line,
+                               'trace' => static::redactTrace( $trace ),
+                       ],
+                       'exception_id' => wfRandomString( 8 ),
+                       'caught_by' => self::CAUGHT_BY_HANDLER
+               ] );
+
+               // Remember call so we don't double process via HHVM's fatal
+               // notifications and the shutdown hook behavior
+               static::$handledFatalCallback = true;
+               return false;
+       }
+
+       /**
+        * Generate a string representation of an exception's stack trace
+        *
+        * Like Exception::getTraceAsString, but replaces argument values with
+        * argument type or class name.
+        *
+        * @param Exception|Throwable $e
+        * @return string
+        * @see prettyPrintTrace()
+        */
+       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 = '';
+
+               $level = 0;
+               foreach ( $trace as $level => $frame ) {
+                       if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
+                               $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
+                       } else {
+                               // 'file' and 'line' are unset for calls via call_user_func
+                               // (T57634) This matches behaviour of
+                               // Exception::getTraceAsString to instead display "[internal
+                               // function]".
+                               $text .= "{$pad}#{$level} [internal function]: ";
+                       }
+
+                       if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
+                               $text .= $frame['class'] . $frame['type'] . $frame['function'];
+                       } elseif ( isset( $frame['function'] ) ) {
+                               $text .= $frame['function'];
+                       } else {
+                               $text .= 'NO_FUNCTION_GIVEN';
+                       }
+
+                       if ( isset( $frame['args'] ) ) {
+                               $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
+                       } else {
+                               $text .= "()\n";
+                       }
+               }
+
+               $level = $level + 1;
+               $text .= "{$pad}#{$level} {main}";
+
+               return $text;
+       }
+
+       /**
+        * Return a copy of an exception's backtrace as an array.
+        *
+        * Like Exception::getTrace, but 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.22
+        * @param Exception|Throwable $e
+        * @return array
+        */
+       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 ) {
+                                       return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
+                               }, $frame['args'] );
+                       }
+                       return $frame;
+               }, $trace );
+       }
+
+       /**
+        * Get the ID for this exception.
+        *
+        * The ID is saved so that one can match the one output to the user (when
+        * $wgShowExceptionDetails is set to false), to the entry in the debug log.
+        *
+        * @since 1.22
+        * @deprecated since 1.27: Exception IDs are synonymous with request IDs.
+        * @param Exception|Throwable $e
+        * @return string
+        */
+       public static function getLogId( $e ) {
+               wfDeprecated( __METHOD__, '1.27' );
+               return WebRequest::getRequestId();
+       }
+
+       /**
+        * If the exception occurred in the course of responding to a request,
+        * returns the requested URL. Otherwise, returns false.
+        *
+        * @since 1.23
+        * @return string|false
+        */
+       public static function getURL() {
+               global $wgRequest;
+               if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
+                       return false;
+               }
+               return $wgRequest->getRequestURL();
+       }
+
+       /**
+        * Get a message formatting the exception message and its origin.
+        *
+        * @since 1.22
+        * @param Exception|Throwable $e
+        * @return string
+        */
+       public static function getLogMessage( $e ) {
+               $id = WebRequest::getRequestId();
+               $type = get_class( $e );
+               $file = $e->getFile();
+               $line = $e->getLine();
+               $message = $e->getMessage();
+               $url = self::getURL() ?: '[no req]';
+
+               return "[$id] $url   $type from line $line of $file: $message";
+       }
+
+       /**
+        * Get a normalised message for formatting with PSR-3 log event context.
+        *
+        * Must be used together with `getLogContext()` to be useful.
+        *
+        * @since 1.30
+        * @param Exception|Throwable $e
+        * @return string
+        */
+       public static function getLogNormalMessage( $e ) {
+               $type = get_class( $e );
+               $file = $e->getFile();
+               $line = $e->getLine();
+               $message = $e->getMessage();
+
+               return "[{exception_id}] {exception_url}   $type from line $line of $file: $message";
+       }
+
+       /**
+        * @param Exception|Throwable $e
+        * @return string
+        */
+       public static function getPublicLogMessage( $e ) {
+               $reqId = WebRequest::getRequestId();
+               $type = get_class( $e );
+               return '[' . $reqId . '] '
+                       . gmdate( 'Y-m-d H:i:s' ) . ': '
+                       . 'Fatal exception of type "' . $type . '"';
+       }
+
+       /**
+        * 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
+        * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+        * @return array
+        */
+       public static function getLogContext( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+               return [
+                       'exception' => $e,
+                       'exception_id' => WebRequest::getRequestId(),
+                       'exception_url' => self::getURL() ?: '[no req]',
+                       'caught_by' => $catcher
+               ];
+       }
+
+       /**
+        * 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
+        * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+        * @return array
+        * @since 1.26
+        */
+       public static function getStructuredExceptionData( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+               global $wgLogExceptionBacktrace;
+
+               $data = [
+                       'id' => WebRequest::getRequestId(),
+                       'type' => get_class( $e ),
+                       'file' => $e->getFile(),
+                       'line' => $e->getLine(),
+                       'message' => $e->getMessage(),
+                       'code' => $e->getCode(),
+                       'url' => self::getURL() ?: null,
+                       'caught_by' => $catcher
+               ];
+
+               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, $catcher );
+               }
+
+               return $data;
+       }
+
+       /**
+        * Serialize an Exception object to JSON.
+        *
+        * The JSON object will have keys 'id', 'file', 'line', 'message', and
+        * 'url'. These keys map to string values, with the exception of 'line',
+        * which is a number, and 'url', which may be either a string URL or or
+        * null if the exception did not occur in the context of serving a web
+        * request.
+        *
+        * If $wgLogExceptionBacktrace is true, it will also have a 'backtrace'
+        * key, mapped to the array return value of Exception::getTrace, but with
+        * each element in each frame's "args" array (if set) replaced with the
+        * argument's class name (if the argument is an object) or type name (if
+        * the argument is a PHP primitive).
+        *
+        * @par Sample JSON record ($wgLogExceptionBacktrace = false):
+        * @code
+        *  {
+        *    "id": "c41fb419",
+        *    "type": "MWException",
+        *    "file": "/var/www/mediawiki/includes/cache/MessageCache.php",
+        *    "line": 704,
+        *    "message": "Non-string key given",
+        *    "url": "/wiki/Main_Page"
+        *  }
+        * @endcode
+        *
+        * @par Sample JSON record ($wgLogExceptionBacktrace = true):
+        * @code
+        *  {
+        *    "id": "dc457938",
+        *    "type": "MWException",
+        *    "file": "/vagrant/mediawiki/includes/cache/MessageCache.php",
+        *    "line": 704,
+        *    "message": "Non-string key given",
+        *    "url": "/wiki/Main_Page",
+        *    "backtrace": [{
+        *      "file": "/vagrant/mediawiki/extensions/VisualEditor/VisualEditor.hooks.php",
+        *      "line": 80,
+        *      "function": "get",
+        *      "class": "MessageCache",
+        *      "type": "->",
+        *      "args": ["array"]
+        *    }]
+        *  }
+        * @endcode
+        *
+        * @since 1.23
+        * @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.
+        * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+        * @return string|false JSON string if successful; false upon failure
+        */
+       public static function jsonSerializeException(
+               $e, $pretty = false, $escaping = 0, $catcher = self::CAUGHT_BY_OTHER
+       ) {
+               return FormatJson::encode(
+                       self::getStructuredExceptionData( $e, $catcher ),
+                       $pretty,
+                       $escaping
+               );
+       }
+
+       /**
+        * Log an exception to the exception log (if enabled).
+        *
+        * This method must not assume the exception is an MWException,
+        * it is also used to handle PHP exceptions or exceptions from other libraries.
+        *
+        * @since 1.22
+        * @param Exception|Throwable $e
+        * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error
+        */
+       public static function logException( $e, $catcher = self::CAUGHT_BY_OTHER ) {
+               if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
+                       $logger = LoggerFactory::getInstance( 'exception' );
+                       $logger->error(
+                               self::getLogNormalMessage( $e ),
+                               self::getLogContext( $e, $catcher )
+                       );
+
+                       $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
+                       if ( $json !== false ) {
+                               $logger = LoggerFactory::getInstance( 'exception-json' );
+                               $logger->error( $json, [ 'private' => true ] );
+                       }
+
+                       Hooks::run( 'LogException', [ $e, false ] );
+               }
+       }
+
+       /**
+        * Log an exception that wasn't thrown but made to wrap an error.
+        *
+        * @since 1.25
+        * @param ErrorException $e
+        * @param string $channel
+        * @param string $level
+        */
+       protected static function logError(
+               ErrorException $e, $channel, $level = LogLevel::ERROR
+       ) {
+               $catcher = self::CAUGHT_BY_HANDLER;
+               // The set_error_handler callback is independent from error_reporting.
+               // Filter out unwanted errors manually (e.g. when
+               // MediaWiki\suppressWarnings is active).
+               $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
+               if ( !$suppressed ) {
+                       $logger = LoggerFactory::getInstance( $channel );
+                       $logger->log(
+                               $level,
+                               self::getLogNormalMessage( $e ),
+                               self::getLogContext( $e, $catcher )
+                       );
+               }
+
+               // Include all errors in the json log (surpressed errors will be flagged)
+               $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
+               if ( $json !== false ) {
+                       $logger = LoggerFactory::getInstance( "{$channel}-json" );
+                       $logger->log( $level, $json, [ 'private' => true ] );
+               }
+
+               Hooks::run( 'LogException', [ $e, $suppressed ] );
+       }
+}