]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - includes/api/ApiMain.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / api / ApiMain.php
index 2063b8a261987ed9ced3fe9f507eda57ec78a8e8..c76c2b205f5312d18bbacdf0e3b842ce954a5f6a 100644 (file)
@@ -1,10 +1,8 @@
 <?php
 /**
- * API for MediaWiki 1.8+
- *
  * Created on Sep 4, 2006
  *
- * Copyright © 2006 Yuri Astrakhan <Firstname><Lastname>@gmail.com
+ * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
  *
  * 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
  * @defgroup API API
  */
 
-if ( !defined( 'MEDIAWIKI' ) ) {
-       // Eclipse helper - will be ignored in production
-       require_once( 'ApiBase.php' );
-}
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Timestamp\TimestampException;
+use Wikimedia\Rdbms\DBQueryError;
+use Wikimedia\Rdbms\DBError;
 
 /**
  * This is the main API class, used for both external and internal processing.
@@ -44,29 +43,49 @@ if ( !defined( 'MEDIAWIKI' ) ) {
  * @ingroup API
  */
 class ApiMain extends ApiBase {
-
        /**
         * When no format parameter is given, this format will be used
         */
-       const API_DEFAULT_FORMAT = 'xmlfm';
+       const API_DEFAULT_FORMAT = 'jsonfm';
+
+       /**
+        * When no uselang parameter is given, this language will be used
+        */
+       const API_DEFAULT_USELANG = 'user';
 
        /**
         * List of available modules: action name => module class
         */
-       private static $Modules = array(
+       private static $Modules = [
                'login' => 'ApiLogin',
+               'clientlogin' => 'ApiClientLogin',
                'logout' => 'ApiLogout',
+               'createaccount' => 'ApiAMCreateAccount',
+               'linkaccount' => 'ApiLinkAccount',
+               'unlinkaccount' => 'ApiRemoveAuthenticationData',
+               'changeauthenticationdata' => 'ApiChangeAuthenticationData',
+               'removeauthenticationdata' => 'ApiRemoveAuthenticationData',
+               'resetpassword' => 'ApiResetPassword',
                'query' => 'ApiQuery',
                'expandtemplates' => 'ApiExpandTemplates',
                'parse' => 'ApiParse',
+               'stashedit' => 'ApiStashEdit',
                'opensearch' => 'ApiOpenSearch',
+               'feedcontributions' => 'ApiFeedContributions',
+               'feedrecentchanges' => 'ApiFeedRecentChanges',
                'feedwatchlist' => 'ApiFeedWatchlist',
                'help' => 'ApiHelp',
                'paraminfo' => 'ApiParamInfo',
                'rsd' => 'ApiRsd',
+               'compare' => 'ApiComparePages',
+               'tokens' => 'ApiTokens',
+               'checktoken' => 'ApiCheckToken',
+               'cspreport' => 'ApiCSPReport',
+               'validatepassword' => 'ApiValidatePassword',
 
                // Write modules
                'purge' => 'ApiPurge',
+               'setnotificationtimestamp' => 'ApiSetNotificationTimestamp',
                'rollback' => 'ApiRollback',
                'delete' => 'ApiDelete',
                'undelete' => 'ApiUndelete',
@@ -76,118 +95,219 @@ class ApiMain extends ApiBase {
                'move' => 'ApiMove',
                'edit' => 'ApiEditPage',
                'upload' => 'ApiUpload',
+               'filerevert' => 'ApiFileRevert',
                'emailuser' => 'ApiEmailUser',
                'watch' => 'ApiWatch',
                'patrol' => 'ApiPatrol',
                'import' => 'ApiImport',
+               'clearhasmsg' => 'ApiClearHasMsg',
                'userrights' => 'ApiUserrights',
-       );
+               'options' => 'ApiOptions',
+               'imagerotate' => 'ApiImageRotate',
+               'revisiondelete' => 'ApiRevisionDelete',
+               'managetags' => 'ApiManageTags',
+               'tag' => 'ApiTag',
+               'mergehistory' => 'ApiMergeHistory',
+               'setpagelanguage' => 'ApiSetPageLanguage',
+       ];
 
        /**
         * List of available formats: format name => format class
         */
-       private static $Formats = array(
+       private static $Formats = [
                'json' => 'ApiFormatJson',
                'jsonfm' => 'ApiFormatJson',
                'php' => 'ApiFormatPhp',
                'phpfm' => 'ApiFormatPhp',
-               'wddx' => 'ApiFormatWddx',
-               'wddxfm' => 'ApiFormatWddx',
                'xml' => 'ApiFormatXml',
                'xmlfm' => 'ApiFormatXml',
-               'yaml' => 'ApiFormatYaml',
-               'yamlfm' => 'ApiFormatYaml',
                'rawfm' => 'ApiFormatJson',
-               'txt' => 'ApiFormatTxt',
-               'txtfm' => 'ApiFormatTxt',
-               'dbg' => 'ApiFormatDbg',
-               'dbgfm' => 'ApiFormatDbg',
-               'dump' => 'ApiFormatDump',
-               'dumpfm' => 'ApiFormatDump',
-       );
+               'none' => 'ApiFormatNone',
+       ];
 
+       // @codingStandardsIgnoreStart String contenation on "msg" not allowed to break long line
        /**
         * List of user roles that are specifically relevant to the API.
-        * array( 'right' => array ( 'msg'    => 'Some message with a $1',
-        *                           'params' => array ( $someVarToSubst ) ),
-        *                          );
-        */
-       private static $mRights = array(
-               'writeapi' => array(
-                       'msg' => 'Use of the write API',
-                       'params' => array()
-               ),
-               'apihighlimits' => array(
-                       'msg' => 'Use higher limits in API queries (Slow queries: $1 results; Fast queries: $2 results). The limits for slow queries also apply to multivalue parameters.',
-                       'params' => array( ApiBase::LIMIT_SML2, ApiBase::LIMIT_BIG2 )
-               )
-       );
-
-       private $mPrinter, $mModules, $mModuleNames, $mFormats, $mFormatNames;
-       private $mResult, $mAction, $mShowVersions, $mEnableWrite, $mRequest;
-       private $mInternalMode, $mSquidMaxage, $mModule;
+        * [ 'right' => [ 'msg'    => 'Some message with a $1',
+        *                'params' => [ $someVarToSubst ] ],
+        * ];
+        */
+       private static $mRights = [
+               'writeapi' => [
+                       'msg' => 'right-writeapi',
+                       'params' => []
+               ],
+               'apihighlimits' => [
+                       'msg' => 'api-help-right-apihighlimits',
+                       'params' => [ ApiBase::LIMIT_SML2, ApiBase::LIMIT_BIG2 ]
+               ]
+       ];
+       // @codingStandardsIgnoreEnd
+
+       /**
+        * @var ApiFormatBase
+        */
+       private $mPrinter;
+
+       private $mModuleMgr, $mResult, $mErrorFormatter = null;
+       /** @var ApiContinuationManager|null */
+       private $mContinuationManager;
+       private $mAction;
+       private $mEnableWrite;
+       private $mInternalMode, $mSquidMaxage;
+       /** @var ApiBase */
+       private $mModule;
 
        private $mCacheMode = 'private';
-       private $mCacheControl = array();
+       private $mCacheControl = [];
+       private $mParamsUsed = [];
+       private $mParamsSensitive = [];
+
+       /** @var bool|null Cached return value from self::lacksSameOriginSecurity() */
+       private $lacksSameOriginSecurity = null;
 
        /**
         * Constructs an instance of ApiMain that utilizes the module and format specified by $request.
         *
-        * @param $request WebRequest - if this is an instance of FauxRequest, errors are thrown and no printing occurs
-        * @param $enableWrite bool should be set to true if the api may modify data
+        * @param IContextSource|WebRequest $context If this is an instance of
+        *    FauxRequest, errors are thrown and no printing occurs
+        * @param bool $enableWrite Should be set to true if the api may modify data
         */
-       public function __construct( $request, $enableWrite = false ) {
+       public function __construct( $context = null, $enableWrite = false ) {
+               if ( $context === null ) {
+                       $context = RequestContext::getMain();
+               } elseif ( $context instanceof WebRequest ) {
+                       // BC for pre-1.19
+                       $request = $context;
+                       $context = RequestContext::getMain();
+               }
+               // We set a derivative context so we can change stuff later
+               $this->setContext( new DerivativeContext( $context ) );
+
+               if ( isset( $request ) ) {
+                       $this->getContext()->setRequest( $request );
+               } else {
+                       $request = $this->getRequest();
+               }
+
                $this->mInternalMode = ( $request instanceof FauxRequest );
 
                // Special handling for the main module: $parent === $this
                parent::__construct( $this, $this->mInternalMode ? 'main_int' : 'main' );
 
+               $config = $this->getConfig();
+
                if ( !$this->mInternalMode ) {
-                       // Impose module restrictions.
-                       // If the current user cannot read,
-                       // Remove all modules other than login
-                       global $wgUser;
-
-                       if ( $request->getVal( 'callback' ) !== null ) {
-                               // JSON callback allows cross-site reads.
-                               // For safety, strip user credentials.
-                               wfDebug( "API: stripping user credentials for JSON callback\n" );
+                       // Log if a request with a non-whitelisted Origin header is seen
+                       // with session cookies.
+                       $originHeader = $request->getHeader( 'Origin' );
+                       if ( $originHeader === false ) {
+                               $origins = [];
+                       } else {
+                               $originHeader = trim( $originHeader );
+                               $origins = preg_split( '/\s+/', $originHeader );
+                       }
+                       $sessionCookies = array_intersect(
+                               array_keys( $_COOKIE ),
+                               MediaWiki\Session\SessionManager::singleton()->getVaryCookies()
+                       );
+                       if ( $origins && $sessionCookies && (
+                               count( $origins ) !== 1 || !self::matchOrigin(
+                                       $origins[0],
+                                       $config->get( 'CrossSiteAJAXdomains' ),
+                                       $config->get( 'CrossSiteAJAXdomainExceptions' )
+                               )
+                       ) ) {
+                               LoggerFactory::getInstance( 'cors' )->warning(
+                                       'Non-whitelisted CORS request with session cookies', [
+                                               'origin' => $originHeader,
+                                               'cookies' => $sessionCookies,
+                                               'ip' => $request->getIP(),
+                                               'userAgent' => $this->getUserAgent(),
+                                               'wiki' => wfWikiID(),
+                                       ]
+                               );
+                       }
+
+                       // If we're in a mode that breaks the same-origin policy, strip
+                       // user credentials for security.
+                       if ( $this->lacksSameOriginSecurity() ) {
+                               global $wgUser;
+                               wfDebug( "API: stripping user credentials when the same-origin policy is not applied\n" );
                                $wgUser = new User();
+                               $this->getContext()->setUser( $wgUser );
+                               $request->response()->header( 'MediaWiki-Login-Suppressed: true' );
                        }
                }
 
-               global $wgAPIModules; // extension modules
-               $this->mModules = $wgAPIModules + self::$Modules;
+               $this->mResult = new ApiResult( $this->getConfig()->get( 'APIMaxResultSize' ) );
 
-               $this->mModuleNames = array_keys( $this->mModules );
-               $this->mFormats = self::$Formats;
-               $this->mFormatNames = array_keys( $this->mFormats );
+               // Setup uselang. This doesn't use $this->getParameter()
+               // because we're not ready to handle errors yet.
+               $uselang = $request->getVal( 'uselang', self::API_DEFAULT_USELANG );
+               if ( $uselang === 'user' ) {
+                       // Assume the parent context is going to return the user language
+                       // for uselang=user (see T85635).
+               } else {
+                       if ( $uselang === 'content' ) {
+                               global $wgContLang;
+                               $uselang = $wgContLang->getCode();
+                       }
+                       $code = RequestContext::sanitizeLangCode( $uselang );
+                       $this->getContext()->setLanguage( $code );
+                       if ( !$this->mInternalMode ) {
+                               global $wgLang;
+                               $wgLang = $this->getContext()->getLanguage();
+                               RequestContext::getMain()->setLanguage( $wgLang );
+                       }
+               }
 
-               $this->mResult = new ApiResult( $this );
-               $this->mShowVersions = false;
-               $this->mEnableWrite = $enableWrite;
+               // Set up the error formatter. This doesn't use $this->getParameter()
+               // because we're not ready to handle errors yet.
+               $errorFormat = $request->getVal( 'errorformat', 'bc' );
+               $errorLangCode = $request->getVal( 'errorlang', 'uselang' );
+               $errorsUseDB = $request->getCheck( 'errorsuselocal' );
+               if ( in_array( $errorFormat, [ 'plaintext', 'wikitext', 'html', 'raw', 'none' ], true ) ) {
+                       if ( $errorLangCode === 'uselang' ) {
+                               $errorLang = $this->getLanguage();
+                       } elseif ( $errorLangCode === 'content' ) {
+                               global $wgContLang;
+                               $errorLang = $wgContLang;
+                       } else {
+                               $errorLangCode = RequestContext::sanitizeLangCode( $errorLangCode );
+                               $errorLang = Language::factory( $errorLangCode );
+                       }
+                       $this->mErrorFormatter = new ApiErrorFormatter(
+                               $this->mResult, $errorLang, $errorFormat, $errorsUseDB
+                       );
+               } else {
+                       $this->mErrorFormatter = new ApiErrorFormatter_BackCompat( $this->mResult );
+               }
+               $this->mResult->setErrorFormatter( $this->getErrorFormatter() );
+
+               $this->mModuleMgr = new ApiModuleManager( $this );
+               $this->mModuleMgr->addModules( self::$Modules, 'action' );
+               $this->mModuleMgr->addModules( $config->get( 'APIModules' ), 'action' );
+               $this->mModuleMgr->addModules( self::$Formats, 'format' );
+               $this->mModuleMgr->addModules( $config->get( 'APIFormatModules' ), 'format' );
 
-               $this->mRequest = &$request;
+               Hooks::run( 'ApiMain::moduleManager', [ $this->mModuleMgr ] );
 
-               $this->mSquidMaxage = - 1; // flag for executeActionWithErrorHandling()
+               $this->mContinuationManager = null;
+               $this->mEnableWrite = $enableWrite;
+
+               $this->mSquidMaxage = -1; // flag for executeActionWithErrorHandling()
                $this->mCommit = false;
        }
 
        /**
         * Return true if the API was started by other PHP code using FauxRequest
+        * @return bool
         */
        public function isInternalMode() {
                return $this->mInternalMode;
        }
 
-       /**
-        * Return the request object that contains client's request
-        * @return WebRequest
-        */
-       public function getRequest() {
-               return $this->mRequest;
-       }
-
        /**
         * Get the ApiResult object associated with current request
         *
@@ -197,8 +317,82 @@ class ApiMain extends ApiBase {
                return $this->mResult;
        }
 
+       /**
+        * Get the security flag for the current request
+        * @return bool
+        */
+       public function lacksSameOriginSecurity() {
+               if ( $this->lacksSameOriginSecurity !== null ) {
+                       return $this->lacksSameOriginSecurity;
+               }
+
+               $request = $this->getRequest();
+
+               // JSONP mode
+               if ( $request->getVal( 'callback' ) !== null ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Anonymous CORS
+               if ( $request->getVal( 'origin' ) === '*' ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Header to be used from XMLHTTPRequest when the request might
+               // otherwise be used for XSS.
+               if ( $request->getHeader( 'Treat-as-Untrusted' ) !== false ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Allow extensions to override.
+               $this->lacksSameOriginSecurity = !Hooks::run( 'RequestHasSameOriginSecurity', [ $request ] );
+               return $this->lacksSameOriginSecurity;
+       }
+
+       /**
+        * Get the ApiErrorFormatter object associated with current request
+        * @return ApiErrorFormatter
+        */
+       public function getErrorFormatter() {
+               return $this->mErrorFormatter;
+       }
+
+       /**
+        * Get the continuation manager
+        * @return ApiContinuationManager|null
+        */
+       public function getContinuationManager() {
+               return $this->mContinuationManager;
+       }
+
+       /**
+        * Set the continuation manager
+        * @param ApiContinuationManager|null $manager
+        */
+       public function setContinuationManager( $manager ) {
+               if ( $manager !== null ) {
+                       if ( !$manager instanceof ApiContinuationManager ) {
+                               throw new InvalidArgumentException( __METHOD__ . ': Was passed ' .
+                                       is_object( $manager ) ? get_class( $manager ) : gettype( $manager )
+                               );
+                       }
+                       if ( $this->mContinuationManager !== null ) {
+                               throw new UnexpectedValueException(
+                                       __METHOD__ . ': tried to set manager from ' . $manager->getSource() .
+                                       ' when a manager is already set from ' . $this->mContinuationManager->getSource()
+                               );
+                       }
+               }
+               $this->mContinuationManager = $manager;
+       }
+
        /**
         * Get the API module object. Only works after executeAction()
+        *
+        * @return ApiBase
         */
        public function getModule() {
                return $this->mModule;
@@ -215,18 +409,20 @@ class ApiMain extends ApiBase {
 
        /**
         * Set how long the response should be cached.
+        *
+        * @param int $maxage
         */
        public function setCacheMaxAge( $maxage ) {
-               $this->setCacheControl( array(
+               $this->setCacheControl( [
                        'max-age' => $maxage,
                        's-maxage' => $maxage
-               ) );
+               ] );
        }
 
        /**
         * Set the type of caching headers which will be sent.
         *
-        * @param $mode String One of:
+        * @param string $mode One of:
         *    - 'public':     Cache this object in public caches, if the maxage or smaxage
         *         parameter is set, or if setCacheMaxAge() was called. If a maximum age is
         *         not provided by any of these means, the object will be private.
@@ -249,33 +445,36 @@ class ApiMain extends ApiBase {
         *  If this function is never called, then the default will be the private mode.
         */
        public function setCacheMode( $mode ) {
-               if ( !in_array( $mode, array( 'private', 'public', 'anon-public-user-private' ) ) ) {
+               if ( !in_array( $mode, [ 'private', 'public', 'anon-public-user-private' ] ) ) {
                        wfDebug( __METHOD__ . ": unrecognised cache mode \"$mode\"\n" );
+
                        // Ignore for forwards-compatibility
                        return;
                }
 
-               if ( !in_array( 'read', User::getGroupPermissions( array( '*' ) ), true ) ) {
+               if ( !User::isEveryoneAllowed( 'read' ) ) {
                        // Private wiki, only private headers
                        if ( $mode !== 'private' ) {
                                wfDebug( __METHOD__ . ": ignoring request for $mode cache mode, private wiki\n" );
+
                                return;
                        }
                }
 
+               if ( $mode === 'public' && $this->getParameter( 'uselang' ) === 'user' ) {
+                       // User language is used for i18n, so we don't want to publicly
+                       // cache. Anons are ok, because if they have non-default language
+                       // then there's an appropriate Vary header set by whatever set
+                       // their non-default language.
+                       wfDebug( __METHOD__ . ": downgrading cache mode 'public' to " .
+                               "'anon-public-user-private' due to uselang=user\n" );
+                       $mode = 'anon-public-user-private';
+               }
+
                wfDebug( __METHOD__ . ": setting cache mode $mode\n" );
                $this->mCacheMode = $mode;
        }
 
-       /**
-        * @deprecated Private caching is now the default, so there is usually no
-        * need to call this function. If there is a need, you can use
-        * $this->setCacheMode('private')
-        */
-       public function setCachePrivate() {
-               $this->setCacheMode( 'private' );
-       }
-
        /**
         * Set directives (key/value pairs) for the Cache-Control header.
         * Boolean values will be formatted as such, by including or omitting
@@ -283,47 +482,40 @@ class ApiMain extends ApiBase {
         *
         * Cache control values set here will only be used if the cache mode is not
         * private, see setCacheMode().
+        *
+        * @param array $directives
         */
        public function setCacheControl( $directives ) {
                $this->mCacheControl = $directives + $this->mCacheControl;
        }
 
        /**
-        * Make sure Vary: Cookie and friends are set. Use this when the output of a request
-        * may be cached for anons but may not be cached for logged-in users.
+        * Create an instance of an output formatter by its name
         *
-        * WARNING: This function must be called CONSISTENTLY for a given URL. This means that a
-        * given URL must either always or never call this function; if it sometimes does and
-        * sometimes doesn't, stuff will break.
+        * @param string $format
         *
-        * @deprecated Use setCacheMode( 'anon-public-user-private' )
-        */
-       public function setVaryCookie() {
-               $this->setCacheMode( 'anon-public-user-private' );
-       }
-
-       /**
-        * Create an instance of an output formatter by its name
+        * @return ApiFormatBase
         */
        public function createPrinterByName( $format ) {
-               if ( !isset( $this->mFormats[$format] ) ) {
-                       $this->dieUsage( "Unrecognized format: {$format}", 'unknown_format' );
+               $printer = $this->mModuleMgr->getModule( $format, 'format' );
+               if ( $printer === null ) {
+                       $this->dieWithError(
+                               [ 'apierror-unknownformat', wfEscapeWikiText( $format ) ], 'unknown_format'
+                       );
                }
-               return new $this->mFormats[$format] ( $this, $format );
+
+               return $printer;
        }
 
        /**
         * Execute api request. Any errors will be handled if the API was called by the remote client.
         */
        public function execute() {
-               $this->profileIn();
                if ( $this->mInternalMode ) {
                        $this->executeAction();
                } else {
                        $this->executeActionWithErrorHandling();
                }
-
-               $this->profileOut();
        }
 
        /**
@@ -331,78 +523,433 @@ class ApiMain extends ApiBase {
         * have been accumulated, and replace it with an error message and a help screen.
         */
        protected function executeActionWithErrorHandling() {
+               // Verify the CORS header before executing the action
+               if ( !$this->handleCORS() ) {
+                       // handleCORS() has sent a 403, abort
+                       return;
+               }
+
+               // Exit here if the request method was OPTIONS
+               // (assume there will be a followup GET or POST)
+               if ( $this->getRequest()->getMethod() === 'OPTIONS' ) {
+                       return;
+               }
+
                // In case an error occurs during data output,
                // clear the output buffer and print just the error information
+               $obLevel = ob_get_level();
                ob_start();
 
+               $t = microtime( true );
+               $isError = false;
                try {
                        $this->executeAction();
+                       $runTime = microtime( true ) - $t;
+                       $this->logRequest( $runTime );
+                       if ( $this->mModule->isWriteMode() && $this->getRequest()->wasPosted() ) {
+                               MediaWikiServices::getInstance()->getStatsdDataFactory()->timing(
+                                       'api.' . $this->mModule->getModuleName() . '.executeTiming', 1000 * $runTime
+                               );
+                       }
                } catch ( Exception $e ) {
-                       // Log it
-                       if ( $e instanceof MWException ) {
-                               wfDebugLog( 'exception', $e->getLogMessage() );
+                       $this->handleException( $e );
+                       $this->logRequest( microtime( true ) - $t, $e );
+                       $isError = true;
+               }
+
+               // Commit DBs and send any related cookies and headers
+               MediaWiki::preOutputCommit( $this->getContext() );
+
+               // Send cache headers after any code which might generate an error, to
+               // avoid sending public cache headers for errors.
+               $this->sendCacheHeaders( $isError );
+
+               // Executing the action might have already messed with the output
+               // buffers.
+               while ( ob_get_level() > $obLevel ) {
+                       ob_end_flush();
+               }
+       }
+
+       /**
+        * Handle an exception as an API response
+        *
+        * @since 1.23
+        * @param Exception $e
+        */
+       protected function handleException( Exception $e ) {
+               // T65145: Rollback any open database transactions
+               if ( !( $e instanceof ApiUsageException || $e instanceof UsageException ) ) {
+                       // UsageExceptions are intentional, so don't rollback if that's the case
+                       MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+               }
+
+               // Allow extra cleanup and logging
+               Hooks::run( 'ApiMain::onException', [ $this, $e ] );
+
+               // Handle any kind of exception by outputting properly formatted error message.
+               // If this fails, an unhandled exception should be thrown so that global error
+               // handler will process and log it.
+
+               $errCodes = $this->substituteResultWithError( $e );
+
+               // Error results should not be cached
+               $this->setCacheMode( 'private' );
+
+               $response = $this->getRequest()->response();
+               $headerStr = 'MediaWiki-API-Error: ' . join( ', ', $errCodes );
+               $response->header( $headerStr );
+
+               // Reset and print just the error message
+               ob_clean();
+
+               // Printer may not be initialized if the extractRequestParams() fails for the main module
+               $this->createErrorPrinter();
+
+               $failed = false;
+               try {
+                       $this->printResult( $e->getCode() );
+               } catch ( ApiUsageException $ex ) {
+                       // The error printer itself is failing. Try suppressing its request
+                       // parameters and redo.
+                       $failed = true;
+                       $this->addWarning( 'apiwarn-errorprinterfailed' );
+                       foreach ( $ex->getStatusValue()->getErrors() as $error ) {
+                               try {
+                                       $this->mPrinter->addWarning( $error );
+                               } catch ( Exception $ex2 ) {
+                                       // WTF?
+                                       $this->addWarning( $error );
+                               }
+                       }
+               } catch ( UsageException $ex ) {
+                       // The error printer itself is failing. Try suppressing its request
+                       // parameters and redo.
+                       $failed = true;
+                       $this->addWarning(
+                               [ 'apiwarn-errorprinterfailed-ex', $ex->getMessage() ], 'errorprinterfailed'
+                       );
+               }
+               if ( $failed ) {
+                       $this->mPrinter = null;
+                       $this->createErrorPrinter();
+                       $this->mPrinter->forceDefaultParams();
+                       if ( $e->getCode() ) {
+                               $response->statusHeader( 200 ); // Reset in case the fallback doesn't want a non-200
                        }
+                       $this->printResult( $e->getCode() );
+               }
+       }
 
-                       //
-                       // Handle any kind of exception by outputing properly formatted error message.
-                       // If this fails, an unhandled exception should be thrown so that global error
-                       // handler will process and log it.
-                       //
+       /**
+        * Handle an exception from the ApiBeforeMain hook.
+        *
+        * This tries to print the exception as an API response, to be more
+        * friendly to clients. If it fails, it will rethrow the exception.
+        *
+        * @since 1.23
+        * @param Exception $e
+        * @throws Exception
+        */
+       public static function handleApiBeforeMainException( Exception $e ) {
+               ob_start();
 
-                       $errCode = $this->substituteResultWithError( $e );
+               try {
+                       $main = new self( RequestContext::getMain(), false );
+                       $main->handleException( $e );
+                       $main->logRequest( 0, $e );
+               } catch ( Exception $e2 ) {
+                       // Nope, even that didn't work. Punt.
+                       throw $e;
+               }
 
-                       // Error results should not be cached
-                       $this->setCacheMode( 'private' );
+               // Reset cache headers
+               $main->sendCacheHeaders( true );
 
-                       $headerStr = 'MediaWiki-API-Error: ' . $errCode;
-                       if ( $e->getCode() === 0 ) {
-                               header( $headerStr );
+               ob_end_flush();
+       }
+
+       /**
+        * Check the &origin= query parameter against the Origin: HTTP header and respond appropriately.
+        *
+        * If no origin parameter is present, nothing happens.
+        * If an origin parameter is present but doesn't match the Origin header, a 403 status code
+        * is set and false is returned.
+        * If the parameter and the header do match, the header is checked against $wgCrossSiteAJAXdomains
+        * and $wgCrossSiteAJAXdomainExceptions, and if the origin qualifies, the appropriate CORS
+        * headers are set.
+        * https://www.w3.org/TR/cors/#resource-requests
+        * https://www.w3.org/TR/cors/#resource-preflight-requests
+        *
+        * @return bool False if the caller should abort (403 case), true otherwise (all other cases)
+        */
+       protected function handleCORS() {
+               $originParam = $this->getParameter( 'origin' ); // defaults to null
+               if ( $originParam === null ) {
+                       // No origin parameter, nothing to do
+                       return true;
+               }
+
+               $request = $this->getRequest();
+               $response = $request->response();
+
+               $matchedOrigin = false;
+               $allowTiming = false;
+               $varyOrigin = true;
+
+               if ( $originParam === '*' ) {
+                       // Request for anonymous CORS
+                       // Technically we should check for the presence of an Origin header
+                       // and not process it as CORS if it's not set, but that would
+                       // require us to vary on Origin for all 'origin=*' requests which
+                       // we don't want to do.
+                       $matchedOrigin = true;
+                       $allowOrigin = '*';
+                       $allowCredentials = 'false';
+                       $varyOrigin = false; // No need to vary
+               } else {
+                       // Non-anonymous CORS, check we allow the domain
+
+                       // Origin: header is a space-separated list of origins, check all of them
+                       $originHeader = $request->getHeader( 'Origin' );
+                       if ( $originHeader === false ) {
+                               $origins = [];
                        } else {
-                               header( $headerStr, true, $e->getCode() );
+                               $originHeader = trim( $originHeader );
+                               $origins = preg_split( '/\s+/', $originHeader );
                        }
 
-                       // Reset and print just the error message
-                       ob_clean();
+                       if ( !in_array( $originParam, $origins ) ) {
+                               // origin parameter set but incorrect
+                               // Send a 403 response
+                               $response->statusHeader( 403 );
+                               $response->header( 'Cache-Control: no-cache' );
+                               echo "'origin' parameter does not match Origin header\n";
+
+                               return false;
+                       }
+
+                       $config = $this->getConfig();
+                       $matchedOrigin = count( $origins ) === 1 && self::matchOrigin(
+                               $originParam,
+                               $config->get( 'CrossSiteAJAXdomains' ),
+                               $config->get( 'CrossSiteAJAXdomainExceptions' )
+                       );
 
-                       // If the error occured during printing, do a printer->profileOut()
-                       $this->mPrinter->safeProfileOut();
-                       $this->printResult( true );
+                       $allowOrigin = $originHeader;
+                       $allowCredentials = 'true';
+                       $allowTiming = $originHeader;
                }
 
-               // Send cache headers after any code which might generate an error, to
-               // avoid sending public cache headers for errors.
-               $this->sendCacheHeaders();
+               if ( $matchedOrigin ) {
+                       $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' );
+                       $preflight = $request->getMethod() === 'OPTIONS' && $requestedMethod !== false;
+                       if ( $preflight ) {
+                               // This is a CORS preflight request
+                               if ( $requestedMethod !== 'POST' && $requestedMethod !== 'GET' ) {
+                                       // If method is not a case-sensitive match, do not set any additional headers and terminate.
+                                       $response->header( 'MediaWiki-CORS-Rejection: Unsupported method requested in preflight' );
+                                       return true;
+                               }
+                               // We allow the actual request to send the following headers
+                               $requestedHeaders = $request->getHeader( 'Access-Control-Request-Headers' );
+                               if ( $requestedHeaders !== false ) {
+                                       if ( !self::matchRequestedHeaders( $requestedHeaders ) ) {
+                                               $response->header( 'MediaWiki-CORS-Rejection: Unsupported header requested in preflight' );
+                                               return true;
+                                       }
+                                       $response->header( 'Access-Control-Allow-Headers: ' . $requestedHeaders );
+                               }
+
+                               // We only allow the actual request to be GET or POST
+                               $response->header( 'Access-Control-Allow-Methods: POST, GET' );
+                       } elseif ( $request->getMethod() !== 'POST' && $request->getMethod() !== 'GET' ) {
+                               // Unsupported non-preflight method, don't handle it as CORS
+                               $response->header(
+                                       'MediaWiki-CORS-Rejection: Unsupported method for simple request or actual request'
+                               );
+                               return true;
+                       }
+
+                       $response->header( "Access-Control-Allow-Origin: $allowOrigin" );
+                       $response->header( "Access-Control-Allow-Credentials: $allowCredentials" );
+                       // https://www.w3.org/TR/resource-timing/#timing-allow-origin
+                       if ( $allowTiming !== false ) {
+                               $response->header( "Timing-Allow-Origin: $allowTiming" );
+                       }
 
-               if ( $this->mPrinter->getIsHtml() && !$this->mPrinter->isDisabled() ) {
-                       echo wfReportTime();
+                       if ( !$preflight ) {
+                               $response->header(
+                                       'Access-Control-Expose-Headers: MediaWiki-API-Error, Retry-After, X-Database-Lag, '
+                                       . 'MediaWiki-Login-Suppressed'
+                               );
+                       }
+               } else {
+                       $response->header( 'MediaWiki-CORS-Rejection: Origin mismatch' );
                }
 
-               ob_end_flush();
+               if ( $varyOrigin ) {
+                       $this->getOutput()->addVaryHeader( 'Origin' );
+               }
+
+               return true;
+       }
+
+       /**
+        * Attempt to match an Origin header against a set of rules and a set of exceptions
+        * @param string $value Origin header
+        * @param array $rules Set of wildcard rules
+        * @param array $exceptions Set of wildcard rules
+        * @return bool True if $value matches a rule in $rules and doesn't match
+        *    any rules in $exceptions, false otherwise
+        */
+       protected static function matchOrigin( $value, $rules, $exceptions ) {
+               foreach ( $rules as $rule ) {
+                       if ( preg_match( self::wildcardToRegex( $rule ), $value ) ) {
+                               // Rule matches, check exceptions
+                               foreach ( $exceptions as $exc ) {
+                                       if ( preg_match( self::wildcardToRegex( $exc ), $value ) ) {
+                                               return false;
+                                       }
+                               }
+
+                               return true;
+                       }
+               }
+
+               return false;
        }
 
-       protected function sendCacheHeaders() {
+       /**
+        * Attempt to validate the value of Access-Control-Request-Headers against a list
+        * of headers that we allow the follow up request to send.
+        *
+        * @param string $requestedHeaders Comma seperated list of HTTP headers
+        * @return bool True if all requested headers are in the list of allowed headers
+        */
+       protected static function matchRequestedHeaders( $requestedHeaders ) {
+               if ( trim( $requestedHeaders ) === '' ) {
+                       return true;
+               }
+               $requestedHeaders = explode( ',', $requestedHeaders );
+               $allowedAuthorHeaders = array_flip( [
+                       /* simple headers (see spec) */
+                       'accept',
+                       'accept-language',
+                       'content-language',
+                       'content-type',
+                       /* non-authorable headers in XHR, which are however requested by some UAs */
+                       'accept-encoding',
+                       'dnt',
+                       'origin',
+                       /* MediaWiki whitelist */
+                       'api-user-agent',
+               ] );
+               foreach ( $requestedHeaders as $rHeader ) {
+                       $rHeader = strtolower( trim( $rHeader ) );
+                       if ( !isset( $allowedAuthorHeaders[$rHeader] ) ) {
+                               wfDebugLog( 'api', 'CORS preflight failed on requested header: ' . $rHeader );
+                               return false;
+                       }
+               }
+               return true;
+       }
+
+       /**
+        * Helper function to convert wildcard string into a regex
+        * '*' => '.*?'
+        * '?' => '.'
+        *
+        * @param string $wildcard String with wildcards
+        * @return string Regular expression
+        */
+       protected static function wildcardToRegex( $wildcard ) {
+               $wildcard = preg_quote( $wildcard, '/' );
+               $wildcard = str_replace(
+                       [ '\*', '\?' ],
+                       [ '.*?', '.' ],
+                       $wildcard
+               );
+
+               return "/^https?:\/\/$wildcard$/";
+       }
+
+       /**
+        * Send caching headers
+        * @param bool $isError Whether an error response is being output
+        * @since 1.26 added $isError parameter
+        */
+       protected function sendCacheHeaders( $isError ) {
+               $response = $this->getRequest()->response();
+               $out = $this->getOutput();
+
+               $out->addVaryHeader( 'Treat-as-Untrusted' );
+
+               $config = $this->getConfig();
+
+               if ( $config->get( 'VaryOnXFP' ) ) {
+                       $out->addVaryHeader( 'X-Forwarded-Proto' );
+               }
+
+               if ( !$isError && $this->mModule &&
+                       ( $this->getRequest()->getMethod() === 'GET' || $this->getRequest()->getMethod() === 'HEAD' )
+               ) {
+                       $etag = $this->mModule->getConditionalRequestData( 'etag' );
+                       if ( $etag !== null ) {
+                               $response->header( "ETag: $etag" );
+                       }
+                       $lastMod = $this->mModule->getConditionalRequestData( 'last-modified' );
+                       if ( $lastMod !== null ) {
+                               $response->header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $lastMod ) );
+                       }
+               }
+
+               // The logic should be:
+               // $this->mCacheControl['max-age'] is set?
+               //    Use it, the module knows better than our guess.
+               // !$this->mModule || $this->mModule->isWriteMode(), and mCacheMode is private?
+               //    Use 0 because we can guess caching is probably the wrong thing to do.
+               // Use $this->getParameter( 'maxage' ), which already defaults to 0.
+               $maxage = 0;
+               if ( isset( $this->mCacheControl['max-age'] ) ) {
+                       $maxage = $this->mCacheControl['max-age'];
+               } elseif ( ( $this->mModule && !$this->mModule->isWriteMode() ) ||
+                       $this->mCacheMode !== 'private'
+               ) {
+                       $maxage = $this->getParameter( 'maxage' );
+               }
+               $privateCache = 'private, must-revalidate, max-age=' . $maxage;
+
                if ( $this->mCacheMode == 'private' ) {
-                       header( 'Cache-Control: private' );
+                       $response->header( "Cache-Control: $privateCache" );
                        return;
                }
 
+               $useKeyHeader = $config->get( 'UseKeyHeader' );
                if ( $this->mCacheMode == 'anon-public-user-private' ) {
-                       global $wgUseXVO, $wgOut;
-                       header( 'Vary: Accept-Encoding, Cookie' );
-                       if ( $wgUseXVO ) {
-                               header( $wgOut->getXVO() );
-                               if ( $wgOut->haveCacheVaryCookies() ) {
+                       $out->addVaryHeader( 'Cookie' );
+                       $response->header( $out->getVaryHeader() );
+                       if ( $useKeyHeader ) {
+                               $response->header( $out->getKeyHeader() );
+                               if ( $out->haveCacheVaryCookies() ) {
                                        // Logged in, mark this request private
-                                       header( 'Cache-Control: private' );
+                                       $response->header( "Cache-Control: $privateCache" );
                                        return;
                                }
                                // Logged out, send normal public headers below
-                       } elseif ( session_id() != '' ) {
+                       } elseif ( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ) {
                                // Logged in or otherwise has session (e.g. anonymous users who have edited)
                                // Mark request private
-                               header( 'Cache-Control: private' );
+                               $response->header( "Cache-Control: $privateCache" );
+
                                return;
-                       } // else no XVO and anonymous, send public headers below
+                       } // else no Key and anonymous, send public headers below
+               }
+
+               // Send public headers
+               $response->header( $out->getVaryHeader() );
+               if ( $useKeyHeader ) {
+                       $response->header( $out->getKeyHeader() );
                }
 
                // If nobody called setCacheMaxAge(), use the (s)maxage parameters
@@ -417,7 +964,8 @@ class ApiMain extends ApiBase {
                        // Public cache not requested
                        // Sending a Vary header in this case is harmless, and protects us
                        // against conditional calls of setCacheMaxAge().
-                       header( 'Cache-Control: private' );
+                       $response->header( "Cache-Control: $privateCache" );
+
                        return;
                }
 
@@ -426,7 +974,7 @@ class ApiMain extends ApiBase {
                // Send an Expires header
                $maxAge = min( $this->mCacheControl['s-maxage'], $this->mCacheControl['max-age'] );
                $expiryUnixTime = ( $maxAge == 0 ? 1 : time() + $maxAge );
-               header( 'Expires: ' . wfTimestamp( TS_RFC2822, $expiryUnixTime ) );
+               $response->header( 'Expires: ' . wfTimestamp( TS_RFC2822, $expiryUnixTime ) );
 
                // Construct the Cache-Control header
                $ccHeader = '';
@@ -443,190 +991,548 @@ class ApiMain extends ApiBase {
                        }
                }
 
-               header( "Cache-Control: $ccHeader" );
+               $response->header( "Cache-Control: $ccHeader" );
        }
 
        /**
-        * Replace the result data with the information about an exception.
-        * Returns the error code
-        * @param $e Exception
+        * Create the printer for error output
         */
-       protected function substituteResultWithError( $e ) {
-               // Printer may not be initialized if the extractRequestParams() fails for the main module
-               if ( !isset ( $this->mPrinter ) ) {
-                       // The printer has not been created yet. Try to manually get formatter value.
+       private function createErrorPrinter() {
+               if ( !isset( $this->mPrinter ) ) {
                        $value = $this->getRequest()->getVal( 'format', self::API_DEFAULT_FORMAT );
-                       if ( !in_array( $value, $this->mFormatNames ) ) {
+                       if ( !$this->mModuleMgr->isDefined( $value, 'format' ) ) {
                                $value = self::API_DEFAULT_FORMAT;
                        }
-
                        $this->mPrinter = $this->createPrinterByName( $value );
-                       if ( $this->mPrinter->getNeedsRawData() ) {
-                               $this->getResult()->setRawMode();
-                       }
                }
 
-               if ( $e instanceof UsageException ) {
-                       //
-                       // User entered incorrect parameters - print usage screen
-                       //
-                       $errMessage = $e->getMessageArray();
+               // Printer may not be able to handle errors. This is particularly
+               // likely if the module returns something for getCustomPrinter().
+               if ( !$this->mPrinter->canPrintErrors() ) {
+                       $this->mPrinter = $this->createPrinterByName( self::API_DEFAULT_FORMAT );
+               }
+       }
 
-                       // Only print the help message when this is for the developer, not runtime
-                       if ( $this->mPrinter->getWantsHelp() || $this->mAction == 'help' ) {
-                               ApiResult::setContent( $errMessage, $this->makeHelpMsg() );
+       /**
+        * Create an error message for the given exception.
+        *
+        * If an ApiUsageException, errors/warnings will be extracted from the
+        * embedded StatusValue.
+        *
+        * If a base UsageException, the getMessageArray() method will be used to
+        * extract the code and English message for a single error (no warnings).
+        *
+        * Any other exception will be returned with a generic code and wrapper
+        * text around the exception's (presumably English) message as a single
+        * error (no warnings).
+        *
+        * @param Exception $e
+        * @param string $type 'error' or 'warning'
+        * @return ApiMessage[]
+        * @since 1.27
+        */
+       protected function errorMessagesFromException( $e, $type = 'error' ) {
+               $messages = [];
+               if ( $e instanceof ApiUsageException ) {
+                       foreach ( $e->getStatusValue()->getErrorsByType( $type ) as $error ) {
+                               $messages[] = ApiMessage::create( $error );
                        }
-
+               } elseif ( $type !== 'error' ) {
+                       // None of the rest have any messages for non-error types
+               } elseif ( $e instanceof UsageException ) {
+                       // User entered incorrect parameters - generate error response
+                       $data = MediaWiki\quietCall( [ $e, 'getMessageArray' ] );
+                       $code = $data['code'];
+                       $info = $data['info'];
+                       unset( $data['code'], $data['info'] );
+                       $messages[] = new ApiRawMessage( [ '$1', $info ], $code, $data );
                } else {
-                       global $wgShowSQLErrors, $wgShowExceptionDetails;
-                       //
                        // Something is seriously wrong
-                       //
-                       if ( ( $e instanceof DBQueryError ) && !$wgShowSQLErrors ) {
-                               $info = 'Database query error';
+                       $config = $this->getConfig();
+                       $class = preg_replace( '#^Wikimedia\\\Rdbms\\\#', '', get_class( $e ) );
+                       $code = 'internal_api_error_' . $class;
+                       if ( ( $e instanceof DBQueryError ) && !$config->get( 'ShowSQLErrors' ) ) {
+                               $params = [ 'apierror-databaseerror', WebRequest::getRequestId() ];
                        } else {
-                               $info = "Exception Caught: {$e->getMessage()}";
+                               $params = [
+                                       'apierror-exceptioncaught',
+                                       WebRequest::getRequestId(),
+                                       $e instanceof ILocalizedException
+                                               ? $e->getMessageObject()
+                                               : wfEscapeWikiText( $e->getMessage() )
+                               ];
+                       }
+                       $messages[] = ApiMessage::create( $params, $code );
+               }
+               return $messages;
+       }
+
+       /**
+        * Replace the result data with the information about an exception.
+        * @param Exception $e
+        * @return string[] Error codes
+        */
+       protected function substituteResultWithError( $e ) {
+               $result = $this->getResult();
+               $formatter = $this->getErrorFormatter();
+               $config = $this->getConfig();
+               $errorCodes = [];
+
+               // Remember existing warnings and errors across the reset
+               $errors = $result->getResultData( [ 'errors' ] );
+               $warnings = $result->getResultData( [ 'warnings' ] );
+               $result->reset();
+               if ( $warnings !== null ) {
+                       $result->addValue( null, 'warnings', $warnings, ApiResult::NO_SIZE_CHECK );
+               }
+               if ( $errors !== null ) {
+                       $result->addValue( null, 'errors', $errors, ApiResult::NO_SIZE_CHECK );
+
+                       // Collect the copied error codes for the return value
+                       foreach ( $errors as $error ) {
+                               if ( isset( $error['code'] ) ) {
+                                       $errorCodes[$error['code']] = true;
+                               }
                        }
+               }
+
+               // Add errors from the exception
+               $modulePath = $e instanceof ApiUsageException ? $e->getModulePath() : null;
+               foreach ( $this->errorMessagesFromException( $e, 'error' ) as $msg ) {
+                       $errorCodes[$msg->getApiCode()] = true;
+                       $formatter->addError( $modulePath, $msg );
+               }
+               foreach ( $this->errorMessagesFromException( $e, 'warning' ) as $msg ) {
+                       $formatter->addWarning( $modulePath, $msg );
+               }
 
-                       $errMessage = array(
-                               'code' => 'internal_api_error_' . get_class( $e ),
-                               'info' => $info,
+               // Add additional data. Path depends on whether we're in BC mode or not.
+               // Data depends on the type of exception.
+               if ( $formatter instanceof ApiErrorFormatter_BackCompat ) {
+                       $path = [ 'error' ];
+               } else {
+                       $path = null;
+               }
+               if ( $e instanceof ApiUsageException || $e instanceof UsageException ) {
+                       $link = wfExpandUrl( wfScript( 'api' ) );
+                       $result->addContentValue(
+                               $path,
+                               'docref',
+                               trim(
+                                       $this->msg( 'api-usage-docref', $link )->inLanguage( $formatter->getLanguage() )->text()
+                                       . ' '
+                                       . $this->msg( 'api-usage-mailinglist-ref' )->inLanguage( $formatter->getLanguage() )->text()
+                               )
                        );
-                       ApiResult::setContent( $errMessage, $wgShowExceptionDetails ? "\n\n{$e->getTraceAsString()}\n\n" : '' );
+               } else {
+                       if ( $config->get( 'ShowExceptionDetails' ) &&
+                               ( !$e instanceof DBError || $config->get( 'ShowDBErrorBacktrace' ) )
+                       ) {
+                               $result->addContentValue(
+                                       $path,
+                                       'trace',
+                                       $this->msg( 'api-exception-trace',
+                                               get_class( $e ),
+                                               $e->getFile(),
+                                               $e->getLine(),
+                                               MWExceptionHandler::getRedactedTraceAsString( $e )
+                                       )->inLanguage( $formatter->getLanguage() )->text()
+                               );
+                       }
                }
 
-               $this->getResult()->reset();
-               $this->getResult()->disableSizeCheck();
-               // Re-add the id
+               // Add the id and such
+               $this->addRequestedFields( [ 'servedby' ] );
+
+               return array_keys( $errorCodes );
+       }
+
+       /**
+        * Add requested fields to the result
+        * @param string[] $force Which fields to force even if not requested. Accepted values are:
+        *  - servedby
+        */
+       protected function addRequestedFields( $force = [] ) {
+               $result = $this->getResult();
+
                $requestid = $this->getParameter( 'requestid' );
-               if ( !is_null( $requestid ) ) {
-                       $this->getResult()->addValue( null, 'requestid', $requestid );
+               if ( $requestid !== null ) {
+                       $result->addValue( null, 'requestid', $requestid, ApiResult::NO_SIZE_CHECK );
+               }
+
+               if ( $this->getConfig()->get( 'ShowHostnames' ) && (
+                       in_array( 'servedby', $force, true ) || $this->getParameter( 'servedby' )
+               ) ) {
+                       $result->addValue( null, 'servedby', wfHostname(), ApiResult::NO_SIZE_CHECK );
+               }
+
+               if ( $this->getParameter( 'curtimestamp' ) ) {
+                       $result->addValue( null, 'curtimestamp', wfTimestamp( TS_ISO_8601, time() ),
+                               ApiResult::NO_SIZE_CHECK );
                }
-               // servedby is especially useful when debugging errors
-               $this->getResult()->addValue( null, 'servedby', wfHostName() );
-               $this->getResult()->addValue( null, 'error', $errMessage );
 
-               return $errMessage['code'];
+               if ( $this->getParameter( 'responselanginfo' ) ) {
+                       $result->addValue( null, 'uselang', $this->getLanguage()->getCode(),
+                               ApiResult::NO_SIZE_CHECK );
+                       $result->addValue( null, 'errorlang', $this->getErrorFormatter()->getLanguage()->getCode(),
+                               ApiResult::NO_SIZE_CHECK );
+               }
        }
 
        /**
         * Set up for the execution.
+        * @return array
         */
        protected function setupExecuteAction() {
-               // First add the id to the top element
-               $requestid = $this->getParameter( 'requestid' );
-               if ( !is_null( $requestid ) ) {
-                       $this->getResult()->addValue( null, 'requestid', $requestid );
-               }
-               $servedby = $this->getParameter( 'servedby' );
-               if ( $servedby ) {
-                       $this->getResult()->addValue( null, 'servedby', wfHostName() );
-               }
+               $this->addRequestedFields();
 
                $params = $this->extractRequestParams();
-
-               $this->mShowVersions = $params['version'];
                $this->mAction = $params['action'];
 
-               if ( !is_string( $this->mAction ) ) {
-                       $this->dieUsage( 'The API requires a valid action parameter', 'unknown_action' );
-               }
-
                return $params;
        }
 
        /**
         * Set up the module for response
         * @return ApiBase The module that will handle this action
+        * @throws MWException
+        * @throws ApiUsageException
         */
        protected function setupModule() {
                // Instantiate the module requested by the user
-               $module = new $this->mModules[$this->mAction] ( $this, $this->mAction );
-               $this->mModule = $module;
-
+               $module = $this->mModuleMgr->getModule( $this->mAction, 'action' );
+               if ( $module === null ) {
+                       $this->dieWithError(
+                               [ 'apierror-unknownaction', wfEscapeWikiText( $this->mAction ) ], 'unknown_action'
+                       );
+               }
                $moduleParams = $module->extractRequestParams();
 
-               // Die if token required, but not provided (unless there is a gettoken parameter)
-               if ( isset( $moduleParams['gettoken'] ) ) {
-                       $gettoken = $moduleParams['gettoken'];
-               } else {
-                       $gettoken = false;
+               // Check token, if necessary
+               if ( $module->needsToken() === true ) {
+                       throw new MWException(
+                               "Module '{$module->getModuleName()}' must be updated for the new token handling. " .
+                               'See documentation for ApiBase::needsToken for details.'
+                       );
                }
+               if ( $module->needsToken() ) {
+                       if ( !$module->mustBePosted() ) {
+                               throw new MWException(
+                                       "Module '{$module->getModuleName()}' must require POST to use tokens."
+                               );
+                       }
 
-               $salt = $module->getTokenSalt();
-               if ( $salt !== false && !$gettoken ) {
                        if ( !isset( $moduleParams['token'] ) ) {
-                               $this->dieUsageMsg( array( 'missingparam', 'token' ) );
-                       } else {
-                               global $wgUser;
-                               if ( !$wgUser->matchEditToken( $moduleParams['token'], $salt ) ) {
-                                       $this->dieUsageMsg( array( 'sessionfailure' ) );
-                               }
+                               $module->dieWithError( [ 'apierror-missingparam', 'token' ] );
+                       }
+
+                       $module->requirePostedParameters( [ 'token' ] );
+
+                       if ( !$module->validateToken( $moduleParams['token'], $moduleParams ) ) {
+                               $module->dieWithError( 'apierror-badtoken' );
                        }
                }
+
                return $module;
        }
 
+       /**
+        * @return array
+        */
+       private function getMaxLag() {
+               $dbLag = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaxLag();
+               $lagInfo = [
+                       'host' => $dbLag[0],
+                       'lag' => $dbLag[1],
+                       'type' => 'db'
+               ];
+
+               $jobQueueLagFactor = $this->getConfig()->get( 'JobQueueIncludeInMaxLagFactor' );
+               if ( $jobQueueLagFactor ) {
+                       // Turn total number of jobs into seconds by using the configured value
+                       $totalJobs = array_sum( JobQueueGroup::singleton()->getQueueSizes() );
+                       $jobQueueLag = $totalJobs / (float)$jobQueueLagFactor;
+                       if ( $jobQueueLag > $lagInfo['lag'] ) {
+                               $lagInfo = [
+                                       'host' => wfHostname(), // XXX: Is there a better value that could be used?
+                                       'lag' => $jobQueueLag,
+                                       'type' => 'jobqueue',
+                                       'jobs' => $totalJobs,
+                               ];
+                       }
+               }
+
+               return $lagInfo;
+       }
+
        /**
         * Check the max lag if necessary
-        * @param $module ApiBase object: Api module being used
-        * @param $params Array an array containing the request parameters.
-        * @return boolean True on success, false should exit immediately
+        * @param ApiBase $module Api module being used
+        * @param array $params Array an array containing the request parameters.
+        * @return bool True on success, false should exit immediately
         */
        protected function checkMaxLag( $module, $params ) {
                if ( $module->shouldCheckMaxlag() && isset( $params['maxlag'] ) ) {
-                       // Check for maxlag
-                       global $wgShowHostnames;
                        $maxLag = $params['maxlag'];
-                       list( $host, $lag ) = wfGetLB()->getMaxLag();
-                       if ( $lag > $maxLag ) {
-                               header( 'Retry-After: ' . max( intval( $maxLag ), 5 ) );
-                               header( 'X-Database-Lag: ' . intval( $lag ) );
-                               if ( $wgShowHostnames ) {
-                                       $this->dieUsage( "Waiting for $host: $lag seconds lagged", 'maxlag' );
-                               } else {
-                                       $this->dieUsage( "Waiting for a database server: $lag seconds lagged", 'maxlag' );
+                       $lagInfo = $this->getMaxLag();
+                       if ( $lagInfo['lag'] > $maxLag ) {
+                               $response = $this->getRequest()->response();
+
+                               $response->header( 'Retry-After: ' . max( intval( $maxLag ), 5 ) );
+                               $response->header( 'X-Database-Lag: ' . intval( $lagInfo['lag'] ) );
+
+                               if ( $this->getConfig()->get( 'ShowHostnames' ) ) {
+                                       $this->dieWithError(
+                                               [ 'apierror-maxlag', $lagInfo['lag'], $lagInfo['host'] ],
+                                               'maxlag',
+                                               $lagInfo
+                                       );
                                }
-                               return false;
+
+                               $this->dieWithError( [ 'apierror-maxlag-generic', $lagInfo['lag'] ], 'maxlag', $lagInfo );
                        }
                }
+
                return true;
        }
 
+       /**
+        * Check selected RFC 7232 precondition headers
+        *
+        * RFC 7232 envisions a particular model where you send your request to "a
+        * resource", and for write requests that you can read "the resource" by
+        * changing the method to GET. When the API receives a GET request, it
+        * works out even though "the resource" from RFC 7232's perspective might
+        * be many resources from MediaWiki's perspective. But it totally fails for
+        * a POST, since what HTTP sees as "the resource" is probably just
+        * "/api.php" with all the interesting bits in the body.
+        *
+        * Therefore, we only support RFC 7232 precondition headers for GET (and
+        * HEAD). That means we don't need to bother with If-Match and
+        * If-Unmodified-Since since they only apply to modification requests.
+        *
+        * And since we don't support Range, If-Range is ignored too.
+        *
+        * @since 1.26
+        * @param ApiBase $module Api module being used
+        * @return bool True on success, false should exit immediately
+        */
+       protected function checkConditionalRequestHeaders( $module ) {
+               if ( $this->mInternalMode ) {
+                       // No headers to check in internal mode
+                       return true;
+               }
+
+               if ( $this->getRequest()->getMethod() !== 'GET' && $this->getRequest()->getMethod() !== 'HEAD' ) {
+                       // Don't check POSTs
+                       return true;
+               }
+
+               $return304 = false;
+
+               $ifNoneMatch = array_diff(
+                       $this->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ) ?: [],
+                       [ '' ]
+               );
+               if ( $ifNoneMatch ) {
+                       if ( $ifNoneMatch === [ '*' ] ) {
+                               // API responses always "exist"
+                               $etag = '*';
+                       } else {
+                               $etag = $module->getConditionalRequestData( 'etag' );
+                       }
+               }
+               if ( $ifNoneMatch && $etag !== null ) {
+                       $test = substr( $etag, 0, 2 ) === 'W/' ? substr( $etag, 2 ) : $etag;
+                       $match = array_map( function ( $s ) {
+                               return substr( $s, 0, 2 ) === 'W/' ? substr( $s, 2 ) : $s;
+                       }, $ifNoneMatch );
+                       $return304 = in_array( $test, $match, true );
+               } else {
+                       $value = trim( $this->getRequest()->getHeader( 'If-Modified-Since' ) );
+
+                       // Some old browsers sends sizes after the date, like this:
+                       //  Wed, 20 Aug 2003 06:51:19 GMT; length=5202
+                       // Ignore that.
+                       $i = strpos( $value, ';' );
+                       if ( $i !== false ) {
+                               $value = trim( substr( $value, 0, $i ) );
+                       }
+
+                       if ( $value !== '' ) {
+                               try {
+                                       $ts = new MWTimestamp( $value );
+                                       if (
+                                               // RFC 7231 IMF-fixdate
+                                               $ts->getTimestamp( TS_RFC2822 ) === $value ||
+                                               // RFC 850
+                                               $ts->format( 'l, d-M-y H:i:s' ) . ' GMT' === $value ||
+                                               // asctime (with and without space-padded day)
+                                               $ts->format( 'D M j H:i:s Y' ) === $value ||
+                                               $ts->format( 'D M  j H:i:s Y' ) === $value
+                                       ) {
+                                               $lastMod = $module->getConditionalRequestData( 'last-modified' );
+                                               if ( $lastMod !== null ) {
+                                                       // Mix in some MediaWiki modification times
+                                                       $modifiedTimes = [
+                                                               'page' => $lastMod,
+                                                               'user' => $this->getUser()->getTouched(),
+                                                               'epoch' => $this->getConfig()->get( 'CacheEpoch' ),
+                                                       ];
+                                                       if ( $this->getConfig()->get( 'UseSquid' ) ) {
+                                                               // T46570: the core page itself may not change, but resources might
+                                                               $modifiedTimes['sepoch'] = wfTimestamp(
+                                                                       TS_MW, time() - $this->getConfig()->get( 'SquidMaxage' )
+                                                               );
+                                                       }
+                                                       Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes, $this->getOutput() ] );
+                                                       $lastMod = max( $modifiedTimes );
+                                                       $return304 = wfTimestamp( TS_MW, $lastMod ) <= $ts->getTimestamp( TS_MW );
+                                               }
+                                       }
+                               } catch ( TimestampException $e ) {
+                                       // Invalid timestamp, ignore it
+                               }
+                       }
+               }
+
+               if ( $return304 ) {
+                       $this->getRequest()->response()->statusHeader( 304 );
+
+                       // Avoid outputting the compressed representation of a zero-length body
+                       MediaWiki\suppressWarnings();
+                       ini_set( 'zlib.output_compression', 0 );
+                       MediaWiki\restoreWarnings();
+                       wfClearOutputBuffers();
+
+                       return false;
+               }
+
+               return true;
+       }
 
        /**
         * Check for sufficient permissions to execute
-        * @param $module ApiBase An Api module
+        * @param ApiBase $module An Api module
         */
        protected function checkExecutePermissions( $module ) {
-               global $wgUser;
-               if ( $module->isReadMode() && !in_array( 'read', User::getGroupPermissions( array( '*' ) ), true ) &&
-                       !$wgUser->isAllowed( 'read' ) )
-               {
-                       $this->dieUsageMsg( array( 'readrequired' ) );
+               $user = $this->getUser();
+               if ( $module->isReadMode() && !User::isEveryoneAllowed( 'read' ) &&
+                       !$user->isAllowed( 'read' )
+               {
+                       $this->dieWithError( 'apierror-readapidenied' );
                }
+
                if ( $module->isWriteMode() ) {
                        if ( !$this->mEnableWrite ) {
-                               $this->dieUsageMsg( array( 'writedisabled' ) );
+                               $this->dieWithError( 'apierror-noapiwrite' );
+                       } elseif ( !$user->isAllowed( 'writeapi' ) ) {
+                               $this->dieWithError( 'apierror-writeapidenied' );
+                       } elseif ( $this->getRequest()->getHeader( 'Promise-Non-Write-API-Action' ) ) {
+                               $this->dieWithError( 'apierror-promised-nonwrite-api' );
                        }
-                       if ( !$wgUser->isAllowed( 'writeapi' ) ) {
-                               $this->dieUsageMsg( array( 'writerequired' ) );
+
+                       $this->checkReadOnly( $module );
+               }
+
+               // Allow extensions to stop execution for arbitrary reasons.
+               $message = false;
+               if ( !Hooks::run( 'ApiCheckCanExecute', [ $module, $user, &$message ] ) ) {
+                       $this->dieWithError( $message );
+               }
+       }
+
+       /**
+        * Check if the DB is read-only for this user
+        * @param ApiBase $module An Api module
+        */
+       protected function checkReadOnly( $module ) {
+               if ( wfReadOnly() ) {
+                       $this->dieReadOnly();
+               }
+
+               if ( $module->isWriteMode()
+                       && $this->getUser()->isBot()
+                       && wfGetLB()->getServerCount() > 1
+               ) {
+                       $this->checkBotReadOnly();
+               }
+       }
+
+       /**
+        * Check whether we are readonly for bots
+        */
+       private function checkBotReadOnly() {
+               // Figure out how many servers have passed the lag threshold
+               $numLagged = 0;
+               $lagLimit = $this->getConfig()->get( 'APIMaxLagThreshold' );
+               $laggedServers = [];
+               $loadBalancer = wfGetLB();
+               foreach ( $loadBalancer->getLagTimes() as $serverIndex => $lag ) {
+                       if ( $lag > $lagLimit ) {
+                               ++$numLagged;
+                               $laggedServers[] = $loadBalancer->getServerName( $serverIndex ) . " ({$lag}s)";
                        }
-                       if ( wfReadOnly() ) {
-                               $this->dieReadOnly();
+               }
+
+               // If a majority of replica DBs are too lagged then disallow writes
+               $replicaCount = wfGetLB()->getServerCount() - 1;
+               if ( $numLagged >= ceil( $replicaCount / 2 ) ) {
+                       $laggedServers = implode( ', ', $laggedServers );
+                       wfDebugLog(
+                               'api-readonly',
+                               "Api request failed as read only because the following DBs are lagged: $laggedServers"
+                       );
+
+                       $this->dieWithError(
+                               'readonly_lag',
+                               'readonly',
+                               [ 'readonlyreason' => "Waiting for $numLagged lagged database(s)" ]
+                       );
+               }
+       }
+
+       /**
+        * Check asserts of the user's rights
+        * @param array $params
+        */
+       protected function checkAsserts( $params ) {
+               if ( isset( $params['assert'] ) ) {
+                       $user = $this->getUser();
+                       switch ( $params['assert'] ) {
+                               case 'user':
+                                       if ( $user->isAnon() ) {
+                                               $this->dieWithError( 'apierror-assertuserfailed' );
+                                       }
+                                       break;
+                               case 'bot':
+                                       if ( !$user->isAllowed( 'bot' ) ) {
+                                               $this->dieWithError( 'apierror-assertbotfailed' );
+                                       }
+                                       break;
+                       }
+               }
+               if ( isset( $params['assertuser'] ) ) {
+                       $assertUser = User::newFromName( $params['assertuser'], false );
+                       if ( !$assertUser || !$this->getUser()->equals( $assertUser ) ) {
+                               $this->dieWithError(
+                                       [ 'apierror-assertnameduserfailed', wfEscapeWikiText( $params['assertuser'] ) ]
+                               );
                        }
                }
        }
 
        /**
         * Check POST for external response and setup result printer
-        * @param $module ApiBase An Api module
-        * @param $params Array an array with the request parameters
+        * @param ApiBase $module An Api module
+        * @param array $params An array with the request parameters
         */
        protected function setupExternalResponse( $module, $params ) {
-               // Ignore mustBePosted() for internal calls
-               if ( $module->mustBePosted() && !$this->mRequest->wasPosted() ) {
-                       $this->dieUsageMsg( array( 'mustbeposted', $this->mAction ) );
+               $request = $this->getRequest();
+               if ( !$request->wasPosted() && $module->mustBePosted() ) {
+                       // Module requires POST. GET request might still be allowed
+                       // if $wgDebugApi is true, otherwise fail.
+                       $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $this->mAction ] );
                }
 
                // See if custom printer is used
@@ -636,8 +1542,12 @@ class ApiMain extends ApiBase {
                        $this->mPrinter = $this->createPrinterByName( $params['format'] );
                }
 
-               if ( $this->mPrinter->getNeedsRawData() ) {
-                       $this->getResult()->setRawMode();
+               if ( $request->getProtocol() === 'http' && (
+                       $request->getSession()->shouldForceHTTPS() ||
+                       ( $this->getUser()->isLoggedIn() &&
+                               $this->getUser()->requiresHTTPS() )
+               ) ) {
+                       $this->addDeprecation( 'apiwarn-deprecation-httpsexpected', 'https-expected' );
                }
        }
 
@@ -647,6 +1557,11 @@ class ApiMain extends ApiBase {
        protected function executeAction() {
                $params = $this->setupExecuteAction();
                $module = $this->setupModule();
+               $this->mModule = $module;
+
+               if ( !$this->mInternalMode ) {
+                       $this->setRequestExpectations( $module );
+               }
 
                $this->checkExecutePermissions( $module );
 
@@ -654,363 +1569,464 @@ class ApiMain extends ApiBase {
                        return;
                }
 
+               if ( !$this->checkConditionalRequestHeaders( $module ) ) {
+                       return;
+               }
+
                if ( !$this->mInternalMode ) {
                        $this->setupExternalResponse( $module, $params );
                }
 
+               $this->checkAsserts( $params );
+
                // Execute
-               $module->profileIn();
                $module->execute();
-               wfRunHooks( 'APIAfterExecute', array( &$module ) );
-               $module->profileOut();
+               Hooks::run( 'APIAfterExecute', [ &$module ] );
+
+               $this->reportUnusedParams();
 
                if ( !$this->mInternalMode ) {
+                       // append Debug information
+                       MWDebug::appendDebugInfoToApiResult( $this->getContext(), $this->getResult() );
+
                        // Print result data
-                       $this->printResult( false );
+                       $this->printResult();
                }
        }
 
        /**
-        * Print results using the current printer
+        * Set database connection, query, and write expectations given this module request
+        * @param ApiBase $module
         */
-       protected function printResult( $isError ) {
-               $this->getResult()->cleanUpUTF8();
-               $printer = $this->mPrinter;
-               $printer->profileIn();
+       protected function setRequestExpectations( ApiBase $module ) {
+               $limits = $this->getConfig()->get( 'TrxProfilerLimits' );
+               $trxProfiler = Profiler::instance()->getTransactionProfiler();
+               $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
+               if ( $this->getRequest()->hasSafeMethod() ) {
+                       $trxProfiler->setExpectations( $limits['GET'], __METHOD__ );
+               } elseif ( $this->getRequest()->wasPosted() && !$module->isWriteMode() ) {
+                       $trxProfiler->setExpectations( $limits['POST-nonwrite'], __METHOD__ );
+                       $this->getRequest()->markAsSafeRequest();
+               } else {
+                       $trxProfiler->setExpectations( $limits['POST'], __METHOD__ );
+               }
+       }
 
-               /**
-                * If the help message is requested in the default (xmlfm) format,
-                * tell the printer not to escape ampersands so that our links do
-                * not break.
-                */
-               $printer->setUnescapeAmps( ( $this->mAction == 'help' || $isError )
-                               && $printer->getFormat() == 'XML' && $printer->getIsHtml() );
+       /**
+        * Log the preceding request
+        * @param float $time Time in seconds
+        * @param Exception $e Exception caught while processing the request
+        */
+       protected function logRequest( $time, $e = null ) {
+               $request = $this->getRequest();
+               $logCtx = [
+                       'ts' => time(),
+                       'ip' => $request->getIP(),
+                       'userAgent' => $this->getUserAgent(),
+                       'wiki' => wfWikiID(),
+                       'timeSpentBackend' => (int)round( $time * 1000 ),
+                       'hadError' => $e !== null,
+                       'errorCodes' => [],
+                       'params' => [],
+               ];
+
+               if ( $e ) {
+                       foreach ( $this->errorMessagesFromException( $e ) as $msg ) {
+                               $logCtx['errorCodes'][] = $msg->getApiCode();
+                       }
+               }
 
-               $printer->initPrinter( $isError );
+               // Construct space separated message for 'api' log channel
+               $msg = "API {$request->getMethod()} " .
+                       wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) .
+                       " {$logCtx['ip']} " .
+                       "T={$logCtx['timeSpentBackend']}ms";
+
+               $sensitive = array_flip( $this->getSensitiveParams() );
+               foreach ( $this->getParamsUsed() as $name ) {
+                       $value = $request->getVal( $name );
+                       if ( $value === null ) {
+                               continue;
+                       }
 
-               $printer->execute();
-               $printer->closePrinter();
-               $printer->profileOut();
-       }
+                       if ( isset( $sensitive[$name] ) ) {
+                               $value = '[redacted]';
+                               $encValue = '[redacted]';
+                       } elseif ( strlen( $value ) > 256 ) {
+                               $value = substr( $value, 0, 256 );
+                               $encValue = $this->encodeRequestLogValue( $value ) . '[...]';
+                       } else {
+                               $encValue = $this->encodeRequestLogValue( $value );
+                       }
 
-       public function isReadMode() {
-               return false;
+                       $logCtx['params'][$name] = $value;
+                       $msg .= " {$name}={$encValue}";
+               }
+
+               wfDebugLog( 'api', $msg, 'private' );
+               // ApiAction channel is for structured data consumers
+               wfDebugLog( 'ApiAction', '', 'private', $logCtx );
        }
 
        /**
-        * See ApiBase for description.
+        * Encode a value in a format suitable for a space-separated log line.
+        * @param string $s
+        * @return string
         */
-       public function getAllowedParams() {
-               return array(
-                       'format' => array(
-                               ApiBase::PARAM_DFLT => ApiMain::API_DEFAULT_FORMAT,
-                               ApiBase::PARAM_TYPE => $this->mFormatNames
-                       ),
-                       'action' => array(
-                               ApiBase::PARAM_DFLT => 'help',
-                               ApiBase::PARAM_TYPE => $this->mModuleNames
-                       ),
-                       'version' => false,
-                       'maxlag'  => array(
-                               ApiBase::PARAM_TYPE => 'integer'
-                       ),
-                       'smaxage' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_DFLT => 0
-                       ),
-                       'maxage' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_DFLT => 0
-                       ),
-                       'requestid' => null,
-                       'servedby'  => false,
-               );
+       protected function encodeRequestLogValue( $s ) {
+               static $table;
+               if ( !$table ) {
+                       $chars = ';@$!*(),/:';
+                       $numChars = strlen( $chars );
+                       for ( $i = 0; $i < $numChars; $i++ ) {
+                               $table[rawurlencode( $chars[$i] )] = $chars[$i];
+                       }
+               }
+
+               return strtr( rawurlencode( $s ), $table );
        }
 
        /**
-        * See ApiBase for description.
+        * Get the request parameters used in the course of the preceding execute() request
+        * @return array
         */
-       public function getParamDescription() {
-               return array(
-                       'format' => 'The format of the output',
-                       'action' => 'What action you would like to perform. See below for module help',
-                       'version' => 'When showing help, include version for each module',
-                       'maxlag' => 'Maximum lag',
-                       'smaxage' => 'Set the s-maxage header to this many seconds. Errors are never cached',
-                       'maxage' => 'Set the max-age header to this many seconds. Errors are never cached',
-                       'requestid' => 'Request ID to distinguish requests. This will just be output back to you',
-                       'servedby' => 'Include the hostname that served the request in the results. Unconditionally shown on error',
-               );
+       protected function getParamsUsed() {
+               return array_keys( $this->mParamsUsed );
        }
 
        /**
-        * See ApiBase for description.
+        * Mark parameters as used
+        * @param string|string[] $params
         */
-       public function getDescription() {
-               return array(
-                       '',
-                       '',
-                       '******************************************************************************************',
-                       '**                                                                                      **',
-                       '**              This is an auto-generated MediaWiki API documentation page              **',
-                       '**                                                                                      **',
-                       '**                            Documentation and Examples:                               **',
-                       '**                         http://www.mediawiki.org/wiki/API                            **',
-                       '**                                                                                      **',
-                       '******************************************************************************************',
-                       '',
-                       'Status:                All features shown on this page should be working, but the API',
-                       '                       is still in active development, and  may change at any time.',
-                       '                       Make sure to monitor our mailing list for any updates',
-                       '',
-                       'Documentation:         http://www.mediawiki.org/wiki/API',
-                       'Mailing list:          http://lists.wikimedia.org/mailman/listinfo/mediawiki-api',
-                       'Api Announcements:     http://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce',
-                       'Bugs & Requests:       http://bugzilla.wikimedia.org/buglist.cgi?component=API&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&order=bugs.delta_ts',
-                       '',
-                       '',
-                       '',
-                       '',
-                       '',
-               );
-       }
-
-       public function getPossibleErrors() {
-               return array_merge( parent::getPossibleErrors(), array(
-                       array( 'readonlytext' ),
-                       array( 'code' => 'unknown_format', 'info' => 'Unrecognized format: format' ),
-                       array( 'code' => 'unknown_action', 'info' => 'The API requires a valid action parameter' ),
-                       array( 'code' => 'maxlag', 'info' => 'Waiting for host: x seconds lagged' ),
-                       array( 'code' => 'maxlag', 'info' => 'Waiting for a database server: x seconds lagged' ),
-               ) );
+       public function markParamsUsed( $params ) {
+               $this->mParamsUsed += array_fill_keys( (array)$params, true );
        }
 
        /**
-        * Returns an array of strings with credits for the API
+        * Get the request parameters that should be considered sensitive
+        * @since 1.29
+        * @return array
         */
-       protected function getCredits() {
-               return array(
-                       'API developers:',
-                       '    Roan Kattouw <Firstname>.<Lastname>@home.nl (lead developer Sep 2007-present)',
-                       '    Victor Vasiliev - vasilvv at gee mail dot com',
-                       '    Bryan Tong Minh - bryan . tongminh @ gmail . com',
-                       '    Sam Reed - sam @ reedyboy . net',
-                       '    Yuri Astrakhan <Firstname><Lastname>@gmail.com (creator, lead developer Sep 2006-Sep 2007)',
-                       '',
-                       'Please send your comments, suggestions and questions to mediawiki-api@lists.wikimedia.org',
-                       'or file a bug report at http://bugzilla.wikimedia.org/'
-               );
+       protected function getSensitiveParams() {
+               return array_keys( $this->mParamsSensitive );
        }
+
        /**
-        * Sets whether the pretty-printer should format *bold* and $italics$
+        * Mark parameters as sensitive
+        * @since 1.29
+        * @param string|string[] $params
         */
-       public function setHelp( $help = true ) {
-               $this->mPrinter->setHelp( $help );
+       public function markParamsSensitive( $params ) {
+               $this->mParamsSensitive += array_fill_keys( (array)$params, true );
        }
 
        /**
-        * Override the parent to generate help messages for all available modules.
+        * Get a request value, and register the fact that it was used, for logging.
+        * @param string $name
+        * @param mixed $default
+        * @return mixed
         */
-       public function makeHelpMsg() {
-               global $wgMemc, $wgAPICacheHelp, $wgAPICacheHelpTimeout;
-               $this->setHelp();
-               // Get help text from cache if present
-               $key = wfMemcKey( 'apihelp', $this->getModuleName(),
-                       SpecialVersion::getVersion( 'nodb' ) .
-                       $this->getMain()->getShowVersions() );
-               if ( $wgAPICacheHelp ) {
-                       $cached = $wgMemc->get( $key );
-                       if ( $cached ) {
-                               return $cached;
+       public function getVal( $name, $default = null ) {
+               $this->mParamsUsed[$name] = true;
+
+               $ret = $this->getRequest()->getVal( $name );
+               if ( $ret === null ) {
+                       if ( $this->getRequest()->getArray( $name ) !== null ) {
+                               // See T12262 for why we don't just implode( '|', ... ) the
+                               // array.
+                               $this->addWarning( [ 'apiwarn-unsupportedarray', $name ] );
                        }
+                       $ret = $default;
                }
-               $retval = $this->reallyMakeHelpMsg();
-               if ( $wgAPICacheHelp ) {
-                       $wgMemc->set( $key, $retval, $wgAPICacheHelpTimeout );
-               }
-               return $retval;
+               return $ret;
        }
 
-       public function reallyMakeHelpMsg() {
-               $this->setHelp();
+       /**
+        * Get a boolean request value, and register the fact that the parameter
+        * was used, for logging.
+        * @param string $name
+        * @return bool
+        */
+       public function getCheck( $name ) {
+               return $this->getVal( $name, null ) !== null;
+       }
 
-               // Use parent to make default message for the main module
-               $msg = parent::makeHelpMsg();
+       /**
+        * Get a request upload, and register the fact that it was used, for logging.
+        *
+        * @since 1.21
+        * @param string $name Parameter name
+        * @return WebRequestUpload
+        */
+       public function getUpload( $name ) {
+               $this->mParamsUsed[$name] = true;
 
-               $astriks = str_repeat( '*** ', 10 );
-               $msg .= "\n\n$astriks Modules  $astriks\n\n";
-               foreach ( array_keys( $this->mModules ) as $moduleName ) {
-                       $module = new $this->mModules[$moduleName] ( $this, $moduleName );
-                       $msg .= self::makeHelpMsgHeader( $module, 'action' );
-                       $msg2 = $module->makeHelpMsg();
-                       if ( $msg2 !== false ) {
-                               $msg .= $msg2;
-                       }
-                       $msg .= "\n";
-               }
+               return $this->getRequest()->getUpload( $name );
+       }
 
-               $msg .= "\n$astriks Permissions $astriks\n\n";
-               foreach ( self::$mRights as $right => $rightMsg ) {
-                       $groups = User::getGroupsWithPermission( $right );
-                       $msg .= "* " . $right . " *\n  " . wfMsgReplaceArgs( $rightMsg[ 'msg' ], $rightMsg[ 'params' ] ) .
-                                               "\nGranted to:\n  " . str_replace( '*', 'all', implode( ', ', $groups ) ) . "\n\n";
+       /**
+        * Report unused parameters, so the client gets a hint in case it gave us parameters we don't know,
+        * for example in case of spelling mistakes or a missing 'g' prefix for generators.
+        */
+       protected function reportUnusedParams() {
+               $paramsUsed = $this->getParamsUsed();
+               $allParams = $this->getRequest()->getValueNames();
 
+               if ( !$this->mInternalMode ) {
+                       // Printer has not yet executed; don't warn that its parameters are unused
+                       $printerParams = $this->mPrinter->encodeParamName(
+                               array_keys( $this->mPrinter->getFinalParams() ?: [] )
+                       );
+                       $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams );
+               } else {
+                       $unusedParams = array_diff( $allParams, $paramsUsed );
                }
 
-               $msg .= "\n$astriks Formats  $astriks\n\n";
-               foreach ( array_keys( $this->mFormats ) as $formatName ) {
-                       $module = $this->createPrinterByName( $formatName );
-                       $msg .= self::makeHelpMsgHeader( $module, 'format' );
-                       $msg2 = $module->makeHelpMsg();
-                       if ( $msg2 !== false ) {
-                               $msg .= $msg2;
-                       }
-                       $msg .= "\n";
+               if ( count( $unusedParams ) ) {
+                       $this->addWarning( [
+                               'apierror-unrecognizedparams',
+                               Message::listParam( array_map( 'wfEscapeWikiText', $unusedParams ), 'comma' ),
+                               count( $unusedParams )
+                       ] );
                }
-
-               $msg .= "\n*** Credits: ***\n   " . implode( "\n   ", $this->getCredits() ) . "\n";
-
-               return $msg;
        }
 
-       public static function makeHelpMsgHeader( $module, $paramName ) {
-               $modulePrefix = $module->getModulePrefix();
-               if ( strval( $modulePrefix ) !== '' ) {
-                       $modulePrefix = "($modulePrefix) ";
+       /**
+        * Print results using the current printer
+        *
+        * @param int $httpCode HTTP status code, or 0 to not change
+        */
+       protected function printResult( $httpCode = 0 ) {
+               if ( $this->getConfig()->get( 'DebugAPI' ) !== false ) {
+                       $this->addWarning( 'apiwarn-wgDebugAPI' );
                }
 
-               return "* $paramName={$module->getModuleName()} $modulePrefix*";
+               $printer = $this->mPrinter;
+               $printer->initPrinter( false );
+               if ( $httpCode ) {
+                       $printer->setHttpStatus( $httpCode );
+               }
+               $printer->execute();
+               $printer->closePrinter();
        }
 
-       private $mIsBot = null;
-       private $mIsSysop = null;
-       private $mCanApiHighLimits = null;
-
        /**
-        * Returns true if the currently logged in user is a bot, false otherwise
-        * OBSOLETE, use canApiHighLimits() instead
+        * @return bool
         */
-       public function isBot() {
-               if ( !isset( $this->mIsBot ) ) {
-                       global $wgUser;
-                       $this->mIsBot = $wgUser->isAllowed( 'bot' );
-               }
-               return $this->mIsBot;
+       public function isReadMode() {
+               return false;
        }
 
        /**
-        * Similar to isBot(), this method returns true if the logged in user is
-        * a sysop, and false if not.
-        * OBSOLETE, use canApiHighLimits() instead
+        * See ApiBase for description.
+        *
+        * @return array
         */
-       public function isSysop() {
-               if ( !isset( $this->mIsSysop ) ) {
-                       global $wgUser;
-                       $this->mIsSysop = in_array( 'sysop', $wgUser->getGroups() );
+       public function getAllowedParams() {
+               return [
+                       'action' => [
+                               ApiBase::PARAM_DFLT => 'help',
+                               ApiBase::PARAM_TYPE => 'submodule',
+                       ],
+                       'format' => [
+                               ApiBase::PARAM_DFLT => self::API_DEFAULT_FORMAT,
+                               ApiBase::PARAM_TYPE => 'submodule',
+                       ],
+                       'maxlag' => [
+                               ApiBase::PARAM_TYPE => 'integer'
+                       ],
+                       'smaxage' => [
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_DFLT => 0
+                       ],
+                       'maxage' => [
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_DFLT => 0
+                       ],
+                       'assert' => [
+                               ApiBase::PARAM_TYPE => [ 'user', 'bot' ]
+                       ],
+                       'assertuser' => [
+                               ApiBase::PARAM_TYPE => 'user',
+                       ],
+                       'requestid' => null,
+                       'servedby' => false,
+                       'curtimestamp' => false,
+                       'responselanginfo' => false,
+                       'origin' => null,
+                       'uselang' => [
+                               ApiBase::PARAM_DFLT => self::API_DEFAULT_USELANG,
+                       ],
+                       'errorformat' => [
+                               ApiBase::PARAM_TYPE => [ 'plaintext', 'wikitext', 'html', 'raw', 'none', 'bc' ],
+                               ApiBase::PARAM_DFLT => 'bc',
+                       ],
+                       'errorlang' => [
+                               ApiBase::PARAM_DFLT => 'uselang',
+                       ],
+                       'errorsuselocal' => [
+                               ApiBase::PARAM_DFLT => false,
+                       ],
+               ];
+       }
+
+       /** @inheritDoc */
+       protected function getExamplesMessages() {
+               return [
+                       'action=help'
+                               => 'apihelp-help-example-main',
+                       'action=help&recursivesubmodules=1'
+                               => 'apihelp-help-example-recursive',
+               ];
+       }
+
+       public function modifyHelp( array &$help, array $options, array &$tocData ) {
+               // Wish PHP had an "array_insert_before". Instead, we have to manually
+               // reindex the array to get 'permissions' in the right place.
+               $oldHelp = $help;
+               $help = [];
+               foreach ( $oldHelp as $k => $v ) {
+                       if ( $k === 'submodules' ) {
+                               $help['permissions'] = '';
+                       }
+                       $help[$k] = $v;
                }
+               $help['datatypes'] = '';
+               $help['credits'] = '';
+
+               // Fill 'permissions'
+               $help['permissions'] .= Html::openElement( 'div',
+                       [ 'class' => 'apihelp-block apihelp-permissions' ] );
+               $m = $this->msg( 'api-help-permissions' );
+               if ( !$m->isDisabled() ) {
+                       $help['permissions'] .= Html::rawElement( 'div', [ 'class' => 'apihelp-block-head' ],
+                               $m->numParams( count( self::$mRights ) )->parse()
+                       );
+               }
+               $help['permissions'] .= Html::openElement( 'dl' );
+               foreach ( self::$mRights as $right => $rightMsg ) {
+                       $help['permissions'] .= Html::element( 'dt', null, $right );
+
+                       $rightMsg = $this->msg( $rightMsg['msg'], $rightMsg['params'] )->parse();
+                       $help['permissions'] .= Html::rawElement( 'dd', null, $rightMsg );
 
-               return $this->mIsSysop;
+                       $groups = array_map( function ( $group ) {
+                               return $group == '*' ? 'all' : $group;
+                       }, User::getGroupsWithPermission( $right ) );
+
+                       $help['permissions'] .= Html::rawElement( 'dd', null,
+                               $this->msg( 'api-help-permissions-granted-to' )
+                                       ->numParams( count( $groups ) )
+                                       ->params( Message::listParam( $groups ) )
+                                       ->parse()
+                       );
+               }
+               $help['permissions'] .= Html::closeElement( 'dl' );
+               $help['permissions'] .= Html::closeElement( 'div' );
+
+               // Fill 'datatypes' and 'credits', if applicable
+               if ( empty( $options['nolead'] ) ) {
+                       $level = $options['headerlevel'];
+                       $tocnumber = &$options['tocnumber'];
+
+                       $header = $this->msg( 'api-help-datatypes-header' )->parse();
+
+                       $id = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_PRIMARY );
+                       $idFallback = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_FALLBACK );
+                       $headline = Linker::makeHeadline( min( 6, $level ),
+                               ' class="apihelp-header">',
+                               $id,
+                               $header,
+                               '',
+                               $idFallback
+                       );
+                       // Ensure we have a sane anchor
+                       if ( $id !== 'main/datatypes' && $idFallback !== 'main/datatypes' ) {
+                               $headline = '<div id="main/datatypes"></div>' . $headline;
+                       }
+                       $help['datatypes'] .= $headline;
+                       $help['datatypes'] .= $this->msg( 'api-help-datatypes' )->parseAsBlock();
+                       if ( !isset( $tocData['main/datatypes'] ) ) {
+                               $tocnumber[$level]++;
+                               $tocData['main/datatypes'] = [
+                                       'toclevel' => count( $tocnumber ),
+                                       'level' => $level,
+                                       'anchor' => 'main/datatypes',
+                                       'line' => $header,
+                                       'number' => implode( '.', $tocnumber ),
+                                       'index' => false,
+                               ];
+                       }
+
+                       $header = $this->msg( 'api-credits-header' )->parse();
+                       $id = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_PRIMARY );
+                       $idFallback = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_FALLBACK );
+                       $headline = Linker::makeHeadline( min( 6, $level ),
+                               ' class="apihelp-header">',
+                               $id,
+                               $header,
+                               '',
+                               $idFallback
+                       );
+                       // Ensure we have a sane anchor
+                       if ( $id !== 'main/credits' && $idFallback !== 'main/credits' ) {
+                               $headline = '<div id="main/credits"></div>' . $headline;
+                       }
+                       $help['credits'] .= $headline;
+                       $help['credits'] .= $this->msg( 'api-credits' )->useDatabase( false )->parseAsBlock();
+                       if ( !isset( $tocData['main/credits'] ) ) {
+                               $tocnumber[$level]++;
+                               $tocData['main/credits'] = [
+                                       'toclevel' => count( $tocnumber ),
+                                       'level' => $level,
+                                       'anchor' => 'main/credits',
+                                       'line' => $header,
+                                       'number' => implode( '.', $tocnumber ),
+                                       'index' => false,
+                               ];
+                       }
+               }
        }
 
+       private $mCanApiHighLimits = null;
+
        /**
         * Check whether the current user is allowed to use high limits
         * @return bool
         */
        public function canApiHighLimits() {
                if ( !isset( $this->mCanApiHighLimits ) ) {
-                       global $wgUser;
-                       $this->mCanApiHighLimits = $wgUser->isAllowed( 'apihighlimits' );
+                       $this->mCanApiHighLimits = $this->getUser()->isAllowed( 'apihighlimits' );
                }
 
                return $this->mCanApiHighLimits;
        }
 
        /**
-        * Check whether the user wants us to show version information in the API help
-        * @return bool
-        */
-       public function getShowVersions() {
-               return $this->mShowVersions;
-       }
-
-       /**
-        * Returns the version information of this file, plus it includes
-        * the versions for all files that are not callable proper API modules
+        * Overrides to return this instance's module manager.
+        * @return ApiModuleManager
         */
-       public function getVersion() {
-               $vers = array ();
-               $vers[] = 'MediaWiki: ' . SpecialVersion::getVersion() . "\n    http://svn.wikimedia.org/viewvc/mediawiki/trunk/phase3/";
-               $vers[] = __CLASS__ . ': $Id$';
-               $vers[] = ApiBase::getBaseVersion();
-               $vers[] = ApiFormatBase::getBaseVersion();
-               $vers[] = ApiQueryBase::getBaseVersion();
-               return $vers;
+       public function getModuleManager() {
+               return $this->mModuleMgr;
        }
 
        /**
-        * Add or overwrite a module in this ApiMain instance. Intended for use by extending
-        * classes who wish to add their own modules to their lexicon or override the
-        * behavior of inherent ones.
+        * Fetches the user agent used for this request
         *
-        * @param $mdlName String The identifier for this module.
-        * @param $mdlClass String The class where this module is implemented.
-        */
-       protected function addModule( $mdlName, $mdlClass ) {
-               $this->mModules[$mdlName] = $mdlClass;
-       }
-
-       /**
-        * Add or overwrite an output format for this ApiMain. Intended for use by extending
-        * classes who wish to add to or modify current formatters.
+        * The value will be the combination of the 'Api-User-Agent' header (if
+        * any) and the standard User-Agent header (if any).
         *
-        * @param $fmtName The identifier for this format.
-        * @param $fmtClass The class implementing this format.
-        */
-       protected function addFormat( $fmtName, $fmtClass ) {
-               $this->mFormats[$fmtName] = $fmtClass;
-       }
-
-       /**
-        * Get the array mapping module names to class names
+        * @return string
         */
-       function getModules() {
-               return $this->mModules;
+       public function getUserAgent() {
+               return trim(
+                       $this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' .
+                       $this->getRequest()->getHeader( 'User-agent' )
+               );
        }
 }
 
 /**
- * This exception will be thrown when dieUsage is called to stop module execution.
- * The exception handling code will print a help screen explaining how this API may be used.
- *
- * @ingroup API
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
  */
-class UsageException extends Exception {
-
-       private $mCodestr;
-       private $mExtraData;
-
-       public function __construct( $message, $codestr, $code = 0, $extradata = null ) {
-               parent::__construct( $message, $code );
-               $this->mCodestr = $codestr;
-               $this->mExtraData = $extradata;
-       }
-
-       public function getCodeString() {
-               return $this->mCodestr;
-       }
-
-       public function getMessageArray() {
-               $result = array(
-                       'code' => $this->mCodestr,
-                       'info' => $this->getMessage()
-               );
-               if ( is_array( $this->mExtraData ) ) {
-                       $result = array_merge( $result, $this->mExtraData );
-               }
-               return $result;
-       }
-
-       public function __toString() {
-               return "{$this->getCodeString()}: {$this->getMessage()}";
-       }
-}