Merge "HTMLMultiSelectField: Add 'dropdown' option for 'mw-chosen' behavior and document"
authorKrinkle <krinklemail@gmail.com>
Tue, 23 Aug 2016 22:16:27 +0000 (22:16 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 23 Aug 2016 22:16:27 +0000 (22:16 +0000)
68 files changed:
RELEASE-NOTES-1.28
autoload.php
composer.json
includes/Block.php
includes/DefaultSettings.php
includes/EditPage.php
includes/FileDeleteForm.php
includes/Html.php
includes/MediaWiki.php
includes/MovePage.php
includes/OutputPage.php
includes/Pingback.php
includes/Title.php
includes/api/ApiParse.php
includes/api/ApiQueryUsers.php
includes/api/ApiUpload.php
includes/collation/Collation.php
includes/collation/NumericUppercaseCollation.php [new file with mode: 0644]
includes/content/JsonContent.php
includes/content/TextContent.php
includes/content/WikitextContent.php
includes/db/CloneDatabase.php
includes/db/Database.php
includes/db/DatabaseMysqlBase.php
includes/db/loadbalancer/LoadBalancer.php
includes/deferred/DataUpdate.php
includes/deferred/EnqueueableDataUpdate.php [new file with mode: 0644]
includes/deferred/LinksUpdate.php
includes/deferred/SqlDataUpdate.php
includes/filebackend/lockmanager/DBLockManager.php
includes/filebackend/lockmanager/MySqlLockManager.php [new file with mode: 0644]
includes/filebackend/lockmanager/PostgreSqlLockManager.php [new file with mode: 0644]
includes/filebackend/lockmanager/RedisLockManager.php
includes/filerepo/file/LocalFile.php
includes/htmlform/HTMLForm.php
includes/htmlform/HTMLFormField.php
includes/jobqueue/JobQueueDB.php
includes/jobqueue/JobRunner.php
includes/jobqueue/utils/PurgeJobUtils.php [new file with mode: 0644]
includes/libs/virtualrest/VirtualRESTServiceClient.php
includes/objectcache/SqlBagOStuff.php
includes/page/WikiPage.php
includes/parser/Parser.php
includes/specialpage/AuthManagerSpecialPage.php
includes/specialpage/LoginSignupSpecialPage.php
includes/specials/SpecialChangeCredentials.php
includes/specials/SpecialMyLanguage.php
includes/specials/SpecialRunJobs.php
includes/user/User.php
includes/utils/BatchRowWriter.php
languages/Language.php
languages/i18n/en.json
languages/i18n/qqq.json
languages/messages/MessagesSk.php
maintenance/namespaceDupes.php
maintenance/validateRegistrationFile.php
resources/src/jquery/jquery.makeCollapsible.js
resources/src/mediawiki.ui/components/icons.less
resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js
resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less
resources/src/mediawiki/mediawiki.js
tests/parser/parserTests.txt
tests/phpunit/includes/content/TextContentTest.php
tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php
tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php
tests/phpunit/includes/user/UserTest.php
tests/phpunit/structure/ExtensionJsonValidationTest.php

index 5d88fbf..92ac869 100644 (file)
@@ -39,7 +39,8 @@ production.
 * (T141604) Extensions can now provide a better error message when their
   maintenance scripts are run without the extension being installed.
 * (T8948) Numeric sorting in categories is now supported by setting $wgCategoryCollation
-  to uca-default-u-kn or uca-<langcode>-u-kn. If migrating from another
+  to 'uca-default-u-kn' or 'uca-<langcode>-u-kn'. If you can't use UCA collations,
+  a 'numeric' collation is also available. If migrating from another
   collation, you will need to run the updateCollation.php maintenance script.
 
 === External library changes in 1.28 ===
@@ -52,6 +53,16 @@ production.
 ==== Removed and replaced external libraries ====
 
 === Bug fixes in 1.28 ===
+* (T137264) SECURITY: XSS in unclosed internal links
+* (T133147) SECURITY: Escape '<' and ']]>' in inline <style> blocks
+* (T133147) SECURITY: Require login to preview user CSS pages
+* (T132926) SECURITY: Do not allow undeleting a revision deleted file if it is
+  the top file
+* (T129738) SECURITY: Make $wgBlockDisablesLogin also restrict logged in
+  permissions
+* (T129738) SECURITY: Make blocks log users out if $wgBlockDisablesLogin is true
+* (T139670) Move 'UserGetRights' call before application of
+  Session::getAllowedUserRights()
 
 === Action API changes in 1.28 ===
 * Added 'maxarticlesize' property to action=query&meta=siteinfo which contains
@@ -72,6 +83,8 @@ production.
 === Action API internal changes in 1.28 ===
 * Added a new hook, 'ApiMakeParserOptions', to allow extensions to better
   interact with ApiParse and ApiExpandTemplates.
+* (T139565) SECURITY: API: Generate head items in the context of the given title
+* (T115333) SECURITY: Check read permission when loading page content in ApiParse
 
 === Languages updated in 1.28 ===
 
index e7671be..39102fd 100644 (file)
@@ -408,7 +408,7 @@ $wgAutoloadLocalClasses = [
        'EnhancedChangesList' => __DIR__ . '/includes/changes/EnhancedChangesList.php',
        'EnotifNotifyJob' => __DIR__ . '/includes/jobqueue/jobs/EnotifNotifyJob.php',
        'EnqueueJob' => __DIR__ . '/includes/jobqueue/jobs/EnqueueJob.php',
-       'EnqueueableDataUpdate' => __DIR__ . '/includes/deferred/DataUpdate.php',
+       'EnqueueableDataUpdate' => __DIR__ . '/includes/deferred/EnqueueableDataUpdate.php',
        'EraseArchivedFile' => __DIR__ . '/maintenance/eraseArchivedFile.php',
        'ErrorPageError' => __DIR__ . '/includes/exception/ErrorPageError.php',
        'EventRelayer' => __DIR__ . '/includes/libs/eventrelayer/EventRelayer.php',
@@ -953,7 +953,7 @@ $wgAutoloadLocalClasses = [
        'MwSql' => __DIR__ . '/maintenance/sql.php',
        'MySQLField' => __DIR__ . '/includes/db/DatabaseMysqlBase.php',
        'MySQLMasterPos' => __DIR__ . '/includes/db/DatabaseMysqlBase.php',
-       'MySqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/DBLockManager.php',
+       'MySqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/MySqlLockManager.php',
        'MysqlInstaller' => __DIR__ . '/includes/installer/MysqlInstaller.php',
        'MysqlUpdater' => __DIR__ . '/includes/installer/MysqlUpdater.php',
        'NaiveForeignTitleFactory' => __DIR__ . '/includes/title/NaiveForeignTitleFactory.php',
@@ -975,6 +975,7 @@ $wgAutoloadLocalClasses = [
        'NullLockManager' => __DIR__ . '/includes/filebackend/lockmanager/LockManager.php',
        'NullRepo' => __DIR__ . '/includes/filerepo/NullRepo.php',
        'NullStatsdDataFactory' => __DIR__ . '/includes/libs/stats/NullStatsdDataFactory.php',
+       'NumericUppercaseCollation' => __DIR__ . '/includes/collation/NumericUppercaseCollation.php',
        'OOUIHTMLForm' => __DIR__ . '/includes/htmlform/OOUIHTMLForm.php',
        'ORAField' => __DIR__ . '/includes/db/DatabaseOracle.php',
        'ORAResult' => __DIR__ . '/includes/db/DatabaseOracle.php',
@@ -1061,7 +1062,7 @@ $wgAutoloadLocalClasses = [
        'PopulateRecentChangesSource' => __DIR__ . '/maintenance/populateRecentChangesSource.php',
        'PopulateRevisionLength' => __DIR__ . '/maintenance/populateRevisionLength.php',
        'PopulateRevisionSha1' => __DIR__ . '/maintenance/populateRevisionSha1.php',
-       'PostgreSqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/DBLockManager.php',
+       'PostgreSqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/PostgreSqlLockManager.php',
        'PostgresBlob' => __DIR__ . '/includes/db/DatabasePostgres.php',
        'PostgresField' => __DIR__ . '/includes/db/DatabasePostgres.php',
        'PostgresInstaller' => __DIR__ . '/includes/installer/PostgresInstaller.php',
@@ -1096,6 +1097,7 @@ $wgAutoloadLocalClasses = [
        'PurgeAction' => __DIR__ . '/includes/actions/PurgeAction.php',
        'PurgeChangedFiles' => __DIR__ . '/maintenance/purgeChangedFiles.php',
        'PurgeChangedPages' => __DIR__ . '/maintenance/purgeChangedPages.php',
+       'PurgeJobUtils' => __DIR__ . '/includes/jobqueue/utils/PurgeJobUtils.php',
        'PurgeList' => __DIR__ . '/maintenance/purgeList.php',
        'PurgeOldText' => __DIR__ . '/maintenance/purgeOldText.php',
        'PurgeParserCache' => __DIR__ . '/maintenance/purgeParserCache.php',
index 956739d..2243b7c 100644 (file)
@@ -45,7 +45,7 @@
        },
        "require-dev": {
                "jakub-onderka/php-parallel-lint": "0.9.2",
-               "justinrainbow/json-schema": "~1.6",
+               "justinrainbow/json-schema": "~3.0",
                "mediawiki/mediawiki-codesniffer": "0.7.2",
                "monolog/monolog": "~1.18.2",
                "nikic/php-parser": "2.1.0",
index 93df004..79b31bb 100644 (file)
@@ -457,6 +457,7 @@ class Block {
         *      ('id' => block ID, 'autoIds' => array of autoblock IDs)
         */
        public function insert( $dbw = null ) {
+               global $wgBlockDisablesLogin;
                wfDebug( "Block::insert; timestamp {$this->mTimestamp}\n" );
 
                if ( $dbw === null ) {
@@ -499,6 +500,13 @@ class Block {
 
                if ( $affected ) {
                        $auto_ipd_ids = $this->doRetroactiveAutoblock();
+
+                       if ( $wgBlockDisablesLogin && $this->target instanceof User ) {
+                               // Change user login token to force them to be logged out.
+                               $this->target->setToken();
+                               $this->target->saveSettings();
+                       }
+
                        return [ 'id' => $this->mId, 'autoIds' => $auto_ipd_ids ];
                }
 
@@ -961,28 +969,40 @@ class Block {
 
        /**
         * Get/set whether the Block prevents a given action
-        * @param string $action
-        * @param bool|null $x
-        * @return bool
+        *
+        * @param string $action Action to check
+        * @param bool|null $x Value for set, or null to just get value
+        * @return bool|null Null for unrecognized rights.
         */
        public function prevents( $action, $x = null ) {
+               global $wgBlockDisablesLogin;
+               $res = null;
                switch ( $action ) {
                        case 'edit':
                                # For now... <evil laugh>
-                               return true;
-
+                               $res = true;
+                               break;
                        case 'createaccount':
-                               return wfSetVar( $this->mCreateAccount, $x );
-
+                               $res = wfSetVar( $this->mCreateAccount, $x );
+                               break;
                        case 'sendemail':
-                               return wfSetVar( $this->mBlockEmail, $x );
-
+                               $res = wfSetVar( $this->mBlockEmail, $x );
+                               break;
                        case 'editownusertalk':
-                               return wfSetVar( $this->mDisableUsertalk, $x );
-
-                       default:
-                               return null;
+                               $res = wfSetVar( $this->mDisableUsertalk, $x );
+                               break;
+                       case 'read':
+                               $res = false;
+                               break;
                }
+               if ( !$res && $wgBlockDisablesLogin ) {
+                       // If a block would disable login, then it should
+                       // prevent any action that all users cannot do
+                       $anon = new User;
+                       $res = $anon->isAllowed( $action ) ? $res : true;
+               }
+
+               return $res;
        }
 
        /**
index 4e08aa6..cad1f63 100644 (file)
@@ -685,7 +685,7 @@ $wgUseSharedUploads = false;
 /**
  * Full path on the web server where shared uploads can be found
  */
-$wgSharedUploadPath = "http://commons.wikimedia.org/shared/images";
+$wgSharedUploadPath = null;
 
 /**
  * Fetch commons image description pages and display them on the local wiki?
@@ -695,7 +695,7 @@ $wgFetchCommonsDescriptions = false;
 /**
  * Path on the file system where shared uploads can be found.
  */
-$wgSharedUploadDirectory = "/var/www/wiki3/images";
+$wgSharedUploadDirectory = null;
 
 /**
  * DB name with metadata about shared directory.
@@ -8033,9 +8033,13 @@ $wgJobRunRate = 1;
  * When $wgJobRunRate > 0, try to run jobs asynchronously, spawning a new process
  * to handle the job execution, instead of blocking the request until the job
  * execution finishes.
+ *
  * @since 1.23
  */
-$wgRunJobsAsync = true;
+$wgRunJobsAsync = (
+       !function_exists( 'register_postsend_function' ) &&
+       !function_exists( 'fastcgi_finish_request' )
+);
 
 /**
  * Number of rows to update per job
@@ -8342,7 +8346,7 @@ $wgEventRelayerConfig = [
  * PHP version, and chosen database backend. The Wikimedia Foundation shares this data with
  * MediaWiki developers to help guide future development efforts.
  *
- * For details about what data is sent, see: https://www.mediawiki.org/wiki/Pingback
+ * For details about what data is sent, see: https://www.mediawiki.org/wiki/Manual:$wgPingback
  *
  * @var bool
  * @since 1.28
index 9b862b9..7dedac4 100644 (file)
@@ -2914,6 +2914,9 @@ class EditPage {
                                        );
                                }
                                if ( $this->getTitle()->isSubpageOf( $wgUser->getUserPage() ) ) {
+                                       $wgOut->wrapWikiMsg( '<div class="mw-usercssjspublic">$1</div>',
+                                               $this->isCssSubpage ? 'usercssispublic' : 'userjsispublic'
+                                       );
                                        if ( $this->formtype !== 'preview' ) {
                                                if ( $this->isCssSubpage && $wgAllowUserCss ) {
                                                        $wgOut->wrapWikiMsg(
index 361058b..65638f2 100644 (file)
@@ -189,31 +189,24 @@ class FileDeleteForm {
                        );
                        $page = WikiPage::factory( $title );
                        $dbw = wfGetDB( DB_MASTER );
-                       try {
-                               $dbw->startAtomic( __METHOD__ );
-                               // delete the associated article first
-                               $error = '';
-                               $deleteStatus = $page->doDeleteArticleReal( $reason, $suppress, 0, false, $error, $user );
-                               // doDeleteArticleReal() returns a non-fatal error status if the page
-                               // or revision is missing, so check for isOK() rather than isGood()
-                               if ( $deleteStatus->isOK() ) {
-                                       $status = $file->delete( $reason, $suppress, $user );
-                                       if ( $status->isOK() ) {
-                                               $status->value = $deleteStatus->value; // log id
-                                               $dbw->endAtomic( __METHOD__ );
-                                       } else {
-                                               // Page deleted but file still there? rollback page delete
-                                               wfGetLBFactory()->rollbackMasterChanges( __METHOD__ );
-                                       }
-                               } else {
-                                       // Done; nothing changed
+                       $dbw->startAtomic( __METHOD__ );
+                       // delete the associated article first
+                       $error = '';
+                       $deleteStatus = $page->doDeleteArticleReal( $reason, $suppress, 0, false, $error, $user );
+                       // doDeleteArticleReal() returns a non-fatal error status if the page
+                       // or revision is missing, so check for isOK() rather than isGood()
+                       if ( $deleteStatus->isOK() ) {
+                               $status = $file->delete( $reason, $suppress, $user );
+                               if ( $status->isOK() ) {
+                                       $status->value = $deleteStatus->value; // log id
                                        $dbw->endAtomic( __METHOD__ );
+                               } else {
+                                       // Page deleted but file still there? rollback page delete
+                                       wfGetLBFactory()->rollbackMasterChanges( __METHOD__ );
                                }
-                       } catch ( Exception $e ) {
-                               // Rollback before returning to prevent UI from displaying
-                               // incorrect "View or restore N deleted edits?"
-                               $dbw->rollback( __METHOD__ );
-                               throw $e;
+                       } else {
+                               // Done; nothing changed
+                               $dbw->endAtomic( __METHOD__ );
                        }
                }
 
index 7cb75bb..8c01448 100644 (file)
@@ -627,6 +627,17 @@ class Html {
         * @return string Raw HTML
         */
        public static function inlineStyle( $contents, $media = 'all' ) {
+               // Don't escape '>' since that is used
+               // as direct child selector.
+               // Remember, in css, there is no "x" for hexadecimal escapes, and
+               // the space immediately after an escape sequence is swallowed.
+               $contents = strtr( $contents, [
+                       '<' => '\3C ',
+                       // CDATA end tag for good measure, but the main security
+                       // is from escaping the '<'.
+                       ']]>' => '\5D\5D\3E '
+               ] );
+
                if ( preg_match( '/[<&]/', $contents ) ) {
                        $contents = "/*<![CDATA[*/$contents/*]]>*/";
                }
index 7dac0ec..77ac76a 100644 (file)
@@ -803,10 +803,10 @@ class MediaWiki {
         */
        public function triggerJobs() {
                $jobRunRate = $this->config->get( 'JobRunRate' );
-               if ( $jobRunRate <= 0 || wfReadOnly() ) {
-                       return;
-               } elseif ( $this->getTitle()->isSpecial( 'RunJobs' ) ) {
+               if ( $this->getTitle()->isSpecial( 'RunJobs' ) ) {
                        return; // recursion guard
+               } elseif ( $jobRunRate <= 0 || wfReadOnly() ) {
+                       return;
                }
 
                if ( $jobRunRate < 1 ) {
@@ -843,7 +843,7 @@ class MediaWiki {
                        $query, $this->config->get( 'SecretKey' ) );
 
                $errno = $errstr = null;
-               $info = wfParseUrl( $this->config->get( 'Server' ) );
+               $info = wfParseUrl( $this->config->get( 'CanonicalServer' ) );
                MediaWiki\suppressWarnings();
                $host = $info['host'];
                $port = 80;
@@ -872,7 +872,8 @@ class MediaWiki {
                        return;
                }
 
-               $url = wfAppendQuery( wfScript( 'index' ), $query );
+               $special = SpecialPageFactory::getPage( 'RunJobs' );
+               $url = $special->getPageTitle()->getCanonicalURL( $query );
                $req = (
                        "POST $url HTTP/1.1\r\n" .
                        "Host: {$info['host']}\r\n" .
@@ -883,7 +884,7 @@ class MediaWiki {
                $runJobsLogger->info( "Running $n job(s) via '$url'" );
                // Send a cron API request to be performed in the background.
                // Give up if this takes too long to send (which should be rare).
-               stream_set_timeout( $sock, 1 );
+               stream_set_timeout( $sock, 2 );
                $bytes = fwrite( $sock, $req );
                if ( $bytes !== strlen( $req ) ) {
                        $runJobsLogger->error( "Failed to start cron API (socket write error)" );
index bc3305a..d17f234 100644 (file)
@@ -297,19 +297,25 @@ class MovePage {
 
                if ( $protected ) {
                        # Protect the redirect title as the title used to be...
-                       $dbw->insertSelect( 'page_restrictions', 'page_restrictions',
-                               [
-                                       'pr_page' => $redirid,
-                                       'pr_type' => 'pr_type',
-                                       'pr_level' => 'pr_level',
-                                       'pr_cascade' => 'pr_cascade',
-                                       'pr_user' => 'pr_user',
-                                       'pr_expiry' => 'pr_expiry'
-                               ],
+                       $res = $dbw->select(
+                               'page_restrictions',
+                               '*',
                                [ 'pr_page' => $pageid ],
                                __METHOD__,
-                               [ 'IGNORE' ]
+                               'FOR UPDATE'
                        );
+                       $rowsInsert = [];
+                       foreach ( $res as $row ) {
+                               $rowsInsert[] = [
+                                       'pr_page' => $redirid,
+                                       'pr_type' => $row->pr_type,
+                                       'pr_level' => $row->pr_level,
+                                       'pr_cascade' => $row->pr_cascade,
+                                       'pr_user' => $row->pr_user,
+                                       'pr_expiry' => $row->pr_expiry
+                               ];
+                       }
+                       $dbw->insert( 'page_restrictions', $rowsInsert, __METHOD__, [ 'IGNORE' ] );
 
                        // Build comment for log
                        $comment = wfMessage(
index eb3040c..c7499b1 100644 (file)
@@ -2852,7 +2852,6 @@ class OutputPage extends ContextSource {
 
        private function isUserJsPreview() {
                return $this->getConfig()->get( 'AllowUserJs' )
-                       && $this->getUser()->isLoggedIn()
                        && $this->getTitle()
                        && $this->getTitle()->isJsSubpage()
                        && $this->userCanPreview();
@@ -2860,7 +2859,6 @@ class OutputPage extends ContextSource {
 
        private function isUserCssPreview() {
                return $this->getConfig()->get( 'AllowUserCss' )
-                       && $this->getUser()->isLoggedIn()
                        && $this->getTitle()
                        && $this->getTitle()->isCssSubpage()
                        && $this->userCanPreview();
@@ -3097,6 +3095,11 @@ class OutputPage extends ContextSource {
                }
 
                $user = $this->getUser();
+
+               if ( !$user->isLoggedIn() ) {
+                       // Anons have predictable edit tokens
+                       return false;
+               }
                if ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
                        return false;
                }
index dd68102..0039d2b 100644 (file)
@@ -117,6 +117,9 @@ class Pingback {
         *
         * This is public so we can display it in the installer
         *
+        * Developers: If you're adding a new piece of data to this, please ensure
+        * that you update https://www.mediawiki.org/wiki/Manual:$wgPingback
+        *
         * @return array
         */
        public function getSystemInfo() {
index ed445cc..2021e0a 100644 (file)
@@ -2271,13 +2271,17 @@ class Title implements LinkTarget {
         * @return array List of errors
         */
        private function checkUserBlock( $action, $user, $errors, $rigor, $short ) {
+               global $wgEmailConfirmToEdit, $wgBlockDisablesLogin;
                // Account creation blocks handled at userlogin.
                // Unblocking handled in SpecialUnblock
                if ( $rigor === 'quick' || in_array( $action, [ 'createaccount', 'unblock' ] ) ) {
                        return $errors;
                }
 
-               global $wgEmailConfirmToEdit;
+               // Optimize for a very common case
+               if ( $action === 'read' && !$wgBlockDisablesLogin ) {
+                       return $errors;
+               }
 
                if ( $wgEmailConfirmToEdit && !$user->isEmailConfirmed() ) {
                        $errors[] = [ 'confirmedittext' ];
@@ -2434,6 +2438,7 @@ class Title implements LinkTarget {
                        $checks = [
                                'checkPermissionHooks',
                                'checkReadPermissions',
+                               'checkUserBlock', // for wgBlockDisablesLogin
                        ];
                # Don't call checkSpecialsAndNSPermissions or checkCSSandJSPermissions
                # here as it will lead to duplicate error messages. This is okay to do
index 35fad4a..83b5d93 100644 (file)
@@ -36,6 +36,12 @@ class ApiParse extends ApiBase {
        /** @var Content $pstContent */
        private $pstContent = null;
 
+       private function checkReadPermissions( Title $title ) {
+               if ( !$title->userCan( 'read', $this->getUser() ) ) {
+                       $this->dieUsage( "You don't have permission to view this page", 'permissiondenied' );
+               }
+       }
+
        public function execute() {
                // The data is hot but user-dependent, like page views, so we set vary cookies
                $this->getMain()->setCacheMode( 'anon-public-user-private' );
@@ -102,6 +108,8 @@ class ApiParse extends ApiBase {
                                if ( !$rev ) {
                                        $this->dieUsage( "There is no revision ID $oldid", 'missingrev' );
                                }
+
+                               $this->checkReadPermissions( $rev->getTitle() );
                                if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
                                        $this->dieUsage( "You don't have permission to view deleted revisions", 'permissiondenied' );
                                }
@@ -161,6 +169,8 @@ class ApiParse extends ApiBase {
                                if ( !$titleObj || !$titleObj->exists() ) {
                                        $this->dieUsage( "The page you specified doesn't exist", 'missingtitle' );
                                }
+
+                               $this->checkReadPermissions( $titleObj );
                                $wgTitle = $titleObj;
 
                                if ( isset( $prop['revid'] ) ) {
index cfc1e46..9b45b91 100644 (file)
@@ -328,7 +328,6 @@ class ApiQueryUsers extends ApiQueryBase {
                        ],
                        'attachedwiki' => null,
                        'users' => [
-                               ApiBase::PARAM_TYPE => 'user',
                                ApiBase::PARAM_ISMULTI => true
                        ],
                        'token' => [
index ac817ba..f7ce552 100644 (file)
@@ -405,6 +405,7 @@ class ApiUpload extends ApiBase {
                        $code = $overrideCode;
                }
                if ( $moreExtraData ) {
+                       $extraData = $extraData ?: [];
                        $extraData += $moreExtraData;
                }
                $this->dieUsage( $msg, $code, 0, $extraData );
index fe254af..881c8c2 100644 (file)
@@ -49,6 +49,8 @@ abstract class Collation {
                switch ( $collationName ) {
                        case 'uppercase':
                                return new UppercaseCollation;
+                       case 'numeric':
+                               return new NumericUppercaseCollation;
                        case 'identity':
                                return new IdentityCollation;
                        case 'uca-default':
diff --git a/includes/collation/NumericUppercaseCollation.php b/includes/collation/NumericUppercaseCollation.php
new file mode 100644 (file)
index 0000000..4bf2f73
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Collation that orders text with numbers "naturally", so that 'Foo 1' < 'Foo 2' < 'Foo 12'.
+ *
+ * Note that this only works in terms of sequences of digits, and the behavior for decimal fractions
+ * or pretty-formatted numbers may be unexpected.
+ *
+ * @since 1.28
+ */
+class NumericUppercaseCollation extends UppercaseCollation {
+       public function getSortKey( $string ) {
+               $sortkey = parent::getSortKey( $string );
+
+               // For each sequence of digits, insert the digit '0' and then the length of the sequence
+               // (encoded in two bytes) before it. That's all folks, it sorts correctly now! The '0' ensures
+               // correct position (where digits would normally sort), then the length will be compared putting
+               // shorter numbers before longer ones; if identical, then the characters will be compared, which
+               // generates the correct results for numbers of equal length.
+               $sortkey = preg_replace_callback( '/\d+/', function ( $matches ) {
+                       $len = strlen( $matches[0] );
+                       // This allows sequences of up to 65536 numeric characters to be handled correctly. One byte
+                       // would allow only for 256, which doesn't feel future-proof.
+                       $prefix = chr( floor( $len / 256 ) ) . chr( $len % 256 );
+                       return '0' . $prefix . $matches[0];
+               }, $sortkey );
+
+               return $sortkey;
+       }
+
+       public function getFirstLetter( $string ) {
+               if ( preg_match( '/^\d/', $string ) ) {
+                       // Note that we pass 0 and 9 as normal params, not numParams(). This only works for 0-9
+                       // and not localised digits, so we don't want them to be converted.
+                       return wfMessage( 'category-header-numerals' )->params( 0, 9 )->text();
+               } else {
+                       return parent::getFirstLetter( $string );
+               }
+       }
+}
index 40d9277..14c8182 100644 (file)
@@ -86,7 +86,7 @@ class JsonContent extends TextContent {
                        return $this;
                }
 
-               return new static( $this->beautifyJSON() );
+               return new static( self::normalizeLineEndings( $this->beautifyJSON() ) );
        }
 
        /**
index de650b9..7bb4def 100644 (file)
@@ -147,9 +147,28 @@ class TextContent extends AbstractContent {
                }
        }
 
+       /**
+        * Do a "\r\n" -> "\n" and "\r" -> "\n" transformation
+        * as well as trim trailing whitespace
+        *
+        * This was formerly part of Parser::preSaveTransform, but
+        * for non-wikitext content models they probably still want
+        * to normalize line endings without all of the other PST
+        * changes.
+        *
+        * @since 1.28
+        * @param $text
+        * @return string
+        */
+       public static function normalizeLineEndings( $text ) {
+               return str_replace( [ "\r\n", "\r" ], "\n", rtrim( $text ) );
+       }
+
        /**
         * Returns a Content object with pre-save transformations applied.
-        * This implementation just trims trailing whitespace and normalizes newlines.
+        *
+        * At a minimum, subclasses should make sure to call TextContent::normalizeLineEndings()
+        * either directly or part of Parser::preSaveTransform().
         *
         * @param Title $title
         * @param User $user
@@ -159,8 +178,7 @@ class TextContent extends AbstractContent {
         */
        public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
                $text = $this->getNativeData();
-               $pst = rtrim( $text );
-               $pst = str_replace( [ "\r\n", "\r" ], "\n", $pst );
+               $pst = self::normalizeLineEndings( $text );
 
                return ( $text === $pst ) ? $this : new static( $pst, $this->getModel() );
        }
index a63819d..9296728 100644 (file)
@@ -138,7 +138,6 @@ class WikitextContent extends TextContent {
 
                $text = $this->getNativeData();
                $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts );
-               rtrim( $pst );
 
                return ( $text === $pst ) ? $this : new static( $pst );
        }
index 3cd09e2..577c98d 100644 (file)
@@ -129,25 +129,11 @@ class CloneDatabase {
         */
        public static function changePrefix( $prefix ) {
                global $wgDBprefix;
-               wfGetLBFactory()->forEachLB( [ 'CloneDatabase', 'changeLBPrefix' ], [ $prefix ] );
+               wfGetLBFactory()->forEachLB( function( $lb ) use ( $prefix ) {
+                       $lb->forEachOpenConnection( function ( $db ) use ( $prefix ) {
+                               $db->tablePrefix( $prefix );
+                       } );
+               } );
                $wgDBprefix = $prefix;
        }
-
-       /**
-        * @param LoadBalancer $lb
-        * @param string $prefix
-        * @return void
-        */
-       public static function changeLBPrefix( $lb, $prefix ) {
-               $lb->forEachOpenConnection( [ 'CloneDatabase', 'changeDBPrefix' ], [ $prefix ] );
-       }
-
-       /**
-        * @param DatabaseBase $db
-        * @param string $prefix
-        * @return void
-        */
-       public static function changeDBPrefix( $db, $prefix ) {
-               $db->tablePrefix( $prefix );
-       }
 }
index 186a87b..6ddc9f7 100644 (file)
 abstract class DatabaseBase implements IDatabase {
        /** Number of times to re-try an operation in case of deadlock */
        const DEADLOCK_TRIES = 4;
-
        /** Minimum time to wait before retry, in microseconds */
        const DEADLOCK_DELAY_MIN = 500000;
-
        /** Maximum time to wait before retry */
        const DEADLOCK_DELAY_MAX = 1500000;
 
+       /** How long before it is worth doing a dummy query to test the connection */
+       const PING_TTL = 1.0;
+
+       /** @var string SQL query */
        protected $mLastQuery = '';
+       /** @var bool */
        protected $mDoneWrites = false;
+       /** @var string|bool */
        protected $mPHPError = false;
-
-       protected $mServer, $mUser, $mPassword, $mDBname;
+       /** @var string */
+       protected $mServer;
+       /** @var string */
+       protected $mUser;
+       /** @var string */
+       protected $mPassword;
+       /** @var string */
+       protected $mDBname;
 
        /** @var BagOStuff APC cache */
        protected $srvCache;
 
        /** @var resource Database connection */
        protected $mConn = null;
+       /** @var bool */
        protected $mOpened = false;
 
        /** @var array[] List of (callable, method name) */
@@ -61,20 +72,27 @@ abstract class DatabaseBase implements IDatabase {
        /** @var bool Whether to suppress triggering of post-commit callbacks */
        protected $suppressPostCommitCallbacks = false;
 
+       /** @var string */
        protected $mTablePrefix;
+       /** @var string */
        protected $mSchema;
+       /** @var integer */
        protected $mFlags;
+       /** @var bool */
        protected $mForeign;
+       /** @var array */
        protected $mLBInfo = [];
+       /** @var bool|null */
        protected $mDefaultBigSelects = null;
+       /** @var array|bool */
        protected $mSchemaVars = false;
        /** @var array */
        protected $mSessionVars = [];
-
+       /** @var array|null */
        protected $preparedArgs;
-
+       /** @var string|bool|null Stashed value of html_errors INI setting */
        protected $htmlErrors;
-
+       /** @var string */
        protected $delimiter = ';';
 
        /**
@@ -177,6 +195,9 @@ abstract class DatabaseBase implements IDatabase {
         */
        protected $allViews = null;
 
+       /** @var float UNIX timestamp */
+       protected $lastPing = 0.0;
+
        /** @var TransactionProfiler */
        protected $trxProfiler;
 
@@ -786,8 +807,8 @@ abstract class DatabaseBase implements IDatabase {
                $priorWritesPending = $this->writesOrCallbacksPending();
                $this->mLastQuery = $sql;
 
-               $isWriteQuery = $this->isWriteQuery( $sql );
-               if ( $isWriteQuery ) {
+               $isWrite = $this->isWriteQuery( $sql );
+               if ( $isWrite ) {
                        $reason = $this->getReadOnlyReason();
                        if ( $reason !== false ) {
                                throw new DBReadOnlyError( $this, "Database is read-only: $reason" );
@@ -820,31 +841,12 @@ abstract class DatabaseBase implements IDatabase {
                }
 
                # Keep track of whether the transaction has write queries pending
-               if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWriteQuery ) {
+               if ( $this->mTrxLevel && !$this->mTrxDoneWrites && $isWrite ) {
                        $this->mTrxDoneWrites = true;
                        $this->getTransactionProfiler()->transactionWritingIn(
                                $this->mServer, $this->mDBname, $this->mTrxShortId );
                }
 
-               $isMaster = !is_null( $this->getLBInfo( 'master' ) );
-               # generalizeSQL will probably cut down the query to reasonable
-               # logging size most of the time. The substr is really just a sanity check.
-               if ( $isMaster ) {
-                       $queryProf = 'query-m: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
-                       $totalProf = 'DatabaseBase::query-master';
-               } else {
-                       $queryProf = 'query: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
-                       $totalProf = 'DatabaseBase::query';
-               }
-               # Include query transaction state
-               $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
-
-               $profiler = Profiler::instance();
-               if ( !$profiler instanceof ProfilerStub ) {
-                       $totalProfSection = $profiler->scopedProfileIn( $totalProf );
-                       $queryProfSection = $profiler->scopedProfileIn( $queryProf );
-               }
-
                if ( $this->debug() ) {
                        wfDebugLog( 'queries', sprintf( "%s: %s", $this->mDBname, $commentedSql ) );
                }
@@ -852,15 +854,8 @@ abstract class DatabaseBase implements IDatabase {
                # Avoid fatals if close() was called
                $this->assertOpen();
 
-               # Do the query and handle errors
-               $startTime = microtime( true );
-               $ret = $this->doQuery( $commentedSql );
-               $queryRuntime = microtime( true ) - $startTime;
-               # Log the query time and feed it into the DB trx profiler
-               $this->getTransactionProfiler()->recordQueryCompletion(
-                       $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
-
-               MWDebug::query( $sql, $fname, $isMaster, $queryRuntime );
+               # Send the query to the server
+               $ret = $this->doProfiledQuery( $sql, $commentedSql, $isWrite, $fname );
 
                # Try reconnecting if the connection was lost
                if ( false === $ret && $this->wasErrorReissuable() ) {
@@ -881,12 +876,7 @@ abstract class DatabaseBase implements IDatabase {
                                        $this->reportQueryError( $lastError, $lastErrno, $sql, $fname );
                                } else {
                                        # Should be safe to silently retry the query
-                                       $startTime = microtime( true );
-                                       $ret = $this->doQuery( $commentedSql );
-                                       $queryRuntime = microtime( true ) - $startTime;
-                                       # Log the query time and feed it into the DB trx profiler
-                                       $this->getTransactionProfiler()->recordQueryCompletion(
-                                               $queryProf, $startTime, $isWriteQuery, $this->affectedRows() );
+                                       $ret = $this->doProfiledQuery( $sql, $commentedSql, $isWrite, $fname );
                                }
                        } else {
                                wfDebug( "Failed\n" );
@@ -911,16 +901,47 @@ abstract class DatabaseBase implements IDatabase {
 
                $res = $this->resultObject( $ret );
 
-               // Destroy profile sections in the opposite order to their creation
-               ScopedCallback::consume( $queryProfSection );
-               ScopedCallback::consume( $totalProfSection );
+               return $res;
+       }
 
-               if ( $isWriteQuery && $this->mTrxLevel ) {
-                       $this->mTrxWriteDuration += $queryRuntime;
-                       $this->mTrxWriteCallers[] = $fname;
+       private function doProfiledQuery( $sql, $commentedSql, $isWrite, $fname ) {
+               $isMaster = !is_null( $this->getLBInfo( 'master' ) );
+               # generalizeSQL() will probably cut down the query to reasonable
+               # logging size most of the time. The substr is really just a sanity check.
+               if ( $isMaster ) {
+                       $queryProf = 'query-m: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
+               } else {
+                       $queryProf = 'query: ' . substr( DatabaseBase::generalizeSQL( $sql ), 0, 255 );
                }
 
-               return $res;
+               # Include query transaction state
+               $queryProf .= $this->mTrxShortId ? " [TRX#{$this->mTrxShortId}]" : "";
+
+               $profiler = Profiler::instance();
+               if ( !( $profiler instanceof ProfilerStub ) ) {
+                       $queryProfSection = $profiler->scopedProfileIn( $queryProf );
+               }
+
+               $startTime = microtime( true );
+               $ret = $this->doQuery( $commentedSql );
+               $queryRuntime = microtime( true ) - $startTime;
+
+               unset( $queryProfSection ); // profile out (if set)
+
+               if ( $ret !== false ) {
+                       $this->lastPing = $startTime;
+                       if ( $isWrite && $this->mTrxLevel ) {
+                               $this->mTrxWriteDuration += $queryRuntime;
+                               $this->mTrxWriteCallers[] = $fname;
+                       }
+               }
+
+               $this->getTransactionProfiler()->recordQueryCompletion(
+                       $queryProf, $startTime, $isWrite, $this->affectedRows()
+               );
+               MWDebug::query( $sql, $fname, $isMaster, $queryRuntime );
+
+               return $ret;
        }
 
        private function canRecoverFromDisconnect( $sql, $priorWritesPending ) {
@@ -2926,6 +2947,9 @@ abstract class DatabaseBase implements IDatabase {
        }
 
        public function ping() {
+               if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) {
+                       return true;
+               }
                try {
                        // This will reconnect if possible, or error out if not
                        $this->query( "SELECT 1 AS ping", __METHOD__ );
@@ -2939,8 +2963,18 @@ abstract class DatabaseBase implements IDatabase {
         * @return bool
         */
        protected function reconnect() {
-               # Stub. Not essential to override.
-               return true;
+               $this->closeConnection();
+               $this->mOpened = false;
+               $this->mConn = false;
+               try {
+                       $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
+                       $this->lastPing = microtime( true );
+                       $ok = true;
+               } catch ( DBConnectionError $e ) {
+                       $ok = false;
+               }
+
+               return $ok;
        }
 
        public function getSessionLagStatus() {
index d1ebe62..fa3756c 100644 (file)
@@ -599,15 +599,6 @@ abstract class DatabaseMysqlBase extends Database {
                return strlen( $name ) && $name[0] == '`' && substr( $name, -1, 1 ) == '`';
        }
 
-       function reconnect() {
-               $this->closeConnection();
-               $this->mOpened = false;
-               $this->mConn = false;
-               $this->open( $this->mServer, $this->mUser, $this->mPassword, $this->mDBname );
-
-               return true;
-       }
-
        function getLag() {
                if ( $this->getLagDetectionMethod() === 'pt-heartbeat' ) {
                        return $this->getLagFromPtHeartbeat();
index f045acf..13a0879 100644 (file)
@@ -1090,7 +1090,7 @@ class LoadBalancer {
        public function approveMasterChanges( array $options ) {
                $limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0;
                $this->forEachOpenMasterConnection( function ( DatabaseBase $conn ) use ( $limit ) {
-                       // If atomic section or explicit transactions are still open, some caller must have
+                       // If atomic sections or explicit transactions are still open, some caller must have
                        // caught an exception but failed to properly rollback any changes. Detect that and
                        // throw and error (causing rollback).
                        if ( $conn->explicitTrxActive() ) {
@@ -1108,6 +1108,14 @@ class LoadBalancer {
                                        wfMessage( 'transaction-duration-limit-exceeded', $time, $limit )->text()
                                );
                        }
+                       // If a connection sits idle while slow queries execute on another, that connection
+                       // may end up dropped before the commit round is reached. Ping servers to detect this.
+                       if ( $conn->writesOrCallbacksPending() && !$conn->ping() ) {
+                               throw new DBTransactionError(
+                                       $conn,
+                                       "A connection to the {$conn->getDBname()} database was lost before commit."
+                               );
+                       }
                } );
        }
 
index 5b84ca9..281ac24 100644 (file)
@@ -155,18 +155,3 @@ abstract class DataUpdate implements DeferrableUpdate {
                return $remaining;
        }
 }
-
-/**
- * Interface that marks a DataUpdate as enqueuable via the JobQueue
- *
- * Such updates must be representable using IJobSpecification, so that
- * they can be serialized into jobs and enqueued for later execution
- *
- * @since 1.27
- */
-interface EnqueueableDataUpdate {
-       /**
-        * @return array (wiki => wiki ID, job => IJobSpecification)
-        */
-       public function getAsJobSpecification();
-}
diff --git a/includes/deferred/EnqueueableDataUpdate.php b/includes/deferred/EnqueueableDataUpdate.php
new file mode 100644 (file)
index 0000000..ffeb740
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Interface that marks a DataUpdate as enqueuable via the JobQueue
+ *
+ * Such updates must be representable using IJobSpecification, so that
+ * they can be serialized into jobs and enqueued for later execution
+ *
+ * @since 1.27
+ */
+interface EnqueueableDataUpdate {
+       /**
+        * @return array (wiki => wiki ID, job => IJobSpecification)
+        */
+       public function getAsJobSpecification();
+}
index 4f40c38..5e02c5c 100644 (file)
@@ -304,7 +304,7 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
         * @param array $cats
         */
        function invalidateCategories( $cats ) {
-               $this->invalidatePages( NS_CATEGORY, array_keys( $cats ) );
+               PurgeJobUtils::invalidatePages( $this->mDb, NS_CATEGORY, array_keys( $cats ) );
        }
 
        /**
@@ -323,7 +323,7 @@ class LinksUpdate extends SqlDataUpdate implements EnqueueableDataUpdate {
         * @param array $images
         */
        function invalidateImageDescriptions( $images ) {
-               $this->invalidatePages( NS_FILE, array_keys( $images ) );
+               PurgeJobUtils::invalidatePages( $this->mDb, NS_FILE, array_keys( $images ) );
        }
 
        /**
index 9740cbe..ff06915 100644 (file)
@@ -98,53 +98,4 @@ abstract class SqlDataUpdate extends DataUpdate {
                        $this->mHasTransaction = false;
                }
        }
-
-       /**
-        * Invalidate the cache of a list of pages from a single namespace.
-        * This is intended for use by subclasses.
-        *
-        * @param int $namespace Namespace number
-        * @param array $dbkeys
-        */
-       protected function invalidatePages( $namespace, array $dbkeys ) {
-               if ( $dbkeys === [] ) {
-                       return;
-               }
-
-               $dbw = $this->mDb;
-               $dbw->onTransactionPreCommitOrIdle( function() use ( $dbw, $namespace, $dbkeys ) {
-                       /**
-                        * Determine which pages need to be updated
-                        * This is necessary to prevent the job queue from smashing the DB with
-                        * large numbers of concurrent invalidations of the same page
-                        */
-                       $now = $dbw->timestamp();
-                       $ids = $dbw->selectFieldValues( 'page',
-                               'page_id',
-                               [
-                                       'page_namespace' => $namespace,
-                                       'page_title' => $dbkeys,
-                                       'page_touched < ' . $dbw->addQuotes( $now )
-                               ],
-                               __METHOD__
-                       );
-
-                       if ( $ids === [] ) {
-                               return;
-                       }
-
-                       /**
-                        * Do the update
-                        * We still need the page_touched condition, in case the row has changed since
-                        * the non-locking select above.
-                        */
-                       $dbw->update( 'page',
-                               [ 'page_touched' => $now ],
-                               [
-                                       'page_id' => $ids,
-                                       'page_touched < ' . $dbw->addQuotes( $now )
-                               ], __METHOD__
-                       );
-               } );
-       }
 }
index c9aad43..0aefbfa 100644 (file)
@@ -239,205 +239,3 @@ abstract class DBLockManager extends QuorumLockManager {
                }
        }
 }
-
-/**
- * MySQL version of DBLockManager that supports shared locks.
- *
- * All lock servers must have the innodb table defined in locking/filelocks.sql.
- * All locks are non-blocking, which avoids deadlocks.
- *
- * @ingroup LockManager
- */
-class MySqlLockManager extends DBLockManager {
-       /** @var array Mapping of lock types to the type actually used */
-       protected $lockTypeMap = [
-               self::LOCK_SH => self::LOCK_SH,
-               self::LOCK_UW => self::LOCK_SH,
-               self::LOCK_EX => self::LOCK_EX
-       ];
-
-       protected function getLocalLB() {
-               // Use a separate connection so releaseAllLocks() doesn't rollback the main trx
-               return wfGetLBFactory()->newMainLB( $this->domain );
-       }
-
-       protected function initConnection( $lockDb, IDatabase $db ) {
-               # Let this transaction see lock rows from other transactions
-               $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" );
-       }
-
-       /**
-        * Get a connection to a lock DB and acquire locks on $paths.
-        * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118.
-        *
-        * @see DBLockManager::getLocksOnServer()
-        * @param string $lockSrv
-        * @param array $paths
-        * @param string $type
-        * @return Status
-        */
-       protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
-               $status = Status::newGood();
-
-               $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
-
-               $keys = []; // list of hash keys for the paths
-               $data = []; // list of rows to insert
-               $checkEXKeys = []; // list of hash keys that this has no EX lock on
-               # Build up values for INSERT clause
-               foreach ( $paths as $path ) {
-                       $key = $this->sha1Base36Absolute( $path );
-                       $keys[] = $key;
-                       $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ];
-                       if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
-                               $checkEXKeys[] = $key;
-                       }
-               }
-
-               # Block new writers (both EX and SH locks leave entries here)...
-               $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] );
-               # Actually do the locking queries...
-               if ( $type == self::LOCK_SH ) { // reader locks
-                       $blocked = false;
-                       # Bail if there are any existing writers...
-                       if ( count( $checkEXKeys ) ) {
-                               $blocked = $db->selectField( 'filelocks_exclusive', '1',
-                                       [ 'fle_key' => $checkEXKeys ],
-                                       __METHOD__
-                               );
-                       }
-                       # Other prospective writers that haven't yet updated filelocks_exclusive
-                       # will recheck filelocks_shared after doing so and bail due to this entry.
-               } else { // writer locks
-                       $encSession = $db->addQuotes( $this->session );
-                       # Bail if there are any existing writers...
-                       # This may detect readers, but the safe check for them is below.
-                       # Note: if two writers come at the same time, both bail :)
-                       $blocked = $db->selectField( 'filelocks_shared', '1',
-                               [ 'fls_key' => $keys, "fls_session != $encSession" ],
-                               __METHOD__
-                       );
-                       if ( !$blocked ) {
-                               # Build up values for INSERT clause
-                               $data = [];
-                               foreach ( $keys as $key ) {
-                                       $data[] = [ 'fle_key' => $key ];
-                               }
-                               # Block new readers/writers...
-                               $db->insert( 'filelocks_exclusive', $data, __METHOD__ );
-                               # Bail if there are any existing readers...
-                               $blocked = $db->selectField( 'filelocks_shared', '1',
-                                       [ 'fls_key' => $keys, "fls_session != $encSession" ],
-                                       __METHOD__
-                               );
-                       }
-               }
-
-               if ( $blocked ) {
-                       foreach ( $paths as $path ) {
-                               $status->fatal( 'lockmanager-fail-acquirelock', $path );
-                       }
-               }
-
-               return $status;
-       }
-
-       /**
-        * @see QuorumLockManager::releaseAllLocks()
-        * @return Status
-        */
-       protected function releaseAllLocks() {
-               $status = Status::newGood();
-
-               foreach ( $this->conns as $lockDb => $db ) {
-                       if ( $db->trxLevel() ) { // in transaction
-                               try {
-                                       $db->rollback( __METHOD__ ); // finish transaction and kill any rows
-                               } catch ( DBError $e ) {
-                                       $status->fatal( 'lockmanager-fail-db-release', $lockDb );
-                               }
-                       }
-               }
-
-               return $status;
-       }
-}
-
-/**
- * PostgreSQL version of DBLockManager that supports shared locks.
- * All locks are non-blocking, which avoids deadlocks.
- *
- * @ingroup LockManager
- */
-class PostgreSqlLockManager extends DBLockManager {
-       /** @var array Mapping of lock types to the type actually used */
-       protected $lockTypeMap = [
-               self::LOCK_SH => self::LOCK_SH,
-               self::LOCK_UW => self::LOCK_SH,
-               self::LOCK_EX => self::LOCK_EX
-       ];
-
-       protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
-               $status = Status::newGood();
-               if ( !count( $paths ) ) {
-                       return $status; // nothing to lock
-               }
-
-               $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
-               $bigints = array_unique( array_map(
-                       function ( $key ) {
-                               return Wikimedia\base_convert( substr( $key, 0, 15 ), 16, 10 );
-                       },
-                       array_map( [ $this, 'sha1Base16Absolute' ], $paths )
-               ) );
-
-               // Try to acquire all the locks...
-               $fields = [];
-               foreach ( $bigints as $bigint ) {
-                       $fields[] = ( $type == self::LOCK_SH )
-                               ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint"
-                               : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint";
-               }
-               $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
-               $row = $res->fetchRow();
-
-               if ( in_array( 'f', $row ) ) {
-                       // Release any acquired locks if some could not be acquired...
-                       $fields = [];
-                       foreach ( $row as $kbigint => $ok ) {
-                               if ( $ok === 't' ) { // locked
-                                       $bigint = substr( $kbigint, 1 ); // strip off the "K"
-                                       $fields[] = ( $type == self::LOCK_SH )
-                                               ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})"
-                                               : "pg_advisory_unlock({$db->addQuotes( $bigint )})";
-                               }
-                       }
-                       if ( count( $fields ) ) {
-                               $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
-                       }
-                       foreach ( $paths as $path ) {
-                               $status->fatal( 'lockmanager-fail-acquirelock', $path );
-                       }
-               }
-
-               return $status;
-       }
-
-       /**
-        * @see QuorumLockManager::releaseAllLocks()
-        * @return Status
-        */
-       protected function releaseAllLocks() {
-               $status = Status::newGood();
-
-               foreach ( $this->conns as $lockDb => $db ) {
-                       try {
-                               $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ );
-                       } catch ( DBError $e ) {
-                               $status->fatal( 'lockmanager-fail-db-release', $lockDb );
-                       }
-               }
-
-               return $status;
-       }
-}
diff --git a/includes/filebackend/lockmanager/MySqlLockManager.php b/includes/filebackend/lockmanager/MySqlLockManager.php
new file mode 100644 (file)
index 0000000..c51df16
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+/**
+ * MySQL version of DBLockManager that supports shared locks.
+ *
+ * All lock servers must have the innodb table defined in locking/filelocks.sql.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class MySqlLockManager extends DBLockManager {
+       /** @var array Mapping of lock types to the type actually used */
+       protected $lockTypeMap = [
+               self::LOCK_SH => self::LOCK_SH,
+               self::LOCK_UW => self::LOCK_SH,
+               self::LOCK_EX => self::LOCK_EX
+       ];
+
+       protected function getLocalLB() {
+               // Use a separate connection so releaseAllLocks() doesn't rollback the main trx
+               return wfGetLBFactory()->newMainLB( $this->domain );
+       }
+
+       protected function initConnection( $lockDb, IDatabase $db ) {
+               # Let this transaction see lock rows from other transactions
+               $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" );
+       }
+
+       /**
+        * Get a connection to a lock DB and acquire locks on $paths.
+        * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118.
+        *
+        * @see DBLockManager::getLocksOnServer()
+        * @param string $lockSrv
+        * @param array $paths
+        * @param string $type
+        * @return Status
+        */
+       protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+               $status = Status::newGood();
+
+               $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+
+               $keys = []; // list of hash keys for the paths
+               $data = []; // list of rows to insert
+               $checkEXKeys = []; // list of hash keys that this has no EX lock on
+               # Build up values for INSERT clause
+               foreach ( $paths as $path ) {
+                       $key = $this->sha1Base36Absolute( $path );
+                       $keys[] = $key;
+                       $data[] = [ 'fls_key' => $key, 'fls_session' => $this->session ];
+                       if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) {
+                               $checkEXKeys[] = $key;
+                       }
+               }
+
+               # Block new writers (both EX and SH locks leave entries here)...
+               $db->insert( 'filelocks_shared', $data, __METHOD__, [ 'IGNORE' ] );
+               # Actually do the locking queries...
+               if ( $type == self::LOCK_SH ) { // reader locks
+                       $blocked = false;
+                       # Bail if there are any existing writers...
+                       if ( count( $checkEXKeys ) ) {
+                               $blocked = $db->selectField( 'filelocks_exclusive', '1',
+                                       [ 'fle_key' => $checkEXKeys ],
+                                       __METHOD__
+                               );
+                       }
+                       # Other prospective writers that haven't yet updated filelocks_exclusive
+                       # will recheck filelocks_shared after doing so and bail due to this entry.
+               } else { // writer locks
+                       $encSession = $db->addQuotes( $this->session );
+                       # Bail if there are any existing writers...
+                       # This may detect readers, but the safe check for them is below.
+                       # Note: if two writers come at the same time, both bail :)
+                       $blocked = $db->selectField( 'filelocks_shared', '1',
+                               [ 'fls_key' => $keys, "fls_session != $encSession" ],
+                               __METHOD__
+                       );
+                       if ( !$blocked ) {
+                               # Build up values for INSERT clause
+                               $data = [];
+                               foreach ( $keys as $key ) {
+                                       $data[] = [ 'fle_key' => $key ];
+                               }
+                               # Block new readers/writers...
+                               $db->insert( 'filelocks_exclusive', $data, __METHOD__ );
+                               # Bail if there are any existing readers...
+                               $blocked = $db->selectField( 'filelocks_shared', '1',
+                                       [ 'fls_key' => $keys, "fls_session != $encSession" ],
+                                       __METHOD__
+                               );
+                       }
+               }
+
+               if ( $blocked ) {
+                       foreach ( $paths as $path ) {
+                               $status->fatal( 'lockmanager-fail-acquirelock', $path );
+                       }
+               }
+
+               return $status;
+       }
+
+       /**
+        * @see QuorumLockManager::releaseAllLocks()
+        * @return Status
+        */
+       protected function releaseAllLocks() {
+               $status = Status::newGood();
+
+               foreach ( $this->conns as $lockDb => $db ) {
+                       if ( $db->trxLevel() ) { // in transaction
+                               try {
+                                       $db->rollback( __METHOD__ ); // finish transaction and kill any rows
+                               } catch ( DBError $e ) {
+                                       $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+                               }
+                       }
+               }
+
+               return $status;
+       }
+}
diff --git a/includes/filebackend/lockmanager/PostgreSqlLockManager.php b/includes/filebackend/lockmanager/PostgreSqlLockManager.php
new file mode 100644 (file)
index 0000000..d55b5ae
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+/**
+ * PostgreSQL version of DBLockManager that supports shared locks.
+ * All locks are non-blocking, which avoids deadlocks.
+ *
+ * @ingroup LockManager
+ */
+class PostgreSqlLockManager extends DBLockManager {
+       /** @var array Mapping of lock types to the type actually used */
+       protected $lockTypeMap = [
+               self::LOCK_SH => self::LOCK_SH,
+               self::LOCK_UW => self::LOCK_SH,
+               self::LOCK_EX => self::LOCK_EX
+       ];
+
+       protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) {
+               $status = Status::newGood();
+               if ( !count( $paths ) ) {
+                       return $status; // nothing to lock
+               }
+
+               $db = $this->getConnection( $lockSrv ); // checked in isServerUp()
+               $bigints = array_unique( array_map(
+                       function ( $key ) {
+                               return Wikimedia\base_convert( substr( $key, 0, 15 ), 16, 10 );
+                       },
+                       array_map( [ $this, 'sha1Base16Absolute' ], $paths )
+               ) );
+
+               // Try to acquire all the locks...
+               $fields = [];
+               foreach ( $bigints as $bigint ) {
+                       $fields[] = ( $type == self::LOCK_SH )
+                               ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint"
+                               : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint";
+               }
+               $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+               $row = $res->fetchRow();
+
+               if ( in_array( 'f', $row ) ) {
+                       // Release any acquired locks if some could not be acquired...
+                       $fields = [];
+                       foreach ( $row as $kbigint => $ok ) {
+                               if ( $ok === 't' ) { // locked
+                                       $bigint = substr( $kbigint, 1 ); // strip off the "K"
+                                       $fields[] = ( $type == self::LOCK_SH )
+                                               ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})"
+                                               : "pg_advisory_unlock({$db->addQuotes( $bigint )})";
+                               }
+                       }
+                       if ( count( $fields ) ) {
+                               $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ );
+                       }
+                       foreach ( $paths as $path ) {
+                               $status->fatal( 'lockmanager-fail-acquirelock', $path );
+                       }
+               }
+
+               return $status;
+       }
+
+       /**
+        * @see QuorumLockManager::releaseAllLocks()
+        * @return Status
+        */
+       protected function releaseAllLocks() {
+               $status = Status::newGood();
+
+               foreach ( $this->conns as $lockDb => $db ) {
+                       try {
+                               $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ );
+                       } catch ( DBError $e ) {
+                               $status->fatal( 'lockmanager-fail-db-release', $lockDb );
+                       }
+               }
+
+               return $status;
+       }
+}
index 6095aee..4121ecb 100644 (file)
@@ -81,10 +81,12 @@ class RedisLockManager extends QuorumLockManager {
        protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
                $status = Status::newGood();
 
+               $pathList = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+
                $server = $this->lockServers[$lockSrv];
                $conn = $this->redisPool->getConnection( $server );
                if ( !$conn ) {
-                       foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+                       foreach ( $pathList as $path ) {
                                $status->fatal( 'lockmanager-fail-acquirelock', $path );
                        }
 
@@ -157,7 +159,7 @@ LUA;
                }
 
                if ( $res === false ) {
-                       foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+                       foreach ( $pathList as $path ) {
                                $status->fatal( 'lockmanager-fail-acquirelock', $path );
                        }
                } else {
@@ -172,10 +174,12 @@ LUA;
        protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
                $status = Status::newGood();
 
+               $pathList = call_user_func_array( 'array_merge', array_values( $pathsByType ) );
+
                $server = $this->lockServers[$lockSrv];
                $conn = $this->redisPool->getConnection( $server );
                if ( !$conn ) {
-                       foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+                       foreach ( $pathList as $path ) {
                                $status->fatal( 'lockmanager-fail-releaselock', $path );
                        }
 
@@ -225,7 +229,7 @@ LUA;
                }
 
                if ( $res === false ) {
-                       foreach ( array_merge( array_values( $pathsByType ) ) as $path ) {
+                       foreach ( $pathList as $path ) {
                                $status->fatal( 'lockmanager-fail-releaselock', $path );
                        }
                } else {
index 91d628c..7e6e651 100644 (file)
@@ -2596,8 +2596,9 @@ class LocalFileRestoreBatch {
 
                                // The live (current) version cannot be hidden!
                                if ( !$this->unsuppress && $row->fa_deleted ) {
-                                       $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
-                                       $this->cleanupBatch[] = $row->fa_storage_key;
+                                       $status->fatal( 'undeleterevdel' );
+                                       $this->file->unlock();
+                                       return $status;
                                }
                        } else {
                                $archiveName = $row->fa_archive_name;
index ff37e24..3c88594 100644 (file)
@@ -354,6 +354,26 @@ class HTMLForm extends ContextSource {
                $this->mFieldTree = $loadedDescriptor;
        }
 
+       /**
+        * @param string $fieldname
+        * @return bool
+        */
+       public function hasField( $fieldname ) {
+               return isset( $this->mFlatFields[$fieldname] );
+       }
+
+       /**
+        * @param string $fieldname
+        * @return HTMLFormField
+        * @throws DomainException on invalid field name
+        */
+       public function getField( $fieldname ) {
+               if ( !$this->hasField( $fieldname ) ) {
+                       throw new DomainException( __METHOD__ . ': no field named ' . $fieldname );
+               }
+               return $this->mFlatFields[$fieldname];
+       }
+
        /**
         * Set format in which to display the form
         *
index dd53f5e..8f42ea2 100644 (file)
@@ -61,7 +61,7 @@ abstract class HTMLFormField {
         * @return bool
         */
        public function canDisplayErrors() {
-               return true;
+               return $this->hasVisibleOutput();
        }
 
        /**
index 479ec32..3a1da13 100644 (file)
@@ -245,10 +245,7 @@ class JobQueueDB extends JobQueue {
                                count( $rowSet ) + count( $rowList ) - count( $rows )
                        );
                } catch ( DBError $e ) {
-                       if ( $flags & self::QOS_ATOMIC ) {
-                               $dbw->rollback( $method );
-                       }
-                       throw $e;
+                       $this->throwDBException( $e );
                }
                if ( $flags & self::QOS_ATOMIC ) {
                        $dbw->endAtomic( $method );
@@ -264,7 +261,6 @@ class JobQueueDB extends JobQueue {
        protected function doPop() {
                $dbw = $this->getMasterDB();
                try {
-                       $dbw->commit( __METHOD__, 'flush' ); // flush existing transaction
                        $autoTrx = $dbw->getFlag( DBO_TRX ); // get current setting
                        $dbw->clearFlag( DBO_TRX ); // make each query its own transaction
                        $scopedReset = new ScopedCallback( function () use ( $dbw, $autoTrx ) {
@@ -460,7 +456,6 @@ class JobQueueDB extends JobQueue {
 
                $dbw = $this->getMasterDB();
                try {
-                       $dbw->commit( __METHOD__, 'flush' ); // flush existing transaction
                        $autoTrx = $dbw->getFlag( DBO_TRX ); // get current setting
                        $dbw->clearFlag( DBO_TRX ); // make each query its own transaction
                        $scopedReset = new ScopedCallback( function () use ( $dbw, $autoTrx ) {
index 4b906a7..5f48dca 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup JobQueue
  */
 
+use MediaWiki\MediaWikiServices;
 use MediaWiki\Logger\LoggerFactory;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerInterface;
@@ -122,7 +123,8 @@ class JobRunner implements LoggerAwareInterface {
                }
 
                // Flush any pending DB writes for sanity
-               wfGetLBFactory()->commitAll( __METHOD__ );
+               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+               $lbFactory->commitAll( __METHOD__ );
 
                // Catch huge single updates that lead to slave lag
                $trxProfiler = Profiler::instance()->getTransactionProfiler();
@@ -176,9 +178,11 @@ class JobRunner implements LoggerAwareInterface {
                                        $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
                                }
 
+                               $lbFactory->commitMasterChanges( __METHOD__ ); // flush any JobQueueDB writes
                                $info = $this->executeJob( $job, $stats, $popTime );
                                if ( $info['status'] !== false || !$job->allowRetries() ) {
                                        $group->ack( $job ); // succeeded or job cannot be retried
+                                       $lbFactory->commitMasterChanges( __METHOD__ ); // flush any JobQueueDB writes
                                }
 
                                // Back off of certain jobs for a while (for throttling and for errors)
@@ -212,7 +216,7 @@ class JobRunner implements LoggerAwareInterface {
                                $timePassed = microtime( true ) - $lastCheckTime;
                                if ( $timePassed >= self::LAG_CHECK_PERIOD || $timePassed < 0 ) {
                                        try {
-                                               wfGetLBFactory()->waitForReplication( [
+                                               $lbFactory->waitForReplication( [
                                                        'ifWritesSince' => $lastCheckTime,
                                                        'timeout' => self::MAX_ALLOWED_LAG
                                                ] );
@@ -257,6 +261,7 @@ class JobRunner implements LoggerAwareInterface {
                $msg = $job->toString() . " STARTING";
                $this->logger->debug( $msg );
                $this->debugCallback( $msg );
+               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
 
                // Run the job...
                $rssStart = $this->getMaxRssKb();
@@ -284,7 +289,7 @@ class JobRunner implements LoggerAwareInterface {
                // Commit all outstanding connections that are in a transaction
                // to get a fresh repeatable read snapshot on every connection.
                // Note that jobs are still responsible for handling slave lag.
-               wfGetLBFactory()->commitAll( __METHOD__ );
+               $lbFactory->commitAll( __METHOD__ );
                // Clear out title cache data from prior snapshots
                LinkCache::singleton()->clear();
                $timeMs = intval( ( microtime( true ) - $jobStartTime ) * 1000 );
@@ -528,17 +533,6 @@ class JobRunner implements LoggerAwareInterface {
                        $lb->waitForAll( $pos );
                }
 
-               $fname = __METHOD__;
-               // Re-ping all masters with transactions. This throws DBError if some
-               // connection died while waiting on locks/slaves, triggering a rollback.
-               wfGetLBFactory()->forEachLB( function( LoadBalancer $lb ) use ( $fname ) {
-                       $lb->forEachOpenConnection( function( IDatabase $conn ) use ( $fname ) {
-                               if ( $conn->writesOrCallbacksPending() ) {
-                                       $conn->ping();
-                               }
-                       } );
-               } );
-
                // Actually commit the DB master changes
                wfGetLBFactory()->commitMasterChanges( __METHOD__ );
 
diff --git a/includes/jobqueue/utils/PurgeJobUtils.php b/includes/jobqueue/utils/PurgeJobUtils.php
new file mode 100644 (file)
index 0000000..329bc23
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Base code for update jobs that put some secondary data extracted
+ * from article content into the database.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+class PurgeJobUtils {
+       /**
+        * Invalidate the cache of a list of pages from a single namespace.
+        * This is intended for use by subclasses.
+        *
+        * @param IDatabase $dbw
+        * @param int $namespace Namespace number
+        * @param array $dbkeys
+        */
+       public static function invalidatePages( IDatabase $dbw, $namespace, array $dbkeys ) {
+               if ( $dbkeys === [] ) {
+                       return;
+               }
+
+               $dbw->onTransactionPreCommitOrIdle( function() use ( $dbw, $namespace, $dbkeys ) {
+                       // Determine which pages need to be updated.
+                       // This is necessary to prevent the job queue from smashing the DB with
+                       // large numbers of concurrent invalidations of the same page.
+                       $now = $dbw->timestamp();
+                       $ids = $dbw->selectFieldValues(
+                               'page',
+                               'page_id',
+                               [
+                                       'page_namespace' => $namespace,
+                                       'page_title' => $dbkeys,
+                                       'page_touched < ' . $dbw->addQuotes( $now )
+                               ],
+                               __METHOD__
+                       );
+
+                       if ( $ids === [] ) {
+                               return;
+                       }
+
+                       // Do the update.
+                       // We still need the page_touched condition, in case the row has changed since
+                       // the non-locking select above.
+                       $dbw->update(
+                               'page',
+                               [ 'page_touched' => $now ],
+                               [
+                                       'page_id' => $ids,
+                                       'page_touched < ' . $dbw->addQuotes( $now )
+                               ],
+                               __METHOD__
+                       );
+               } );
+       }
+}
index f304bd9..a333d75 100644 (file)
@@ -237,8 +237,6 @@ class VirtualRESTServiceClient {
                                        $checkReqIndexesByPrefix[$prefix][$index] = 1;
                                }
                        }
-                       // Update index of requests to inspect for replacement
-                       $replaceReqsByService = $newReplaceReqsByService;
                        // Run the actual work HTTP requests
                        foreach ( $this->http->runMulti( $executeReqs ) as $index => $ranReq ) {
                                $doneReqs[$index] = $ranReq;
index 5556dd8..9ab28aa 100644 (file)
@@ -21,6 +21,8 @@
  * @ingroup Cache
  */
 
+use \MediaWiki\MediaWikiServices;
+
 /**
  * Class to store objects in the database
  *
@@ -276,6 +278,7 @@ class SqlBagOStuff extends BagOStuff {
                        if ( isset( $dataRows[$key] ) ) { // HIT?
                                $row = $dataRows[$key];
                                $this->debug( "get: retrieved data; expiry time is " . $row->exptime );
+                               $db = null;
                                try {
                                        $db = $this->getDB( $row->serverIndex );
                                        if ( $this->isExpired( $db, $row->exptime ) ) { // MISS
@@ -284,7 +287,7 @@ class SqlBagOStuff extends BagOStuff {
                                                $values[$key] = $this->unserialize( $db->decodeBlob( $row->value ) );
                                        }
                                } catch ( DBQueryError $e ) {
-                                       $this->handleWriteError( $e, $row->serverIndex );
+                                       $this->handleWriteError( $e, $db, $row->serverIndex );
                                }
                        } else { // MISS
                                $this->debug( 'get: no matching rows' );
@@ -306,10 +309,11 @@ class SqlBagOStuff extends BagOStuff {
                $result = true;
                $exptime = (int)$expiry;
                foreach ( $keysByTable as $serverIndex => $serverKeys ) {
+                       $db = null;
                        try {
                                $db = $this->getDB( $serverIndex );
                        } catch ( DBError $e ) {
-                               $this->handleWriteError( $e, $serverIndex );
+                               $this->handleWriteError( $e, $db, $serverIndex );
                                $result = false;
                                continue;
                        }
@@ -342,7 +346,7 @@ class SqlBagOStuff extends BagOStuff {
                                                __METHOD__
                                        );
                                } catch ( DBError $e ) {
-                                       $this->handleWriteError( $e, $serverIndex );
+                                       $this->handleWriteError( $e, $db, $serverIndex );
                                        $result = false;
                                }
 
@@ -364,6 +368,7 @@ class SqlBagOStuff extends BagOStuff {
 
        protected function cas( $casToken, $key, $value, $exptime = 0 ) {
                list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+               $db = null;
                try {
                        $db = $this->getDB( $serverIndex );
                        $exptime = intval( $exptime );
@@ -394,7 +399,7 @@ class SqlBagOStuff extends BagOStuff {
                                __METHOD__
                        );
                } catch ( DBQueryError $e ) {
-                       $this->handleWriteError( $e, $serverIndex );
+                       $this->handleWriteError( $e, $db, $serverIndex );
 
                        return false;
                }
@@ -404,6 +409,7 @@ class SqlBagOStuff extends BagOStuff {
 
        public function delete( $key ) {
                list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+               $db = null;
                try {
                        $db = $this->getDB( $serverIndex );
                        $db->delete(
@@ -411,7 +417,7 @@ class SqlBagOStuff extends BagOStuff {
                                [ 'keyname' => $key ],
                                __METHOD__ );
                } catch ( DBError $e ) {
-                       $this->handleWriteError( $e, $serverIndex );
+                       $this->handleWriteError( $e, $db, $serverIndex );
                        return false;
                }
 
@@ -420,6 +426,7 @@ class SqlBagOStuff extends BagOStuff {
 
        public function incr( $key, $step = 1 ) {
                list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+               $db = null;
                try {
                        $db = $this->getDB( $serverIndex );
                        $step = intval( $step );
@@ -455,7 +462,7 @@ class SqlBagOStuff extends BagOStuff {
                                $newValue = null;
                        }
                } catch ( DBError $e ) {
-                       $this->handleWriteError( $e, $serverIndex );
+                       $this->handleWriteError( $e, $db, $serverIndex );
                        return null;
                }
 
@@ -473,6 +480,7 @@ class SqlBagOStuff extends BagOStuff {
 
        public function changeTTL( $key, $expiry = 0 ) {
                list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
+               $db = null;
                try {
                        $db = $this->getDB( $serverIndex );
                        $db->update(
@@ -485,7 +493,7 @@ class SqlBagOStuff extends BagOStuff {
                                return false;
                        }
                } catch ( DBError $e ) {
-                       $this->handleWriteError( $e, $serverIndex );
+                       $this->handleWriteError( $e, $db, $serverIndex );
                        return false;
                }
 
@@ -542,6 +550,7 @@ class SqlBagOStuff extends BagOStuff {
         */
        public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) {
                for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+                       $db = null;
                        try {
                                $db = $this->getDB( $serverIndex );
                                $dbTimestamp = $db->timestamp( $timestamp );
@@ -604,7 +613,7 @@ class SqlBagOStuff extends BagOStuff {
                                        }
                                }
                        } catch ( DBError $e ) {
-                               $this->handleWriteError( $e, $serverIndex );
+                               $this->handleWriteError( $e, $db, $serverIndex );
                                return false;
                        }
                }
@@ -618,13 +627,14 @@ class SqlBagOStuff extends BagOStuff {
         */
        public function deleteAll() {
                for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) {
+                       $db = null;
                        try {
                                $db = $this->getDB( $serverIndex );
                                for ( $i = 0; $i < $this->shards; $i++ ) {
                                        $db->delete( $this->getTableNameByShard( $i ), '*', __METHOD__ );
                                }
                        } catch ( DBError $e ) {
-                               $this->handleWriteError( $e, $serverIndex );
+                               $this->handleWriteError( $e, $db, $serverIndex );
                                return false;
                        }
                }
@@ -694,18 +704,19 @@ class SqlBagOStuff extends BagOStuff {
         * Handle a DBQueryError which occurred during a write operation.
         *
         * @param DBError $exception
+        * @param IDatabase|null $db DB handle or null if connection failed
         * @param int $serverIndex
+        * @throws Exception
         */
-       protected function handleWriteError( DBError $exception, $serverIndex ) {
-               if ( $exception instanceof DBConnectionError ) {
+       protected function handleWriteError( DBError $exception, IDatabase $db = null, $serverIndex ) {
+               if ( !$db ) {
                        $this->markServerDown( $exception, $serverIndex );
-               }
-               if ( $exception->db && $exception->db->wasReadOnlyError() ) {
-                       if ( $exception->db->trxLevel() ) {
-                               try {
-                                       $exception->db->rollback( __METHOD__ );
-                               } catch ( DBError $e ) {
-                               }
+               } elseif ( $db->wasReadOnlyError() ) {
+                       if ( $db->trxLevel() && $this->usesMainDB() ) {
+                               // Errors like deadlocks and connection drops already cause rollback.
+                               // For consistency, we have no choice but to throw an error and trigger
+                               // complete rollback if the main DB is also being used as the cache DB.
+                               throw $exception;
                        }
                }
 
@@ -725,7 +736,7 @@ class SqlBagOStuff extends BagOStuff {
         * @param DBError $exception
         * @param int $serverIndex
         */
-       protected function markServerDown( $exception, $serverIndex ) {
+       protected function markServerDown( DBError $exception, $serverIndex ) {
                unset( $this->conns[$serverIndex] ); // bug T103435
 
                if ( isset( $this->connFailureTimes[$serverIndex] ) ) {
@@ -762,11 +773,19 @@ class SqlBagOStuff extends BagOStuff {
                }
        }
 
+       /**
+        * @return bool Whether the main DB is used, e.g. wfGetDB( DB_MASTER )
+        */
+       protected function usesMainDB() {
+               return !$this->serverInfos;
+       }
+
        protected function waitForSlaves() {
-               if ( !$this->serverInfos ) {
+               if ( $this->usesMainDB() ) {
                        // Main LB is used; wait for any slaves to catch up
                        try {
-                               wfGetLBFactory()->waitForReplication( [ 'wiki' => wfWikiID() ] );
+                               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                               $lbFactory->waitForReplication( [ 'wiki' => wfWikiID() ] );
                                return true;
                        } catch ( DBReplicationWaitError $e ) {
                                return false;
index 0344756..3851d15 100644 (file)
@@ -1770,7 +1770,6 @@ class WikiPage implements Page, IDBAccessObject {
                        $revisionId = $revision->insertOn( $dbw );
                        // Update page_latest and friends to reflect the new revision
                        if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
-                               $dbw->rollback( __METHOD__ ); // sanity; this should never happen
                                throw new MWException( "Failed to update page row to use new revision." );
                        }
 
@@ -1920,7 +1919,6 @@ class WikiPage implements Page, IDBAccessObject {
                $revisionId = $revision->insertOn( $dbw );
                // Update the page record with revision data
                if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
-                       $dbw->rollback( __METHOD__ ); // sanity; this should never happen
                        throw new MWException( "Failed to update page row to use new revision." );
                }
 
@@ -2875,6 +2873,10 @@ class WikiPage implements Page, IDBAccessObject {
                        return $status;
                }
 
+               // Given the lock above, we can be confident in the title and page ID values
+               $namespace = $this->getTitle()->getNamespace();
+               $dbKey = $this->getTitle()->getDBkey();
+
                // At this point we are now comitted to returning an OK
                // status unless some DB query error or other exception comes up.
                // This way callers don't have to call rollback() if $status is bad
@@ -2895,54 +2897,50 @@ class WikiPage implements Page, IDBAccessObject {
                        $bitfield = 'rev_deleted';
                }
 
-               /**
-                * For now, shunt the revision data into the archive table.
-                * Text is *not* removed from the text table; bulk storage
-                * is left intact to avoid breaking block-compression or
-                * immutable storage schemes.
-                *
-                * For backwards compatibility, note that some older archive
-                * table entries will have ar_text and ar_flags fields still.
-                *
-                * In the future, we may keep revisions and mark them with
-                * the rev_deleted field, which is reserved for this purpose.
-                */
-
-               $row = [
-                       'ar_namespace'  => 'page_namespace',
-                       'ar_title'      => 'page_title',
-                       'ar_comment'    => 'rev_comment',
-                       'ar_user'       => 'rev_user',
-                       'ar_user_text'  => 'rev_user_text',
-                       'ar_timestamp'  => 'rev_timestamp',
-                       'ar_minor_edit' => 'rev_minor_edit',
-                       'ar_rev_id'     => 'rev_id',
-                       'ar_parent_id'  => 'rev_parent_id',
-                       'ar_text_id'    => 'rev_text_id',
-                       'ar_text'       => '\'\'', // Be explicit to appease
-                       'ar_flags'      => '\'\'', // MySQL's "strict mode"...
-                       'ar_len'        => 'rev_len',
-                       'ar_page_id'    => 'page_id',
-                       'ar_deleted'    => $bitfield,
-                       'ar_sha1'       => 'rev_sha1',
-               ];
+               // For now, shunt the revision data into the archive table.
+               // Text is *not* removed from the text table; bulk storage
+               // is left intact to avoid breaking block-compression or
+               // immutable storage schemes.
+               // In the future, we may keep revisions and mark them with
+               // the rev_deleted field, which is reserved for this purpose.
 
-               if ( $wgContentHandlerUseDB ) {
-                       $row['ar_content_model'] = 'rev_content_model';
-                       $row['ar_content_format'] = 'rev_content_format';
-               }
-
-               // Copy all the page revisions into the archive table
-               $dbw->insertSelect(
-                       'archive',
-                       [ 'page', 'revision' ],
-                       $row,
-                       [
-                               'page_id' => $id,
-                               'page_id = rev_page'
-                       ],
-                       __METHOD__
+               // Get all of the page revisions
+               $res = $dbw->select(
+                       'revision',
+                       Revision::selectFields(),
+                       [ 'rev_page' => $id ],
+                       __METHOD__,
+                       'FOR UPDATE'
                );
+               // Build their equivalent archive rows
+               $rowsInsert = [];
+               foreach ( $res as $row ) {
+                       $rowInsert = [
+                               'ar_namespace'  => $namespace,
+                               'ar_title'      => $dbKey,
+                               'ar_comment'    => $row->rev_comment,
+                               'ar_user'       => $row->rev_user,
+                               'ar_user_text'  => $row->rev_user_text,
+                               'ar_timestamp'  => $row->rev_timestamp,
+                               'ar_minor_edit' => $row->rev_minor_edit,
+                               'ar_rev_id'     => $row->rev_id,
+                               'ar_parent_id'  => $row->rev_parent_id,
+                               'ar_text_id'    => $row->rev_text_id,
+                               'ar_text'       => '',
+                               'ar_flags'      => '',
+                               'ar_len'        => $row->rev_len,
+                               'ar_page_id'    => $id,
+                               'ar_deleted'    => $bitfield,
+                               'ar_sha1'       => $row->rev_sha1,
+                       ];
+                       if ( $wgContentHandlerUseDB ) {
+                               $rowInsert['ar_content_model'] = $row->rev_content_model;
+                               $rowInsert['ar_content_format'] = $row->rev_content_format;
+                       }
+                       $rowsInsert[] = $rowInsert;
+               }
+               // Copy them into the archive table
+               $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
                // Save this so we can pass it to the ArticleDeleteComplete hook.
                $archivedRevisionCount = $dbw->affectedRows();
 
index 4f579a9..b116bd4 100644 (file)
@@ -2158,7 +2158,7 @@ class Parser {
                                $might_be_img = true;
                                $text = $m[2];
                                if ( strpos( $m[1], '%' ) !== false ) {
-                                       $m[1] = rawurldecode( $m[1] );
+                                       $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
                                }
                                $trail = "";
                        } else { # Invalid form; output directly
@@ -4364,7 +4364,11 @@ class Parser {
                $this->startParse( $title, $options, self::OT_WIKI, $clearState );
                $this->setUser( $user );
 
-               $text = str_replace( [ "\r\n", "\r" ], "\n", $text );
+               // We still normalize line endings for backwards-compatibility
+               // with other code that just calls PST, but this should already
+               // be handled in TextContent subclasses
+               $text = TextContent::normalizeLineEndings( $text );
+
                if ( $options->getPreSaveTransform() ) {
                        $text = $this->pstPass2( $text, $user );
                }
@@ -4442,9 +4446,6 @@ class Parser {
                        $text = preg_replace( $p2, '[[\\1]]', $text );
                }
 
-               # Trim trailing whitespace
-               $text = rtrim( $text );
-
                return $text;
        }
 
index 85b8dc3..68f0d00 100644 (file)
@@ -536,7 +536,7 @@ abstract class AuthManagerSpecialPage extends SpecialPage {
                $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
                $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
                $form->addHiddenField( 'authAction', $this->authAction );
-               $form->suppressDefaultSubmit( !$this->needsSubmitButton( $formDescriptor ) );
+               $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
 
                return $form;
        }
@@ -554,24 +554,38 @@ abstract class AuthManagerSpecialPage extends SpecialPage {
        }
 
        /**
-        * Returns true if the form has fields which take values. If all available providers use the
-        * redirect flow, the form might contain nothing but submit buttons, in which case we should
-        * not add an extra submit button which does nothing.
+        * Returns true if the form built from the given AuthenticationRequests has fields which take
+        * values. If all available providers use the redirect flow, the form might contain nothing
+        * but submit buttons, in which case we should not add an extra submit button which does nothing.
         *
-        * @param array $formDescriptor A HTMLForm descriptor
+        * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
+        *  form will be built
         * @return bool
         */
-       protected function needsSubmitButton( $formDescriptor ) {
-               return (bool)array_filter( $formDescriptor, function ( $item ) {
-                       $class = false;
-                       if ( array_key_exists( 'class', $item ) ) {
-                               $class = $item['class'];
-                       } elseif ( array_key_exists( 'type', $item ) ) {
-                               $class = HTMLForm::$typeMappings[$item['type']];
+       protected function needsSubmitButton( array $requests ) {
+               foreach ( $requests as $req ) {
+                       if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED &&
+                               $this->doesRequestNeedsSubmitButton( $req )
+                       ) {
+                               return true;
                        }
-                       return !is_a( $class, \HTMLInfoField::class, true ) &&
-                               !is_a( $class, \HTMLSubmitField::class, true );
-               } );
+               }
+               return false;
+       }
+
+       /**
+        * Checks if the given AuthenticationRequest needs a submit button or not.
+        *
+        * @param AuthenticationRequest $req The request to check
+        * @return bool
+        */
+       protected function doesRequestNeedsSubmitButton( AuthenticationRequest $req ) {
+               foreach ( $req->getFieldInfo() as $field => $info ) {
+                       if ( $info['type'] === 'button' ) {
+                               return false;
+                       }
+               }
+               return true;
        }
 
        /**
index 8a2e0d6..22c38cb 100644 (file)
@@ -585,7 +585,7 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                $this->fakeTemplate = $fakeTemplate; // FIXME there should be a saner way to pass this to the hook
                // this will call onAuthChangeFormFields()
                $formDescriptor = static::fieldInfoToFormDescriptor( $requests, $fieldInfo, $this->authAction );
-               $this->postProcessFormDescriptor( $formDescriptor );
+               $this->postProcessFormDescriptor( $formDescriptor, $requests );
 
                $context = $this->getContext();
                if ( $context->getRequest() !== $this->getRequest() ) {
@@ -616,36 +616,6 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                        $form->setId( 'userlogin2' );
                }
 
-               // add pre/post text
-               // header used by ConfirmEdit, CondfirmAccount, Persona, WikimediaIncubator, SemanticSignup
-               // should be above the error message but HTMLForm doesn't support that
-               $form->addHeaderText( $fakeTemplate->get( 'header' ) );
-
-               // FIXME the old form used this for error/warning messages which does not play well with
-               // HTMLForm (maybe it could with a subclass?); for now only display it for signups
-               // (where the JS username validation needs it) and alway empty
-               if ( $this->isSignup() ) {
-                       // used by the mediawiki.special.userlogin.signup.js module
-                       $statusAreaAttribs = [ 'id' => 'mw-createacct-status-area' ];
-                       // $statusAreaAttribs += $msg ? [ 'class' => "{$msgType}box" ] : [ 'style' => 'display: none;' ];
-                       $form->addHeaderText( Html::element( 'div', $statusAreaAttribs ) );
-               }
-
-               // header used by MobileFrontend
-               $form->addHeaderText( $fakeTemplate->get( 'formheader' ) );
-
-               // blank signup footer for site customization
-               if ( $this->isSignup() && $this->showExtraInformation() ) {
-                       // Use signupend-https for HTTPS requests if it's not blank, signupend otherwise
-                       $signupendMsg = $this->msg( 'signupend' );
-                       $signupendHttpsMsg = $this->msg( 'signupend-https' );
-                       if ( !$signupendMsg->isDisabled() ) {
-                               $signupendText = ( $usingHTTPS && !$signupendHttpsMsg->isBlank() )
-                                       ? $signupendHttpsMsg ->parse() : $signupendMsg->parse();
-                               $form->addPostText( Html::rawElement( 'div', [ 'id' => 'signupend' ], $signupendText ) );
-                       }
-               }
-
                // warning header for non-standard workflows (e.g. security reauthentication)
                if ( !$this->isSignup() && $this->getUser()->isLoggedIn() ) {
                        $reauthMessage = $this->securityLevel ? 'userlogin-reauth' : 'userlogin-loggedin';
@@ -653,52 +623,6 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                                $this->msg( $reauthMessage )->params( $this->getUser()->getName() )->parse() ) );
                }
 
-               if ( !$this->isSignup() && $this->showExtraInformation() ) {
-                       $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
-                       if ( $passwordReset->isAllowed( $this->getUser() ) ) {
-                               $form->addFooterText( Html::rawElement(
-                                       'div',
-                                       [ 'class' => 'mw-ui-vform-field mw-form-related-link-container' ],
-                                       Linker::link(
-                                               SpecialPage::getTitleFor( 'PasswordReset' ),
-                                               $this->msg( 'userlogin-resetpassword-link' )->escaped()
-                                       )
-                               ) );
-                       }
-
-                       // Don't show a "create account" link if the user can't.
-                       if ( $this->showCreateAccountLink() ) {
-                               // link to the other action
-                               $linkTitle = $this->getTitleFor( $this->isSignup() ? 'Userlogin' :'CreateAccount' );
-                               $linkq = $this->getReturnToQueryStringFragment();
-                               // Pass any language selection on to the mode switch link
-                               if ( $wgLoginLanguageSelector && $this->mLanguage ) {
-                                       $linkq .= '&uselang=' . $this->mLanguage;
-                               }
-
-                               $loggedIn = $this->getUser()->isLoggedIn();
-                               $createOrLoginHtml = Html::rawElement( 'div',
-                                       [ 'id' => 'mw-createaccount' . ( !$loggedIn ? '-cta' : '' ),
-                                               'class' => ( $loggedIn ? 'mw-form-related-link-container' : 'mw-ui-vform-field' ) ],
-                                       ( $loggedIn ? '' : $this->msg( 'userlogin-noaccount' )->escaped() )
-                                       . Html::element( 'a',
-                                               [
-                                                       'id' => 'mw-createaccount-join' . ( $loggedIn ? '-loggedin' : '' ),
-                                                       'href' => $linkTitle->getLocalURL( $linkq ),
-                                                       'class' => ( $loggedIn ? '' : 'mw-ui-button' ),
-                                                       'tabindex' => 100,
-                                               ],
-                                               $this->msg(
-                                                       ( $this->getUser()->isLoggedIn() ?
-                                                               'userlogin-createanother' :
-                                                               'userlogin-joinproject'
-                                                       ) )->escaped()
-                                       )
-                               );
-                               $form->addFooterText( $createOrLoginHtml );
-                       }
-               }
-
                $form->suppressDefaultSubmit();
 
                $this->authForm = $form;
@@ -837,7 +761,7 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                array $requests, array $fieldInfo, array &$formDescriptor, $action
        ) {
                $coreFieldDescriptors = $this->getFieldDefinitions( $this->fakeTemplate );
-               $specialFields = array_merge( [ 'extraInput', 'linkcontainer', 'entryError' ],
+               $specialFields = array_merge( [ 'extraInput' ],
                        array_keys( $this->fakeTemplate->getExtraInputDefinitions() ) );
 
                // keep the ordering from getCoreFieldDescriptors() where there is no explicit weight
@@ -846,14 +770,19 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                                $formDescriptor[$fieldName] : [];
 
                        // remove everything that is not in the fieldinfo, is not marked as a supplemental field
-                       // to something in the fieldinfo, and is not a generic or B/C field or a submit button
+                       // to something in the fieldinfo, is not B/C for the pre-AuthManager templates,
+                       // and is not an info field or a submit button
                        if (
                                !isset( $fieldInfo[$fieldName] )
                                && (
                                        !isset( $coreField['baseField'] )
                                        || !isset( $fieldInfo[$coreField['baseField']] )
-                               ) && !in_array( $fieldName, $specialFields, true )
-                               && ( !isset( $coreField['type'] ) || $coreField['type'] !== 'submit' )
+                               )
+                               && !in_array( $fieldName, $specialFields, true )
+                               && (
+                                       !isset( $coreField['type'] )
+                                       || !in_array( $coreField['type'], [ 'submit', 'info' ], true )
+                               )
                        ) {
                                $coreFieldDescriptors[$fieldName] = null;
                                continue;
@@ -892,13 +821,12 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
         * @return array
         */
        protected function getFieldDefinitions( $template ) {
-               global $wgEmailConfirmToEdit;
+               global $wgEmailConfirmToEdit, $wgLoginLanguageSelector;
 
                $isLoggedIn = $this->getUser()->isLoggedIn();
                $continuePart = $this->isContinued() ? 'continue-' : '';
                $anotherPart = $isLoggedIn ? 'another-' : '';
-               $expiration = $this->getRequest()->getSession()->getProvider()
-                       ->getRememberUserDuration();
+               $expiration = $this->getRequest()->getSession()->getProvider()->getRememberUserDuration();
                $expirationDays = ceil( $expiration / ( 3600 * 24 ) );
                $secureLoginLink = '';
                if ( $this->mSecureLoginUrl ) {
@@ -910,6 +838,14 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
 
                if ( $this->isSignup() ) {
                        $fieldDefinitions = [
+                               'statusarea' => [
+                                       // used by the mediawiki.special.userlogin.signup.js module for error display
+                                       // FIXME merge this with HTMLForm's normal status (error) area
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'default' => Html::element( 'div', [ 'id' => 'mw-createacct-status-area' ] ),
+                                       'weight' => -105,
+                               ],
                                'username' => [
                                        'label-message' => 'userlogin-yourname',
                                        // FIXME help-message does not match old formatting
@@ -1056,6 +992,7 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                                ],
                        ];
                }
+
                $fieldDefinitions['username'] += [
                        'type' => 'text',
                        'name' => 'wpName',
@@ -1072,6 +1009,19 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                        // 'required' => true,
                ];
 
+               if ( $template->get( 'header' ) || $template->get( 'formheader' ) ) {
+                       // B/C for old extensions that haven't been converted to AuthManager (or have been
+                       // but somebody is using the old version) and still use templates via the
+                       // UserCreateForm/UserLoginForm hook.
+                       // 'header' used by ConfirmEdit, CondfirmAccount, Persona, WikimediaIncubator, SemanticSignup
+                       // 'formheader' used by MobileFrontend
+                       $fieldDefinitions['header'] = [
+                               'type' => 'info',
+                               'raw' => true,
+                               'default' => $template->get( 'header' ) ?: $template->get( 'formheader' ),
+                               'weight' => - 110,
+                       ];
+               }
                if ( $this->mEntryError ) {
                        $fieldDefinitions['entryError'] = [
                                'type' => 'info',
@@ -1082,9 +1032,77 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
                                'weight' => -100,
                        ];
                }
-
                if ( !$this->showExtraInformation() ) {
-                       unset( $fieldDefinitions['linkcontainer'] );
+                       unset( $fieldDefinitions['linkcontainer'], $fieldDefinitions['signupend'] );
+               }
+               if ( $this->isSignup() && $this->showExtraInformation() ) {
+                       // blank signup footer for site customization
+                       // uses signupend-https for HTTPS requests if it's not blank, signupend otherwise
+                       $signupendMsg = $this->msg( 'signupend' );
+                       $signupendHttpsMsg = $this->msg( 'signupend-https' );
+                       if ( !$signupendMsg->isDisabled() ) {
+                               $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+                               $signupendText = ( $usingHTTPS && !$signupendHttpsMsg->isBlank() )
+                                       ? $signupendHttpsMsg ->parse() : $signupendMsg->parse();
+                               $fieldDefinitions['signupend'] = [
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'default' => Html::rawElement( 'div', [ 'id' => 'signupend' ], $signupendText ),
+                                       'weight' => 225,
+                               ];
+                       }
+               }
+               if ( !$this->isSignup() && $this->showExtraInformation() ) {
+                       $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+                       if ( $passwordReset->isAllowed( $this->getUser() ) ) {
+                               $fieldDefinitions['passwordReset'] = [
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'cssclass' => 'mw-form-related-link-container',
+                                       'default' => Linker::link(
+                                               SpecialPage::getTitleFor( 'PasswordReset' ),
+                                               $this->msg( 'userlogin-resetpassword-link' )->escaped()
+                                       ),
+                                       'weight' => 230,
+                               ];
+                       }
+
+                       // Don't show a "create account" link if the user can't.
+                       if ( $this->showCreateAccountLink() ) {
+                               // link to the other action
+                               $linkTitle = $this->getTitleFor( $this->isSignup() ? 'Userlogin' :'CreateAccount' );
+                               $linkq = $this->getReturnToQueryStringFragment();
+                               // Pass any language selection on to the mode switch link
+                               if ( $wgLoginLanguageSelector && $this->mLanguage ) {
+                                       $linkq .= '&uselang=' . $this->mLanguage;
+                               }
+                               $loggedIn = $this->getUser()->isLoggedIn();
+
+                               $fieldDefinitions['createOrLogin'] = [
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'linkQuery' => $linkq,
+                                       'default' => function ( $params ) use ( $loggedIn, $linkTitle ) {
+                                               return Html::rawElement( 'div',
+                                                       [ 'id' => 'mw-createaccount' . ( !$loggedIn ? '-cta' : '' ),
+                                                               'class' => ( $loggedIn ? 'mw-form-related-link-container' : 'mw-ui-vform-field' ) ],
+                                                       ( $loggedIn ? '' : $this->msg( 'userlogin-noaccount' )->escaped() )
+                                                       . Html::element( 'a',
+                                                               [
+                                                                       'id' => 'mw-createaccount-join' . ( $loggedIn ? '-loggedin' : '' ),
+                                                                       'href' => $linkTitle->getLocalURL( $params['linkQuery'] ),
+                                                                       'class' => ( $loggedIn ? '' : 'mw-ui-button' ),
+                                                                       'tabindex' => 100,
+                                                               ],
+                                                               $this->msg(
+                                                                       $loggedIn ? 'userlogin-createanother' : 'userlogin-joinproject'
+                                                               )->escaped()
+                                                       )
+                                               );
+                                       },
+                                       'weight' => 235,
+                               ];
+                       }
                }
 
                $fieldDefinitions = $this->getBCFieldDefinitions( $fieldDefinitions, $template );
@@ -1237,7 +1255,7 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
        /**
         * @param array $formDescriptor
         */
-       protected function postProcessFormDescriptor( &$formDescriptor ) {
+       protected function postProcessFormDescriptor( &$formDescriptor, $requests ) {
                // Pre-fill username (if not creating an account, T46775).
                if (
                        isset( $formDescriptor['username'] ) &&
@@ -1255,7 +1273,7 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
 
                // don't show a submit button if there is nothing to submit (i.e. the only form content
                // is other submit buttons, for redirect flows)
-               if ( !$this->needsSubmitButton( $formDescriptor ) ) {
+               if ( !$this->needsSubmitButton( $requests ) ) {
                        unset( $formDescriptor['createaccount'], $formDescriptor['loginattempt'] );
                }
 
index d98504d..ff70848 100644 (file)
@@ -149,7 +149,7 @@ class SpecialChangeCredentials extends AuthManagerSpecialPage {
                return $form;
        }
 
-       protected function needsSubmitButton( $formDescriptor ) {
+       protected function needsSubmitButton( array $requests ) {
                // Change/remove forms show are built from a single AuthenticationRequest and do not allow
                // for redirect flow; they always need a submit button.
                return true;
index d11fbe6..9cb6d4b 100644 (file)
@@ -91,7 +91,7 @@ class SpecialMyLanguage extends RedirectSpecialArticle {
 
                $uiCode = $this->getLanguage()->getCode();
                $proposed = $base->getSubpage( $uiCode );
-               if ( $uiCode !== $this->getConfig()->get( 'LanguageCode' ) && $proposed && $proposed->exists() ) {
+               if ( $proposed && $proposed->exists() && $uiCode !== $base->getPageLanguage()->getCode() ) {
                        return $proposed;
                } elseif ( $provided && $provided->exists() ) {
                        return $provided;
index ce5533f..e1e2049 100644 (file)
@@ -40,7 +40,6 @@ class SpecialRunJobs extends UnlistedSpecialPage {
 
        public function execute( $par = '' ) {
                $this->getOutput()->disable();
-
                if ( wfReadOnly() ) {
                        // HTTP 423 Locked
                        HttpStatus::header( 423 );
index 83cfa40..a9ccc4e 100644 (file)
@@ -3134,6 +3134,7 @@ class User implements IDBAccessObject {
        public function getRights() {
                if ( is_null( $this->mRights ) ) {
                        $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() );
+                       Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] );
 
                        // Deny any rights denied by the user's session, unless this
                        // endpoint has no sessions.
@@ -3144,9 +3145,24 @@ class User implements IDBAccessObject {
                                }
                        }
 
-                       Hooks::run( 'UserGetRights', [ $this, &$this->mRights ] );
                        // Force reindexation of rights when a hook has unset one of them
                        $this->mRights = array_values( array_unique( $this->mRights ) );
+
+                       // If block disables login, we should also remove any
+                       // extra rights blocked users might have, in case the
+                       // blocked user has a pre-existing session (T129738).
+                       // This is checked here for cases where people only call
+                       // $user->isAllowed(). It is also checked in Title::checkUserBlock()
+                       // to give a better error message in the common case.
+                       $config = RequestContext::getMain()->getConfig();
+                       if (
+                               $this->isLoggedIn() &&
+                               $config->get( 'BlockDisablesLogin' ) &&
+                               $this->isBlocked()
+                       ) {
+                               $anon = new User;
+                               $this->mRights = array_intersect( $this->mRights, $anon->getRights() );
+                       }
                }
                return $this->mRights;
        }
@@ -3961,7 +3977,6 @@ class User implements IDBAccessObject {
                $noPass = PasswordFactory::newInvalidPassword()->toString();
 
                $dbw = wfGetDB( DB_MASTER );
-               $inWrite = $dbw->writesOrCallbacksPending();
                $seqVal = $dbw->nextSequenceValue( 'user_user_id_seq' );
                $dbw->insert( 'user',
                        [
@@ -3980,25 +3995,17 @@ class User implements IDBAccessObject {
                        [ 'IGNORE' ]
                );
                if ( !$dbw->affectedRows() ) {
-                       // The queries below cannot happen in the same REPEATABLE-READ snapshot.
-                       // Handle this by COMMIT, if possible, or by LOCK IN SHARE MODE otherwise.
-                       if ( $inWrite ) {
-                               // Can't commit due to pending writes that may need atomicity.
-                               // This may cause some lock contention unlike the case below.
-                               $options = [ 'LOCK IN SHARE MODE' ];
-                               $flags = self::READ_LOCKING;
-                       } else {
-                               // Often, this case happens early in views before any writes when
-                               // using CentralAuth. It's should be OK to commit and break the snapshot.
-                               $dbw->commit( __METHOD__, 'flush' );
-                               $options = [];
-                               $flags = self::READ_LATEST;
-                       }
-                       $this->mId = $dbw->selectField( 'user', 'user_id',
-                               [ 'user_name' => $this->mName ], __METHOD__, $options );
+                       // Use locking reads to bypass any REPEATABLE-READ snapshot.
+                       $this->mId = $dbw->selectField(
+                               'user',
+                               'user_id',
+                               [ 'user_name' => $this->mName ],
+                               __METHOD__,
+                               [ 'LOCK IN SHARE MODE' ]
+                       );
                        $loaded = false;
                        if ( $this->mId ) {
-                               if ( $this->loadFromDatabase( $flags ) ) {
+                               if ( $this->loadFromDatabase( self::READ_LOCKING ) ) {
                                        $loaded = true;
                                }
                        }
index ffb7053..a6e47c8 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  * @ingroup Maintenance
  */
+use \MediaWiki\MediaWikiServices;
+
 class BatchRowWriter {
        /**
         * @var IDatabase $db The database to write to
@@ -54,7 +56,8 @@ class BatchRowWriter {
         *  names to update values to apply to the row.
         */
        public function write( array $updates ) {
-               $this->db->begin();
+               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+               $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
 
                foreach ( $updates as $update ) {
                        $this->db->update(
@@ -65,7 +68,6 @@ class BatchRowWriter {
                        );
                }
 
-               $this->db->commit();
-               wfGetLBFactory()->waitForReplication();
+               $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
        }
 }
index 8b39d77..cb3b4b8 100644 (file)
@@ -1036,6 +1036,7 @@ class Language {
         *    xin  n (month number) in Iranian calendar
         *    xiy  y (two digit year) in Iranian calendar
         *    xiY  Y (full year) in Iranian calendar
+        *    xit  t (days in month) in Iranian calendar
         *
         *    xjj  j (day number) in Hebrew calendar
         *    xjF  F (month name) in Hebrew calendar
@@ -1331,6 +1332,13 @@ class Language {
                                        }
                                        $num = substr( $iranian[0], -2 );
                                        break;
+                               case 'xit':
+                                       $usedIranianYear = true;
+                                       if ( !$iranian ) {
+                                               $iranian = self::tsToIranian( $ts );
+                                       }
+                                       $num = self::$IRANIAN_DAYS[$iranian[1] - 1];
+                                       break;
                                case 'a':
                                        $usedAMPM = true;
                                        $s .= intval( substr( $ts, 8, 2 ) ) < 12 ? 'am' : 'pm';
index e1c37c8..892ac2c 100644 (file)
        "linkaccounts-submit": "Link accounts",
        "unlinkaccounts": "Unlink accounts",
        "unlinkaccounts-success": "The account was unlinked.",
-       "authenticationdatachange-ignored": "The authentication data change was not handled. Maybe no provider was configured?"
+       "authenticationdatachange-ignored": "The authentication data change was not handled. Maybe no provider was configured?",
+       "userjsispublic": "Please note: JavaScript subpages should not contain confidential data as they are viewable by other users.",
+       "usercssispublic": "Please note: CSS subpages should not contain confidential data as they are viewable by other users."
 }
index 6a3d604..2c5041c 100644 (file)
        "linkaccounts-submit": "Text of the main submit button on [[Special:LinkAccounts]] (when there is one)",
        "unlinkaccounts": "Title of the special page [[Special:UnlinkAccounts]] which allows the user to remove linked remote accounts.",
        "unlinkaccounts-success": "Account unlinking form success message",
-       "authenticationdatachange-ignored": "Shown when authentication data change was unsuccessful due to configuration problems.\n\nCf. e.g. {{msg-mw|Passwordreset-ignored}}."
+       "authenticationdatachange-ignored": "Shown when authentication data change was unsuccessful due to configuration problems.\n\nCf. e.g. {{msg-mw|Passwordreset-ignored}}.",
+       "userjsispublic": "A reminder to users that Javascript subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .js. See also {{msg-mw|usercssispublic}}.",
+       "usercssispublic": "A reminder to users that CSS subpages are not preferences but normal pages, and thus can be viewed by other users and the general public. This message is shown to a user whenever they are editing a subpage in their own user-space that ends in .css. See also {{msg-mw|userjsispublic}}"
 }
index 1fa701e..2d04f63 100644 (file)
@@ -250,8 +250,8 @@ $namespaceNames = [
        NS_MEDIA            => 'Médiá',
        NS_SPECIAL          => 'Å peciálne',
        NS_TALK             => 'Diskusia',
-       NS_USER             => 'Redaktor',
-       NS_USER_TALK        => 'Diskusia_s_redaktorom',
+       NS_USER             => 'Užívateľ',
+       NS_USER_TALK        => 'Diskusia_s_užívateľom',
        NS_PROJECT_TALK     => 'Diskusia_k_{{GRAMMAR:datív|$1}}',
        NS_FILE             => 'Súbor',
        NS_FILE_TALK        => 'Diskusia_k_súboru',
@@ -267,6 +267,8 @@ $namespaceNames = [
 
 $namespaceAliases = [
        "Komentár"               => NS_TALK,
+       'Redaktor'               => NS_USER,
+       'Diskusia_s_redaktorom'  => NS_USER_TALK,
        "Komentár_k_redaktorovi" => NS_USER_TALK,
        "Komentár_k_Wikipédii"   => NS_PROJECT_TALK,
        'Obrázok' => NS_FILE,
@@ -275,6 +277,11 @@ $namespaceAliases = [
        "Komentár_k_MediaWiki"   => NS_MEDIAWIKI_TALK,
 ];
 
+$namespaceGenderAliases = [
+       NS_USER => [ 'male' => 'Užívateľ', 'female' => 'Užívateľka' ],
+       NS_USER_TALK => [ 'male' => 'Diskusia_s_užívateľom', 'female' => 'Diskusia_s_užívateľkou' ],
+];
+
 $separatorTransformTable = [
        ',' => "\xc2\xa0",
        '.' => ','
index 8fd25a6..d6a9ba8 100644 (file)
@@ -596,6 +596,8 @@ class NamespaceConflictChecker extends Maintenance {
 
                $this->db->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
 
+               $this->commitTransaction( $this->db, __METHOD__ );
+
                /* Call LinksDeletionUpdate to delete outgoing links from the old title,
                 * and update category counts.
                 *
@@ -605,9 +607,8 @@ class NamespaceConflictChecker extends Maintenance {
                 * accidentally introduce an assumption of title validity to the code we
                 * are calling.
                 */
-               $update = new LinksDeletionUpdate( $wikiPage );
-               $update->doUpdate();
-               $this->commitTransaction( $this->db, __METHOD__ );
+               $updates = [ new LinksDeletionUpdate( $wikiPage ) ];
+               DataUpdate::runUpdates( $updates );
 
                return true;
        }
index 9cf7b2b..bd34a50 100644 (file)
@@ -8,7 +8,7 @@ class ValidateRegistrationFile extends Maintenance {
                $this->addArg( 'path', 'Path to extension.json/skin.json file.', true );
        }
        public function execute() {
-               if ( !class_exists( 'JsonSchema\Uri\UriRetriever' ) ) {
+               if ( !class_exists( 'JsonSchema\Validato' ) ) {
                        $this->error( 'The JsonSchema library cannot be found, please install it through composer.', 1 );
                }
 
@@ -38,11 +38,8 @@ class ValidateRegistrationFile extends Maintenance {
                        $this->output( "Warning: $path is using a deprecated schema, and should be updated to "
                                . ExtensionRegistry::MANIFEST_VERSION . "\n" );
                }
-               $retriever = new JsonSchema\Uri\UriRetriever();
-               $schema = $retriever->retrieve( 'file://' . $schemaPath );
-
-               $validator = new JsonSchema\Validator();
-               $validator->check( $data, $schema );
+               $validator = new JsonSchema\Validator;
+               $validator->check( $data, (object) [ '$ref' => 'file://' . $schemaPath ] );
                if ( $validator->isValid() ) {
                        $this->output( "$path validates against the version $version schema!\n" );
                } else {
index 501e898..ac60e8f 100644 (file)
                                        }
 
                                } else if ( $collapsible.parent().is( 'li' ) &&
-                                       $collapsible.parent().children( '.mw-collapsible' ).size() === 1
+                                       $collapsible.parent().children( '.mw-collapsible' ).length === 1 &&
+                                       $collapsible.find( '> .mw-collapsible-toggle' ).length === 0
                                ) {
                                        // special case of one collapsible in <li> tag
                                        $toggleLink = buildDefaultToggleLink();
index b4edc50..0035601 100644 (file)
@@ -33,8 +33,8 @@
        // Standalone icons
        //
        // Markup:
-       // <div class="mw-ui-icon mw-ui-icon-element mw-ui-icon-ok">OK</div><br/>
-       // <div class="mw-ui-icon mw-ui-icon-element mw-ui-icon-ok mw-ui-button mw-ui-progressive">OK</div><br/>
+       // <div class="mw-ui-icon mw-ui-icon-element mw-ui-icon-ok">OK</div><br>
+       // <div class="mw-ui-icon mw-ui-icon-element mw-ui-icon-ok mw-ui-button mw-ui-progressive">OK</div><br>
        // <button class="mw-ui-icon mw-ui-icon-ok mw-ui-icon-element mw-ui-button mw-ui-quiet" title="">Close</button>
        //
        // Styleguide 6.1.1.
                        margin-right: @iconGutterWidth;
                }
        }
-}
+
+       // Icons small for elements like indicators
+       //
+       // Markup:
+       // <div class="mw-ui-icon mw-ui-icon-small mw-ui-icon-help"></div>
+       //
+       // Styleguide 6.1.3
+       &.mw-ui-icon-small:before {
+               background-size: 66.67% auto; // 66.67% of 24px equals 16px
+       }
+}
\ No newline at end of file
index 0bdf02e..946823d 100644 (file)
                        $.each( response.query.pages, function ( index, page ) {
                                var title = new ForeignTitle( page.title ).getPrefixedText();
                                cache.existenceCache[ title ] = !page.missing;
+                               if ( !queue[ title ] ) {
+                                       // Debugging for T139130
+                                       throw new Error( 'No queue for "' + title + '", requested "' + titles.join( '|' ) + '"' );
+                               }
                                queue[ title ].resolve( cache.existenceCache[ title ] );
                        } );
                } );
index 7b7ef3d..86018a4 100644 (file)
                border-radius: 0.1em;
                line-height: 1.275em;
                background-color: #fff;
+
+               > .oo-ui-labelElement-label {
+                       padding: 0;
+               }
        }
 
        &.oo-ui-indicatorElement .mw-widget-dateInputWidget-handle > .oo-ui-indicatorElement-indicator {
index 11784fb..491564a 100644 (file)
                         * State machine:
                         *
                         * - `registered`:
-                        *    The module is known to the system but not yet requested.
+                        *    The module is known to the system but not yet required.
                         *    Meta data is registered via mw.loader#register. Calls to that method are
                         *    generated server-side by the startup module.
                         * - `loading`:
-                        *    The module is requested through mw.loader (either directly or as dependency of
-                        *    another module). The client will be fetching module contents from the server.
+                        *    The module was required through mw.loader (either directly or as dependency of
+                        *    another module). The client will fetch module contents from the server.
                         *    The contents are then stashed in the registry via mw.loader#implement.
                         * - `loaded`:
-                        *    The module has been requested from the server and stashed via mw.loader#implement.
-                        *    If the module has no more dependencies in-fight, the module will be executed
-                        *    right away. Otherwise execution is deferred, controlled via #handlePending.
+                        *    The module has been loaded from the server and stashed via mw.loader#implement.
+                        *    If the module has no more dependencies in-flight, the module will be executed
+                        *    immediately. Otherwise execution is deferred, controlled via #handlePending.
                         * - `executing`:
                         *    The module is being executed.
                         * - `ready`:
                                //
                                sources = {},
 
-                               // List of modules which will be loaded as when ready
-                               batch = [],
-
-                               // Pending queueModuleScript() requests
+                               // For queueModuleScript()
                                handlingPendingRequests = false,
                                pendingRequests = [],
 
                                /**
                                 * List of callback jobs waiting for modules to be ready.
                                 *
-                                * Jobs are created by #request() and run by #handlePending().
+                                * Jobs are created by #enqueue() and run by #handlePending().
                                 *
                                 * Typically when a job is created for a module, the job's dependencies contain
-                                * both the module being requested and all its recursive dependencies.
+                                * both the required module and all its recursive dependencies.
                                 *
                                 * Format:
                                 *
                        }
 
                        /**
-                        * Adds all dependencies to the queue with optional callbacks to be run
-                        * when the dependencies are ready or fail
+                        * Add one or more modules to the module load queue.
+                        *
+                        * See also #work().
                         *
                         * @private
                         * @param {string|string[]} dependencies Module name or array of string module names
                         * @param {Function} [ready] Callback to execute when all dependencies are ready
                         * @param {Function} [error] Callback to execute when any dependency fails
                         */
-                       function request( dependencies, ready, error ) {
+                       function enqueue( dependencies, ready, error ) {
                                // Allow calling by single module name
                                if ( typeof dependencies === 'string' ) {
                                        dependencies = [ dependencies ];
                        }
 
                        /**
-                        * Load modules from load.php
+                        * Make a network request to load modules from the server.
                         *
                         * @private
                         * @param {Object} moduleMap Module map, see #buildModulesString
                         * @param {string} sourceLoadScript URL of load.php
                         */
                        function doRequest( moduleMap, currReqBase, sourceLoadScript ) {
-                               var request = $.extend(
+                               var query = $.extend(
                                        { modules: buildModulesString( moduleMap ) },
                                        currReqBase
                                );
-                               request = sortQuery( request );
-                               addScript( sourceLoadScript + '?' + $.param( request ) );
+                               query = sortQuery( query );
+                               addScript( sourceLoadScript + '?' + $.param( query ) );
                        }
 
                        /**
                                } );
                        }
 
+                       /**
+                        * Create network requests for a batch of modules.
+                        *
+                        * This is an internal method for #work(). This must not be called directly
+                        * unless the modules are already registered, and no request is in progress,
+                        * and the module state has already been set to `loading`.
+                        *
+                        * @private
+                        * @param {string[]} batch
+                        */
+                       function batchRequest( batch ) {
+                               var reqBase, splits, maxQueryLength, b, bSource, bGroup, bSourceGroup,
+                                       source, group, i, modules, sourceLoadScript,
+                                       currReqBase, currReqBaseLength, moduleMap, l,
+                                       lastDotIndex, prefix, suffix, bytesAdded;
+
+                               if ( !batch.length ) {
+                                       return;
+                               }
+
+                               // Always order modules alphabetically to help reduce cache
+                               // misses for otherwise identical content.
+                               batch.sort();
+
+                               // Build a list of query parameters common to all requests
+                               reqBase = {
+                                       skin: mw.config.get( 'skin' ),
+                                       lang: mw.config.get( 'wgUserLanguage' ),
+                                       debug: mw.config.get( 'debug' )
+                               };
+                               maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', 2000 );
+
+                               // Split module list by source and by group.
+                               splits = {};
+                               for ( b = 0; b < batch.length; b++ ) {
+                                       bSource = registry[ batch[ b ] ].source;
+                                       bGroup = registry[ batch[ b ] ].group;
+                                       if ( !hasOwn.call( splits, bSource ) ) {
+                                               splits[ bSource ] = {};
+                                       }
+                                       if ( !hasOwn.call( splits[ bSource ], bGroup ) ) {
+                                               splits[ bSource ][ bGroup ] = [];
+                                       }
+                                       bSourceGroup = splits[ bSource ][ bGroup ];
+                                       bSourceGroup.push( batch[ b ] );
+                               }
+
+                               for ( source in splits ) {
+
+                                       sourceLoadScript = sources[ source ];
+
+                                       for ( group in splits[ source ] ) {
+
+                                               // Cache access to currently selected list of
+                                               // modules for this group from this source.
+                                               modules = splits[ source ][ group ];
+
+                                               currReqBase = $.extend( {
+                                                       version: getCombinedVersion( modules )
+                                               }, reqBase );
+                                               // For user modules append a user name to the query string.
+                                               if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) {
+                                                       currReqBase.user = mw.config.get( 'wgUserName' );
+                                               }
+                                               currReqBaseLength = $.param( currReqBase ).length;
+                                               // We may need to split up the request to honor the query string length limit,
+                                               // so build it piece by piece.
+                                               l = currReqBaseLength + 9; // '&modules='.length == 9
+
+                                               moduleMap = {}; // { prefix: [ suffixes ] }
+
+                                               for ( i = 0; i < modules.length; i++ ) {
+                                                       // Determine how many bytes this module would add to the query string
+                                                       lastDotIndex = modules[ i ].lastIndexOf( '.' );
+
+                                                       // If lastDotIndex is -1, substr() returns an empty string
+                                                       prefix = modules[ i ].substr( 0, lastDotIndex );
+                                                       suffix = modules[ i ].slice( lastDotIndex + 1 );
+
+                                                       bytesAdded = hasOwn.call( moduleMap, prefix )
+                                                               ? suffix.length + 3 // '%2C'.length == 3
+                                                               : modules[ i ].length + 3; // '%7C'.length == 3
+
+                                                       // If the url would become too long, create a new one,
+                                                       // but don't create empty requests
+                                                       if ( maxQueryLength > 0 && !$.isEmptyObject( moduleMap ) && l + bytesAdded > maxQueryLength ) {
+                                                               // This url would become too long, create a new one, and start the old one
+                                                               doRequest( moduleMap, currReqBase, sourceLoadScript );
+                                                               moduleMap = {};
+                                                               l = currReqBaseLength + 9;
+                                                               mw.track( 'resourceloader.splitRequest', { maxQueryLength: maxQueryLength } );
+                                                       }
+                                                       if ( !hasOwn.call( moduleMap, prefix ) ) {
+                                                               moduleMap[ prefix ] = [];
+                                                       }
+                                                       moduleMap[ prefix ].push( suffix );
+                                                       l += bytesAdded;
+                                               }
+                                               // If there's anything left in moduleMap, request that too
+                                               if ( !$.isEmptyObject( moduleMap ) ) {
+                                                       doRequest( moduleMap, currReqBase, sourceLoadScript );
+                                               }
+                                       }
+                               }
+                       }
+
                        /* Public Members */
                        return {
                                /**
                                addStyleTag: newStyleTag,
 
                                /**
-                                * Batch-request queued dependencies from the server.
+                                * Start loading of all queued module dependencies.
                                 *
                                 * @protected
                                 */
                                work: function () {
-                                       var     reqBase, splits, maxQueryLength, q, b, bSource, bGroup, bSourceGroup,
-                                               source, concatSource, origBatch, group, i, modules, sourceLoadScript,
-                                               currReqBase, currReqBaseLength, moduleMap, l,
-                                               lastDotIndex, prefix, suffix, bytesAdded;
-
-                                       // Build a list of request parameters common to all requests.
-                                       reqBase = {
-                                               skin: mw.config.get( 'skin' ),
-                                               lang: mw.config.get( 'wgUserLanguage' ),
-                                               debug: mw.config.get( 'debug' )
-                                       };
-                                       // Split module batch by source and by group.
-                                       splits = {};
-                                       maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', 2000 );
+                                       var q, batch, concatSource, origBatch;
+
+                                       batch = [];
 
                                        // Appends a list of modules from the queue to the batch
                                        for ( q = 0; q < queue.length; q++ ) {
-                                               // Only request modules which are registered
+                                               // Only load modules which are registered
                                                if ( hasOwn.call( registry, queue[ q ] ) && registry[ queue[ q ] ].state === 'registered' ) {
                                                        // Prevent duplicate entries
                                                        if ( $.inArray( queue[ q ], batch ) === -1 ) {
                                                }
                                        }
 
-                                       // Early exit if there's nothing to load...
-                                       if ( !batch.length ) {
-                                               return;
-                                       }
-
-                                       // The queue has been processed into the batch, clear up the queue.
+                                       // Now that the queue has been processed into a batch, clear up the queue.
+                                       // This MUST happen before we initiate any network request. Else it's possible
+                                       // that a script will be locally cached, instantly load, and work the queue
+                                       // again; all before we've cleared it causing each request to include modules
+                                       // which are already loaded.
                                        queue = [];
 
-                                       // Always order modules alphabetically to help reduce cache
-                                       // misses for otherwise identical content.
-                                       batch.sort();
-
-                                       // Split batch by source and by group.
-                                       for ( b = 0; b < batch.length; b++ ) {
-                                               bSource = registry[ batch[ b ] ].source;
-                                               bGroup = registry[ batch[ b ] ].group;
-                                               if ( !hasOwn.call( splits, bSource ) ) {
-                                                       splits[ bSource ] = {};
-                                               }
-                                               if ( !hasOwn.call( splits[ bSource ], bGroup ) ) {
-                                                       splits[ bSource ][ bGroup ] = [];
-                                               }
-                                               bSourceGroup = splits[ bSource ][ bGroup ];
-                                               bSourceGroup.push( batch[ b ] );
-                                       }
-
-                                       // Clear the batch - this MUST happen before we append any
-                                       // script elements to the body or it's possible that a script
-                                       // will be locally cached, instantly load, and work the batch
-                                       // again, all before we've cleared it causing each request to
-                                       // include modules which are already loaded.
-                                       batch = [];
-
-                                       for ( source in splits ) {
-
-                                               sourceLoadScript = sources[ source ];
-
-                                               for ( group in splits[ source ] ) {
-
-                                                       // Cache access to currently selected list of
-                                                       // modules for this group from this source.
-                                                       modules = splits[ source ][ group ];
-
-                                                       currReqBase = $.extend( {
-                                                               version: getCombinedVersion( modules )
-                                                       }, reqBase );
-                                                       // For user modules append a user name to the request.
-                                                       if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) {
-                                                               currReqBase.user = mw.config.get( 'wgUserName' );
-                                                       }
-                                                       currReqBaseLength = $.param( currReqBase ).length;
-                                                       // We may need to split up the request to honor the query string length limit,
-                                                       // so build it piece by piece.
-                                                       l = currReqBaseLength + 9; // '&modules='.length == 9
-
-                                                       moduleMap = {}; // { prefix: [ suffixes ] }
-
-                                                       for ( i = 0; i < modules.length; i++ ) {
-                                                               // Determine how many bytes this module would add to the query string
-                                                               lastDotIndex = modules[ i ].lastIndexOf( '.' );
-
-                                                               // If lastDotIndex is -1, substr() returns an empty string
-                                                               prefix = modules[ i ].substr( 0, lastDotIndex );
-                                                               suffix = modules[ i ].slice( lastDotIndex + 1 );
-
-                                                               bytesAdded = hasOwn.call( moduleMap, prefix )
-                                                                       ? suffix.length + 3 // '%2C'.length == 3
-                                                                       : modules[ i ].length + 3; // '%7C'.length == 3
-
-                                                               // If the request would become too long, create a new one,
-                                                               // but don't create empty requests
-                                                               if ( maxQueryLength > 0 && !$.isEmptyObject( moduleMap ) && l + bytesAdded > maxQueryLength ) {
-                                                                       // This request would become too long, create a new one
-                                                                       // and fire off the old one
-                                                                       doRequest( moduleMap, currReqBase, sourceLoadScript );
-                                                                       moduleMap = {};
-                                                                       l = currReqBaseLength + 9;
-                                                                       mw.track( 'resourceloader.splitRequest', { maxQueryLength: maxQueryLength } );
-                                                               }
-                                                               if ( !hasOwn.call( moduleMap, prefix ) ) {
-                                                                       moduleMap[ prefix ] = [];
-                                                               }
-                                                               moduleMap[ prefix ].push( suffix );
-                                                               l += bytesAdded;
-                                                       }
-                                                       // If there's anything left in moduleMap, request that too
-                                                       if ( !$.isEmptyObject( moduleMap ) ) {
-                                                               doRequest( moduleMap, currReqBase, sourceLoadScript );
-                                                       }
-                                               }
-                                       }
+                                       batchRequest( batch );
                                },
 
                                /**
                                /**
                                 * Implement a module given the components that make up the module.
                                 *
-                                * When #load or #using requests one or more modules, the server
+                                * When #load() or #using() requests one or more modules, the server
                                 * response contain calls to this function.
                                 *
                                 * @param {string} module Name of module
                                                        dependencies
                                                );
                                        } else {
-                                               // Not all dependencies are ready: queue up a request
-                                               request( dependencies, function () {
+                                               // Not all dependencies are ready, add to the load queue
+                                               enqueue( dependencies, function () {
                                                        deferred.resolve( mw.loader.require );
                                                }, deferred.reject );
                                        }
                                        if ( allReady( filtered ) || anyFailed( filtered ) ) {
                                                return;
                                        }
-                                       // Since some modules are not yet ready, queue up a request.
-                                       request( filtered, undefined, undefined );
+                                       // Some modules are not yet ready, add to module load queue.
+                                       enqueue( filtered, undefined, undefined );
                                },
 
                                /**
index d6d2b29..c7bbc62 100644 (file)
@@ -27140,3 +27140,12 @@ Thumbnail output
 </div>
 </div>
 !! end
+
+!! test
+unclosed internal link XSS (T137264)
+!! wikitext
+[[#%3Cscript%3Ealert(1)%3C/script%3E|
+!! html
+<p>[[#&lt;script&gt;alert(1)&lt;/script&gt;|
+</p>
+!! end
index b4ae765..b290f8f 100644 (file)
@@ -459,4 +459,30 @@ class TextContentTest extends MediaWikiLangTestCase {
                        $this->assertEquals( $expectedNative, $converted->getNativeData() );
                }
        }
+
+       /**
+        * @covers TextContent::normalizeLineEndings
+        * @dataProvider provideNormalizeLineEndings
+        */
+       public function testNormalizeLineEndings( $input, $expected ) {
+               $this->assertEquals( $expected, TextContent::normalizeLineEndings( $input ) );
+       }
+
+       public static function provideNormalizeLineEndings() {
+               return [
+                       [
+                               "Foo\r\nbar",
+                               "Foo\nbar"
+                       ],
+                       [
+                               "Foo\rbar",
+                               "Foo\nbar"
+                       ],
+                       [
+                               "Foobar\n  ",
+                               "Foobar"
+                       ]
+               ];
+       }
+
 }
index 7fe8055..a01cc6b 100644 (file)
@@ -5,6 +5,10 @@
  */
 class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
 
+       /**
+        * @covers CachedBagOStuff::__construct
+        * @covers CachedBagOStuff::doGet
+        */
        public function testGetFromBackend() {
                $backend = new HashBagOStuff;
                $cache = new CachedBagOStuff( $backend );
@@ -16,6 +20,10 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
                $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' );
        }
 
+       /**
+        * @covers CachedBagOStuff::set
+        * @covers CachedBagOStuff::delete
+        */
        public function testSetAndDelete() {
                $backend = new HashBagOStuff;
                $cache = new CachedBagOStuff( $backend );
@@ -30,6 +38,10 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
                }
        }
 
+       /**
+        * @covers CachedBagOStuff::set
+        * @covers CachedBagOStuff::delete
+        */
        public function testWriteCacheOnly() {
                $backend = new HashBagOStuff;
                $cache = new CachedBagOStuff( $backend );
@@ -50,6 +62,9 @@ class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
                $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend
        }
 
+       /**
+        * @covers CachedBagOStuff::doGet
+        */
        public function testCacheBackendMisses() {
                $backend = new HashBagOStuff;
                $cache = new CachedBagOStuff( $backend );
index fce09ae..c4db0cf 100644 (file)
@@ -5,6 +5,9 @@
  */
 class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
 
+       /**
+        * @covers HashBagOStuff::delete
+        */
        public function testDelete() {
                $cache = new HashBagOStuff();
                for ( $i = 0; $i < 10; $i++ ) {
@@ -15,6 +18,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
                }
        }
 
+       /**
+        * @covers HashBagOStuff::clear
+        */
        public function testClear() {
                $cache = new HashBagOStuff();
                for ( $i = 0; $i < 10; $i++ ) {
@@ -27,6 +33,10 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
                }
        }
 
+       /**
+        * @covers HashBagOStuff::doGet
+        * @covers HashBagOStuff::expire
+        */
        public function testExpire() {
                $cache = new HashBagOStuff();
                $cacheInternal = TestingAccessWrapper::newFromObject( $cache );
@@ -45,6 +55,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
 
        /**
         * Ensure maxKeys eviction prefers keeping new keys.
+        *
+        * @covers HashBagOStuff::__construct
+        * @covers HashBagOStuff::set
         */
        public function testEvictionAdd() {
                $cache = new HashBagOStuff( [ 'maxKeys' => 10 ] );
@@ -62,6 +75,9 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
        /**
         * Ensure maxKeys eviction prefers recently set keys
         * even if the keys pre-exist.
+        *
+        * @covers HashBagOStuff::__construct
+        * @covers HashBagOStuff::set
         */
        public function testEvictionSet() {
                $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
@@ -85,6 +101,10 @@ class HashBagOStuffTest extends PHPUnit_Framework_TestCase {
 
        /**
         * Ensure maxKeys eviction prefers recently retrieved keys (LRU).
+        *
+        * @covers HashBagOStuff::__construct
+        * @covers HashBagOStuff::doGet
+        * @covers HashBagOStuff::hasKey
         */
        public function testEvictionGet() {
                $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
index 6df74d6..38d63e3 100644 (file)
@@ -23,6 +23,10 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase {
                ] );
        }
 
+       /**
+        * @covers MultiWriteBagOStuff::set
+        * @covers MultiWriteBagOStuff::doWrite
+        */
        public function testSetImmediate() {
                $key = wfRandomString();
                $value = wfRandomString();
@@ -34,6 +38,9 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase {
                $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
        }
 
+       /**
+        * @covers MultiWriteBagOStuff
+        */
        public function testSyncMerge() {
                $key = wfRandomString();
                $value = wfRandomString();
@@ -69,6 +76,9 @@ class MultiWriteBagOStuffTest extends MediaWikiTestCase {
                $dbw->commit();
        }
 
+       /**
+        * @covers MultiWriteBagOStuff::set
+        */
        public function testSetDelayed() {
                $key = wfRandomString();
                $value = wfRandomString();
index bd076ba..985554b 100644 (file)
@@ -92,6 +92,57 @@ class UserTest extends MediaWikiTestCase {
                $this->assertNotContains( 'nukeworld', $rights );
        }
 
+       /**
+        * @covers User::getRights
+        */
+       public function testUserGetRightsHooks() {
+               $user = new User;
+               $user->addGroup( 'unittesters' );
+               $user->addGroup( 'testwriters' );
+               $userWrapper = TestingAccessWrapper::newFromObject( $user );
+
+               $rights = $user->getRights();
+               $this->assertContains( 'test', $rights, 'sanity check' );
+               $this->assertContains( 'runtest', $rights, 'sanity check' );
+               $this->assertContains( 'writetest', $rights, 'sanity check' );
+               $this->assertNotContains( 'nukeworld', $rights, 'sanity check' );
+
+               // Add a hook manipluating the rights
+               $this->mergeMwGlobalArrayValue( 'wgHooks', [ 'UserGetRights' => [ function ( $user, &$rights ) {
+                       $rights[] = 'nukeworld';
+                       $rights = array_diff( $rights, [ 'writetest' ] );
+               } ] ] );
+
+               $userWrapper->mRights = null;
+               $rights = $user->getRights();
+               $this->assertContains( 'test', $rights );
+               $this->assertContains( 'runtest', $rights );
+               $this->assertNotContains( 'writetest', $rights );
+               $this->assertContains( 'nukeworld', $rights );
+
+               // Add a Session that limits rights
+               $mock = $this->getMockBuilder( stdclass::class )
+                       ->setMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
+                       ->getMock();
+               $mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] );
+               $mock->method( 'getSessionId' )->willReturn(
+                       new MediaWiki\Session\SessionId( str_repeat( 'X', 32 ) )
+               );
+               $session = MediaWiki\Session\TestUtils::getDummySession( $mock );
+               $mockRequest = $this->getMockBuilder( FauxRequest::class )
+                       ->setMethods( [ 'getSession' ] )
+                       ->getMock();
+               $mockRequest->method( 'getSession' )->willReturn( $session );
+               $userWrapper->mRequest = $mockRequest;
+
+               $userWrapper->mRights = null;
+               $rights = $user->getRights();
+               $this->assertContains( 'test', $rights );
+               $this->assertNotContains( 'runtest', $rights );
+               $this->assertNotContains( 'writetest', $rights );
+               $this->assertNotContains( 'nukeworld', $rights );
+       }
+
        /**
         * @dataProvider provideGetGroupsWithPermission
         * @covers User::getGroupsWithPermission
index 275c0d1..711eab6 100644 (file)
@@ -74,11 +74,9 @@ class ExtensionJsonValidationTest extends PHPUnit_Framework_TestCase {
                        $version <= ExtensionRegistry::MANIFEST_VERSION,
                        "$path is using a non-supported schema version"
                );
-               $retriever = new JsonSchema\Uri\UriRetriever();
-               $schema = $retriever->retrieve( 'file://' . $schemaPath );
 
-               $validator = new JsonSchema\Validator();
-               $validator->check( $data, $schema );
+               $validator = new JsonSchema\Validator;
+               $validator->check( $data, (object) [ '$ref' => 'file://' . $schemaPath ] );
                if ( $validator->isValid() ) {
                        // All good.
                        $this->assertTrue( true );