Merge "Allow empty input in namespaces multiselect on Special:Block"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 19 Jul 2019 13:09:11 +0000 (13:09 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 19 Jul 2019 13:09:11 +0000 (13:09 +0000)
24 files changed:
RELEASE-NOTES-1.34
docs/hooks.txt
includes/DefaultSettings.php
includes/GlobalFunctions.php
includes/Permissions/PermissionManager.php
includes/ServiceWiring.php
includes/TitleArrayFromResult.php
includes/deferred/DeferredUpdates.php
includes/import/ImportableUploadRevisionImporter.php
includes/import/WikiRevision.php
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/MemcachedPeclBagOStuff.php
includes/libs/objectcache/RedisBagOStuff.php
includes/resourceloader/ResourceLoaderFileModule.php
includes/specialpage/AuthManagerSpecialPage.php
includes/specials/SpecialMute.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/mctest.php
resources/Resources.php
tests/phpunit/includes/GlobalFunctions/GlobalTest.php
tests/phpunit/includes/Permissions/PermissionManagerTest.php
tests/phpunit/includes/specials/SpecialMuteTest.php
tests/phpunit/unit/includes/SiteConfigurationTest.php

index a92b5c2..4b28012 100644 (file)
@@ -36,6 +36,9 @@ For notes on 1.33.x and older releases, see HISTORY.
 * $wgEnableSpecialMute (T218265) - This configuration controls whether
   Special:Mute is available and whether to include a link to it on emails
   originating from Special:Email.
+* editmyuserjsredirect user right – users without this right now cannot edit JS
+  redirects in their userspace unless the target of the redirect is also in
+  their userspace. By default, this right is given to everyone.
 
 ==== Changed configuration ====
 * $wgUseCdn, $wgCdnServers, $wgCdnServersNoPurge, and $wgCdnMaxAge – These four
@@ -77,6 +80,8 @@ For notes on 1.33.x and older releases, see HISTORY.
   of headers in private wikis.
 * Language::formatTimePeriod now supports the new 'avoidhours' option to output
   strings like "5 days ago" instead of "5 days 13 hours ago".
+* (T220163) Added SpecialMuteModifyFormFields hook to allow extensions
+  to add fields to Special:Mute.
 
 === External library changes in 1.34 ===
 
index 36e0891..756ba4e 100644 (file)
@@ -3200,6 +3200,10 @@ $request: WebRequest object for getting the value provided by the current user
 &$oldTitle: old title (object)
 &$newTitle: new title (object)
 
+'SpecialMuteModifyFormFields': Add more fields to Special:Mute
+$sp: SpecialPage object, for context
+&$fields: Current HTMLForm fields descriptors
+
 'SpecialNewpagesConditions': Called when building sql query for
 Special:NewPages.
 &$special: NewPagesPager object (subclass of ReverseChronologicalPager)
index 3bfc8f8..107c546 100644 (file)
@@ -5173,6 +5173,7 @@ $wgGroupPermissions['user']['minoredit'] = true;
 $wgGroupPermissions['user']['editmyusercss'] = true;
 $wgGroupPermissions['user']['editmyuserjson'] = true;
 $wgGroupPermissions['user']['editmyuserjs'] = true;
+$wgGroupPermissions['user']['editmyuserjsredirect'] = true;
 $wgGroupPermissions['user']['purge'] = true;
 $wgGroupPermissions['user']['sendemail'] = true;
 $wgGroupPermissions['user']['applychangetags'] = true;
index c6c386c..7b4b502 100644 (file)
@@ -2521,6 +2521,7 @@ function wfForeignMemcKey( $db, $prefix, ...$args ) {
  * @return string
  */
 function wfGlobalCacheKey( ...$args ) {
+       wfDeprecated( __METHOD__, '1.30' );
        return ObjectCache::getLocalClusterInstance()->makeGlobalKey( ...$args );
 }
 
index 5a3dae3..a04b29c 100644 (file)
@@ -23,6 +23,8 @@ use Action;
 use Exception;
 use Hooks;
 use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Session\SessionManager;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\User\UserIdentity;
@@ -55,6 +57,9 @@ class PermissionManager {
        /** @var SpecialPageFactory */
        private $specialPageFactory;
 
+       /** @var RevisionLookup */
+       private $revisionLookup;
+
        /** @var string[] List of pages names anonymous user may see */
        private $whitelistRead;
 
@@ -130,6 +135,7 @@ class PermissionManager {
                'editmyusercss',
                'editmyuserjson',
                'editmyuserjs',
+               'editmyuserjsredirect',
                'editmywatchlist',
                'editsemiprotected',
                'editsitecss',
@@ -184,6 +190,7 @@ class PermissionManager {
 
        /**
         * @param SpecialPageFactory $specialPageFactory
+        * @param RevisionLookup $revisionLookup
         * @param string[] $whitelistRead
         * @param string[] $whitelistReadRegexp
         * @param bool $emailConfirmToEdit
@@ -195,6 +202,7 @@ class PermissionManager {
         */
        public function __construct(
                SpecialPageFactory $specialPageFactory,
+               RevisionLookup $revisionLookup,
                $whitelistRead,
                $whitelistReadRegexp,
                $emailConfirmToEdit,
@@ -205,6 +213,7 @@ class PermissionManager {
                NamespaceInfo $nsInfo
        ) {
                $this->specialPageFactory = $specialPageFactory;
+               $this->revisionLookup = $revisionLookup;
                $this->whitelistRead = $whitelistRead;
                $this->whitelistReadRegexp = $whitelistReadRegexp;
                $this->emailConfirmToEdit = $emailConfirmToEdit;
@@ -1134,6 +1143,20 @@ class PermissionManager {
                                && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
                        ) {
                                $errors[] = [ 'mycustomjsprotected', $action ];
+                       } elseif (
+                               $page->isUserJsConfigPage()
+                               && !$user->isAllowedAny( 'edituserjs', 'editmyuserjsredirect' )
+                       ) {
+                               // T207750 - do not allow users to edit a redirect if they couldn't edit the target
+                               $rev = $this->revisionLookup->getRevisionByTitle( $page );
+                               $content = $rev ? $rev->getContent( 'main', RevisionRecord::RAW ) : null;
+                               $target = $content ? $content->getUltimateRedirectTarget() : null;
+                               if ( $target && (
+                                               !$target->inNamespace( NS_USER )
+                                               || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
+                               ) ) {
+                                       $errors[] = [ 'mycustomjsredirectprotected', $action ];
+                               }
                        }
                } else {
                        // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
index 339c34c..1bb848f 100644 (file)
@@ -465,6 +465,7 @@ return [
                $config = $services->getMainConfig();
                return new PermissionManager(
                        $services->getSpecialPageFactory(),
+                       $services->getRevisionLookup(),
                        $config->get( 'WhitelistRead' ),
                        $config->get( 'WhitelistReadRegexp' ),
                        $config->get( 'EmailConfirmToEdit' ),
index ee60f7b..80fdf9d 100644 (file)
@@ -41,7 +41,7 @@ class TitleArrayFromResult extends TitleArray implements Countable {
        }
 
        /**
-        * @param bool|IResultWrapper $row
+        * @param bool|stdClass $row
         * @return void
         */
        protected function setCurrent( $row ) {
index c754cff..d43ffbc 100644 (file)
@@ -28,6 +28,7 @@ use MediaWiki\MediaWikiServices;
 use Wikimedia\Rdbms\LBFactory;
 use Wikimedia\Rdbms\ILBFactory;
 use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\DBTransactionError;
 
 /**
  * Class for managing the deferred updates
@@ -352,28 +353,30 @@ class DeferredUpdates {
         * @since 1.34
         */
        public static function attemptUpdate( DeferrableUpdate $update, ILBFactory $lbFactory ) {
+               $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+               if ( !$ticket || $lbFactory->hasTransactionRound() ) {
+                       throw new DBTransactionError( null, "A database transaction round is pending." );
+               }
+
                if ( $update instanceof DataUpdate ) {
-                       $update->setTransactionTicket( $lbFactory->getEmptyTransactionTicket( __METHOD__ ) );
+                       $update->setTransactionTicket( $ticket );
                }
 
-               if (
+               $fnameTrxOwner = get_class( $update ) . '::doUpdate';
+               $useExplicitTrxRound = !(
                        $update instanceof TransactionRoundAwareUpdate &&
                        $update->getTransactionRoundRequirement() == $update::TRX_ROUND_ABSENT
-               ) {
-                       $fnameTrxOwner = null;
+               );
+               // Flush any pending changes left over from an implicit transaction round
+               if ( $useExplicitTrxRound ) {
+                       $lbFactory->beginMasterChanges( $fnameTrxOwner ); // new explicit round
                } else {
-                       $fnameTrxOwner = get_class( $update ) . '::doUpdate';
+                       $lbFactory->commitMasterChanges( $fnameTrxOwner ); // new implicit round
                }
-
-               if ( $fnameTrxOwner !== null ) {
-                       $lbFactory->beginMasterChanges( $fnameTrxOwner );
-               }
-
+               // Run the update after any stale master view snapshots have been flushed
                $update->doUpdate();
-
-               if ( $fnameTrxOwner !== null ) {
-                       $lbFactory->commitMasterChanges( $fnameTrxOwner );
-               }
+               // Commit any pending changes from the explicit or implicit transaction round
+               $lbFactory->commitMasterChanges( $fnameTrxOwner );
        }
 
        /**
index 40c9417..4be13b0 100644 (file)
@@ -139,9 +139,9 @@ class ImportableUploadRevisionImporter implements UploadRevisionImporter {
 
        /**
         * @deprecated DO NOT CALL ME.
-        * This method was introduced when factoring UploadImporter out of WikiRevision.
-        * It only has 1 use by the deprecated downloadSource method in WikiRevision.
-        * Do not use this in new code.
+        * This method was introduced when factoring (Importable)UploadRevisionImporter out of
+        * WikiRevision. It only has 1 use by the deprecated downloadSource method in WikiRevision.
+        * Do not use this in new code, it will be made private soon.
         *
         * @param ImportableUploadRevision $wikiRevision
         *
index c006874..cae9542 100644 (file)
@@ -636,7 +636,7 @@ class WikiRevision implements ImportableUploadRevision, ImportableOldRevision {
 
        /**
         * @since 1.12.2
-        * @deprecated in 1.31. Use UploadImporter::import
+        * @deprecated in 1.31. Use UploadRevisionImporter::import
         * @return bool
         */
        public function importUpload() {
@@ -647,7 +647,7 @@ class WikiRevision implements ImportableUploadRevision, ImportableOldRevision {
 
        /**
         * @since 1.12.2
-        * @deprecated in 1.31. Use UploadImporter::downloadSource
+        * @deprecated in 1.31. No replacement
         * @return bool|string
         */
        public function downloadSource() {
index dce49c4..4819f0e 100644 (file)
@@ -97,14 +97,15 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
        /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */
        protected $attrMap = [];
 
-       /** Bitfield constants for get()/getMulti() */
-       const READ_LATEST = 1; // use latest data for replicated stores
-       const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
-       /** Bitfield constants for set()/merge() */
-       const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores
-       const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache
-       const WRITE_ALLOW_SEGMENTS = 16; // Allow partitioning of the value if it is large
-       const WRITE_PRUNE_SEGMENTS = 32; // Delete all partition segments of the value
+       /** Bitfield constants for get()/getMulti(); these are only advisory */
+       const READ_LATEST = 1; // if supported, avoid reading stale data due to replication
+       const READ_VERIFIED = 2; // promise that the caller handles detection of staleness
+       /** Bitfield constants for set()/merge(); these are only advisory */
+       const WRITE_SYNC = 4; // if supported, block until the write is fully replicated
+       const WRITE_CACHE_ONLY = 8; // only change state of the in-memory cache
+       const WRITE_ALLOW_SEGMENTS = 16; // allow partitioning of the value if it is large
+       const WRITE_PRUNE_SEGMENTS = 32; // delete all the segments if the value is partitioned
+       const WRITE_BACKGROUND = 64; // if supported,
 
        /** @var string Component to use for key construction of blob segment keys */
        const SEGMENT_COMPONENT = 'segment';
@@ -727,6 +728,8 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk insertion where the response is not vital
+        *
         * @param mixed[] $data Map of (key => value)
         * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
@@ -759,6 +762,8 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk deletion where the response is not vital
+        *
         * @param string[] $keys List of keys
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
index 221bc82..cc7ee2a 100644 (file)
  */
 class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
        /** @var Memcached */
-       protected $client;
+       protected $syncClient;
+       /** @var Memcached|null */
+       protected $asyncClient;
+
+       /** @var bool Whether the non-buffering client is locked from use */
+       protected $syncClientIsBuffering = false;
+       /** @var bool Whether the non-buffering client should be flushed before use */
+       protected $hasUnflushedChanges = false;
+
+       /** @var array Memcached options */
+       private static $OPTS_SYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => false, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => false // libmemcached buffers
+       ];
+       /** @var array Memcached options */
+       private static $OPTS_ASYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => true, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => true // libmemcached buffers
+       ];
 
        /**
         * Available parameters are:
@@ -63,15 +81,22 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        // The Memcached object is essentially shared for each pool ID.
                        // We can only reuse a pool ID if we keep the config consistent.
                        $connectionPoolId = md5( serialize( $params ) );
-                       $client = new Memcached( $connectionPoolId );
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached( "$connectionPoolId-sync" );
+                       // Avoid clobbering the main thread-shared Memcached instance
+                       $asyncClient = new Memcached( "$connectionPoolId-async" );
                } else {
-                       $client = new Memcached;
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached();
+                       $asyncClient = null;
                }
 
-               $this->client = $client;
+               $this->initializeClient( $syncClient, $params, self::$OPTS_SYNC_WRITES );
+               if ( $asyncClient ) {
+                       $this->initializeClient( $asyncClient, $params, self::$OPTS_ASYNC_WRITES );
+               }
 
+               // Set the main client and any dedicated one for buffered writes
+               $this->syncClient = $syncClient;
+               $this->asyncClient = $asyncClient;
                // The compression threshold is an undocumented php.ini option for some
                // reason. There's probably not much harm in setting it globally, for
                // compatibility with the settings for the PHP client.
@@ -84,9 +109,10 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
         *
         * @param Memcached $client
         * @param array $params
+        * @param array $options Base options for Memcached::setOptions()
         * @throws RuntimeException
         */
-       private function initializeClient( Memcached $client, array $params ) {
+       private function initializeClient( Memcached $client, array $params, array $options ) {
                if ( $client->getServerList() ) {
                        $this->logger->debug( __METHOD__ . ": pre-initialized client instance." );
 
@@ -95,7 +121,9 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                $this->logger->debug( __METHOD__ . ": initializing new client instance." );
 
-               $options = [
+               $options += [
+                       Memcached::OPT_NO_BLOCK => false,
+                       Memcached::OPT_BUFFER_WRITES => false,
                        // Network protocol (ASCII or binary)
                        Memcached::OPT_BINARY_PROTOCOL => $params['use_binary_protocol'],
                        // Set various network timeouts
@@ -150,10 +178,12 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $this->debug( "get($key)" );
+
+               $client = $this->acquireSyncClient();
                if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
                        /** @noinspection PhpUndefinedClassConstantInspection */
                        $flags = Memcached::GET_EXTENDED;
-                       $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags );
+                       $res = $client->get( $this->validateKeyEncoding( $key ), null, $flags );
                        if ( is_array( $res ) ) {
                                $result = $res['value'];
                                $casToken = $res['cas'];
@@ -162,62 +192,77 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                                $casToken = null;
                        }
                } else {
-                       $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken );
+                       $result = $client->get( $this->validateKeyEncoding( $key ), null, $casToken );
                }
-               $result = $this->checkResult( $key, $result );
-               return $result;
+
+               return $this->checkResult( $key, $result );
        }
 
        protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "set($key)" );
-               $result = $this->client->set(
+
+               $client = $this->acquireSyncClient();
+               $result = $client->set(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) {
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTSTORED )
                        // "Not stored" is always used as the mcrouter response with AllAsyncRoute
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "cas($key)" );
-               $result = $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
-                       $value, $this->fixExpiry( $exptime ) );
+
+               $result = $this->acquireSyncClient()->cas(
+                       $casToken,
+                       $this->validateKeyEncoding( $key ),
+                       $value, $this->fixExpiry( $exptime )
+               );
+
                return $this->checkResult( $key, $result );
        }
 
        protected function doDelete( $key, $flags = 0 ) {
                $this->debug( "delete($key)" );
-               $result = $this->client->delete( $this->validateKeyEncoding( $key ) );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
+
+               $client = $this->acquireSyncClient();
+               $result = $client->delete( $this->validateKeyEncoding( $key ) );
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTFOUND )
                        // "Not found" is counted as success in our interface
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "add($key)" );
-               $result = $this->client->add(
+
+               $result = $this->acquireSyncClient()->add(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
+
                return $this->checkResult( $key, $result );
        }
 
        public function incr( $key, $value = 1 ) {
                $this->debug( "incr($key)" );
-               $result = $this->client->increment( $key, $value );
+
+               $result = $this->acquireSyncClient()->increment( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
        public function decr( $key, $value = 1 ) {
                $this->debug( "decr($key)" );
-               $result = $this->client->decrement( $key, $value );
+
+               $result = $this->acquireSyncClient()->decrement( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -236,22 +281,25 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                if ( $result !== false ) {
                        return $result;
                }
-               switch ( $this->client->getResultCode() ) {
+
+               $client = $this->syncClient;
+               switch ( $client->getResultCode() ) {
                        case Memcached::RES_SUCCESS:
                                break;
                        case Memcached::RES_DATA_EXISTS:
                        case Memcached::RES_NOTSTORED:
                        case Memcached::RES_NOTFOUND:
-                               $this->debug( "result: " . $this->client->getResultMessage() );
+                               $this->debug( "result: " . $client->getResultMessage() );
                                break;
                        default:
-                               $msg = $this->client->getResultMessage();
+                               $msg = $client->getResultMessage();
                                $logCtx = [];
                                if ( $key !== false ) {
-                                       $server = $this->client->getServerByKey( $key );
+                                       $server = $client->getServerByKey( $key );
                                        $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
                                        $logCtx['memcached-key'] = $key;
-                                       $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg";
+                                       $msg = "Memcached error for key \"{memcached-key}\" " .
+                                               "on server \"{memcached-server}\": $msg";
                                } else {
                                        $msg = "Memcached error: $msg";
                                }
@@ -263,41 +311,71 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGetMulti( array $keys, $flags = 0 ) {
                $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->getMulti( $keys ) ?: [];
+
+               // The PECL implementation uses "gets" which works as well as a pipeline
+               $result = $this->acquireSyncClient()->getMulti( $keys ) ?: [];
+
                return $this->checkResult( false, $result );
        }
 
        protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
                $this->debug( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
+
+               $exptime = $this->fixExpiry( $exptime );
                foreach ( array_keys( $data ) as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) );
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $result = $client->setMulti( $data, $exptime );
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $result = $this->acquireSyncClient()->setMulti( $data, $exptime );
+               }
+
                return $this->checkResult( false, $result );
        }
 
        protected function doDeleteMulti( array $keys, $flags = 0 ) {
                $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->deleteMulti( $keys ) ?: [];
-               $ok = true;
-               foreach ( $result as $code ) {
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/7443d16d02fb73cdba2e90ae282446f80969229c/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $resultArray = $client->deleteMulti( $keys ) ?: [];
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $resultArray = $this->acquireSyncClient()->deleteMulti( $keys ) ?: [];
+               }
+
+               $result = true;
+               foreach ( $resultArray as $code ) {
                        if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) {
                                // "Not found" is counted as success in our interface
-                               $ok = false;
+                               $result = false;
                        }
                }
-               return $this->checkResult( false, $ok );
+
+               return $this->checkResult( false, $result );
        }
 
        protected function doChangeTTL( $key, $exptime, $flags ) {
                $this->debug( "touch($key)" );
-               $result = $this->client->touch( $key, $exptime );
+
+               $result = $this->acquireSyncClient()->touch( $key, $this->fixExpiry( $exptime ) );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -306,7 +384,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return $value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return serialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -321,7 +399,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return (int)$value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return unserialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -330,4 +408,52 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
        }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireSyncClient() {
+               if ( $this->syncClientIsBuffering ) {
+                       throw new RuntimeException( "The main (unbuffered I/O) client is locked" );
+               }
+
+               if ( $this->hasUnflushedChanges ) {
+                       // Force a synchronous flush of async writes so that their changes are visible
+                       $this->syncClient->fetch();
+                       if ( $this->asyncClient ) {
+                               $this->asyncClient->fetch();
+                       }
+                       $this->hasUnflushedChanges = false;
+               }
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireAsyncClient() {
+               if ( $this->asyncClient ) {
+                       return $this->asyncClient; // dedicated buffering instance
+               }
+
+               // Modify the main instance to temporarily buffer writes
+               $this->syncClientIsBuffering = true;
+               $this->syncClient->setOptions( self::$OPTS_ASYNC_WRITES );
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @param Memcached $client
+        */
+       private function releaseAsyncClient( $client ) {
+               $this->hasUnflushedChanges = true;
+
+               if ( !$this->asyncClient ) {
+                       // This is the main instance; make it stop buffering writes again
+                       $client->setOptions( self::$OPTS_SYNC_WRITES );
+                       $this->syncClientIsBuffering = false;
+               }
+       }
 }
index 2d1ed05..21b14f7 100644 (file)
@@ -368,54 +368,11 @@ class RedisBagOStuff extends BagOStuff {
                }
 
                try {
-                       $conn->watch( $key );
-                       if ( $conn->exists( $key ) ) {
-                               $conn->multi( Redis::MULTI );
-                               $conn->incrBy( $key, $value );
-                               $batchResult = $conn->exec();
-                               if ( $batchResult === false ) {
-                                       $result = false;
-                               } else {
-                                       $result = end( $batchResult );
-                               }
-                       } else {
-                               $result = false;
-                               $conn->unwatch();
-                       }
-               } catch ( RedisException $e ) {
-                       try {
-                               $conn->unwatch(); // sanity
-                       } catch ( RedisException $ex ) {
-                               // already errored
-                       }
-                       $result = false;
-                       $this->handleException( $conn, $e );
-               }
-
-               $this->logRequest( 'incr', $key, $conn->getServer(), $result );
-
-               return $result;
-       }
-
-       public function incrWithInit( $key, $exptime, $value = 1, $init = 1 ) {
-               $conn = $this->getConnection( $key );
-               if ( !$conn ) {
-                       return false;
-               }
-
-               $ttl = $this->convertToRelative( $exptime );
-               $preIncrInit = $init - $value;
-               try {
-                       $conn->multi( Redis::MULTI );
-                       $conn->set( $key, $preIncrInit, $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ] );
-                       $conn->incrBy( $key, $value );
-                       $batchResult = $conn->exec();
-                       if ( $batchResult === false ) {
-                               $result = false;
-                               $this->debug( "incrWithInit request to {$conn->getServer()} failed" );
-                       } else {
-                               $result = end( $batchResult );
+                       if ( !$conn->exists( $key ) ) {
+                               return false;
                        }
+                       // @FIXME: on races, the key may have a 0 TTL
+                       $result = $conn->incrBy( $key, $value );
                } catch ( RedisException $e ) {
                        $result = false;
                        $this->handleException( $conn, $e );
index fbc59fe..af30313 100644 (file)
@@ -1159,11 +1159,12 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                                                throw new MWException( __METHOD__ . ": invalid versionCallback for file" .
                                                        " \"{$fileInfo['name']}\" in module \"{$this->getName()}\"" );
                                        }
-                                       $expanded['definitionSummary'] = ( $fileInfo['versionCallback'] )( $context );
+                                       $expanded['definitionSummary'] =
+                                               ( $fileInfo['versionCallback'] )( $context, $this->getConfig() );
                                        // Don't invoke 'callback' here as it may be expensive (T223260).
                                        $expanded['callback'] = $fileInfo['callback'];
                                } else {
-                                       $expanded['content'] = ( $fileInfo['callback'] )( $context );
+                                       $expanded['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() );
                                }
                        } elseif ( isset( $fileInfo['config'] ) ) {
                                if ( $type !== 'data' ) {
@@ -1240,7 +1241,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                                $fileInfo['content'] = $content;
                                unset( $fileInfo['filePath'] );
                        } elseif ( isset( $fileInfo['callback'] ) ) {
-                               $fileInfo['content'] = ( $fileInfo['callback'] )( $context );
+                               $fileInfo['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() );
                                unset( $fileInfo['callback'] );
                        }
 
index 101570f..65cd2d2 100644 (file)
@@ -429,7 +429,7 @@ abstract class AuthManagerSpecialPage extends SpecialPage {
                                // accidentally returning it so best check and fix
                                $status = Status::wrap( $status );
                        } elseif ( is_string( $status ) ) {
-                               $status = Status::newFatal( new RawMessage( '$1', $status ) );
+                               $status = Status::newFatal( new RawMessage( '$1', [ $status ] ) );
                        } elseif ( is_array( $status ) ) {
                                if ( is_string( reset( $status ) ) ) {
                                        $status = Status::newFatal( ...$status );
index 4f34785..f3ae31a 100644 (file)
@@ -28,6 +28,8 @@ use MediaWiki\Preferences\MultiUsernameFilter;
  */
 class SpecialMute extends FormSpecialPage {
 
+       const PAGE_NAME = 'Mute';
+
        /** @var User */
        private $target;
 
@@ -51,7 +53,7 @@ class SpecialMute extends FormSpecialPage {
 
                $this->centralIdLookup = CentralIdLookup::factory();
 
-               parent::__construct( 'Mute', '', false );
+               parent::__construct( self::PAGE_NAME, '', false );
        }
 
        /**
@@ -66,7 +68,7 @@ class SpecialMute extends FormSpecialPage {
                parent::execute( $par );
 
                $out = $this->getOutput();
-               $out->addModules( 'mediawiki.special.pageLanguage' );
+               $out->addModules( 'mediawiki.misc-authed-ooui' );
        }
 
        /**
@@ -97,10 +99,12 @@ class SpecialMute extends FormSpecialPage {
         * @return bool
         */
        public function onSubmit( array $data, HTMLForm $form = null ) {
-               if ( !empty( $data['MuteEmail'] ) ) {
-                       $this->muteEmailsFromTarget();
-               } else {
-                       $this->unmuteEmailsFromTarget();
+               foreach ( $data as $userOption => $value ) {
+                       if ( $value ) {
+                               $this->muteTarget( $userOption );
+                       } else {
+                               $this->unmuteTarget( $userOption );
+                       }
                }
 
                return true;
@@ -114,10 +118,12 @@ class SpecialMute extends FormSpecialPage {
        }
 
        /**
-        * Un-mute emails from target
+        * Un-mute target
+        *
+        * @param string $userOption up_property key that holds the blacklist
         */
-       private function unmuteEmailsFromTarget() {
-               $blacklist = $this->getBlacklist();
+       private function unmuteTarget( $userOption ) {
+               $blacklist = $this->getBlacklist( $userOption );
 
                $key = array_search( $this->targetCentralId, $blacklist );
                if ( $key !== false ) {
@@ -125,24 +131,25 @@ class SpecialMute extends FormSpecialPage {
                        $blacklist = implode( "\n", $blacklist );
 
                        $user = $this->getUser();
-                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->setOption( $userOption, $blacklist );
                        $user->saveSettings();
                }
        }
 
        /**
-        * Mute emails from target
+        * Mute target
+        * @param string $userOption up_property key that holds the blacklist
         */
-       private function muteEmailsFromTarget() {
+       private function muteTarget( $userOption ) {
                // avoid duplicates just in case
-               if ( !$this->isTargetBlacklisted() ) {
-                       $blacklist = $this->getBlacklist();
+               if ( !$this->isTargetBlacklisted( $userOption ) ) {
+                       $blacklist = $this->getBlacklist( $userOption );
 
                        $blacklist[] = $this->targetCentralId;
                        $blacklist = implode( "\n", $blacklist );
 
                        $user = $this->getUser();
-                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->setOption( $userOption, $blacklist );
                        $user->saveSettings();
                }
        }
@@ -150,30 +157,38 @@ class SpecialMute extends FormSpecialPage {
        /**
         * @inheritDoc
         */
-       protected function alterForm( HTMLForm $form ) {
+       protected function getForm() {
+               $form = parent::getForm();
                $form->setId( 'mw-specialmute-form' );
                $form->setHeaderText( $this->msg( 'specialmute-header', $this->target )->parse() );
                $form->setSubmitTextMsg( 'specialmute-submit' );
                $form->setSubmitID( 'save' );
+
+               return $form;
        }
 
        /**
         * @inheritDoc
         */
        protected function getFormFields() {
-               if ( !$this->enableUserEmailBlacklist || !$this->enableUserEmail ) {
-                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-blacklist-disabled' );
+               $fields = [];
+               if (
+                       $this->enableUserEmailBlacklist &&
+                       $this->enableUserEmail &&
+                       $this->getUser()->getEmailAuthenticationTimestamp()
+               ) {
+                       $fields['email-blacklist'] = [
+                               'type' => 'check',
+                               'label-message' => 'specialmute-label-mute-email',
+                               'default' => $this->isTargetBlacklisted( 'email-blacklist' ),
+                       ];
                }
 
-               if ( !$this->getUser()->getEmailAuthenticationTimestamp() ) {
-                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-preferences' );
-               }
+               Hooks::run( 'SpecialMuteModifyFormFields', [ $this, &$fields ] );
 
-               $fields['MuteEmail'] = [
-                       'type' => 'check',
-                       'label-message' => 'specialmute-label-mute-email',
-                       'default' => $this->isTargetBlacklisted(),
-               ];
+               if ( count( $fields ) == 0 ) {
+                       throw new ErrorPageError( 'specialmute', 'specialmute-error-no-options' );
+               }
 
                return $fields;
        }
@@ -192,18 +207,20 @@ class SpecialMute extends FormSpecialPage {
        }
 
        /**
+        * @param string $userOption
         * @return bool
         */
-       private function isTargetBlacklisted() {
-               $blacklist = $this->getBlacklist();
-               return in_array( $this->targetCentralId, $blacklist );
+       public function isTargetBlacklisted( $userOption ) {
+               $blacklist = $this->getBlacklist( $userOption );
+               return in_array( $this->targetCentralId, $blacklist, true );
        }
 
        /**
+        * @param string $userOption
         * @return array
         */
-       private function getBlacklist() {
-               $blacklist = $this->getUser()->getOption( 'email-blacklist' );
+       private function getBlacklist( $userOption ) {
+               $blacklist = $this->getUser()->getOption( $userOption );
                if ( !$blacklist ) {
                        return [];
                }
index 7e40594..eeca124 100644 (file)
        "right-editmyusercss": "Edit your own user CSS files",
        "right-editmyuserjson": "Edit your own user JSON files",
        "right-editmyuserjs": "Edit your own user JavaScript files",
+       "right-editmyuserjsredirect": "Edit your own user JavaScript files that are redirects",
        "right-viewmywatchlist": "View your own watchlist",
        "right-editmywatchlist": "Edit your own watchlist. Note some actions will still add pages even without this right.",
        "right-viewmyprivateinfo": "View your own private data (e.g. email address, real name)",
        "action-editmyusercss": "edit your own user CSS files",
        "action-editmyuserjson": "edit your own user JSON files",
        "action-editmyuserjs": "edit your own user JavaScript files",
+       "action-editmyuserjsredirect": "edit your own user JavaScript files that are redirects",
        "action-viewsuppressed": "view revisions hidden from any user",
        "action-hideuser": "block a username, hiding it from the public",
        "action-ipblock-exempt": "bypass IP blocks, auto-blocks and range blocks",
        "specialmute-success": "Your mute preferences have been updated. See all muted users in [[Special:Preferences|your preferences]].",
        "specialmute-submit": "Confirm",
        "specialmute-label-mute-email": "Mute emails from this user",
-       "specialmute-header": "Please select your mute preferences for {{BIDI:[[User:$1]]}}.",
+       "specialmute-header": "Please select your mute preferences for <b>{{BIDI:[[User:$1]]}}</b>.",
        "specialmute-error-invalid-user": "The username requested could not be found.",
-       "specialmute-error-email-blacklist-disabled": "Muting users from sending you emails is not enabled.",
-       "specialmute-error-email-preferences": "You must confirm your email address before you can mute a user. You may do so from [[Special:Preferences]].",
+       "specialmute-error-no-options": "Mute features are unavailable. This might be because: you haven't confirmed your email address or the wiki administrator has disabled email features and/or email blacklist for this wiki.",
        "specialmute-email-footer": "To manage email preferences for {{BIDI:$2}} please visit <$1>.",
        "specialmute-login-required": "Please log in to change your mute preferences.",
        "mute-preferences": "Mute preferences",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "Password cannot be in the list of 100,000 most commonly used passwords.",
        "passwordpolicies-policyflag-forcechange": "must change on login",
        "passwordpolicies-policyflag-suggestchangeonlogin": "suggest change on login",
+       "mycustomjsredirectprotected": "You do not have permission to edit this JavaScript page because it is a redirect and it does not point inside your userspace.",
        "easydeflate-invaliddeflate": "Content provided is not properly deflated",
        "unprotected-js": "For security reasons JavaScript cannot be loaded from unprotected pages. Please only create javascript in the MediaWiki: namespace or as a User subpage",
        "userlogout-continue": "Do you want to log out?"
index a58010e..76d969d 100644 (file)
        "right-editsitejs": "{{doc-right|editsitejs}}",
        "right-editmyusercss": "{{doc-right|editmyusercss}}\nSee also:\n* {{msg-mw|Right-editusercss}}",
        "right-editmyuserjson": "{{doc-right|editmyuserjson}}\nSee also:\n* {{msg-mw|Right-edituserjson}}",
-       "right-editmyuserjs": "{{doc-right|editmyuserjs}}\nSee also:\n* {{msg-mw|Right-edituserjs}}",
+       "right-editmyuserjs": "{{doc-right|editmyuserjs}}\nSee also:\n* {{msg-mw|Right-edituserjs}}\n* {{msg-mw|Right-editmyuserjsredirect}}",
+       "right-editmyuserjsredirect": "{{doc-right|editmyuserjsredirect}}\nSame as {{msg-mw|Right-editmyuserjs}} except if page is a redirect.\n\nSee also:\n* {{msg-mw|Right-edituserjs}}",
        "right-viewmywatchlist": "{{doc-right|viewmywatchlist}}",
        "right-editmywatchlist": "{{doc-right|editmywatchlist}}",
        "right-viewmyprivateinfo": "{{doc-right|viewmyprivateinfo}}",
        "action-editmyusercss": "{{doc-action|editmyusercss}}",
        "action-editmyuserjson": "{{doc-action|editmyuserjson}}",
        "action-editmyuserjs": "{{doc-action|editmyuserjs}}",
+       "action-editmyuserjsredirect": "{{doc-action|editmyuserjsredirect}}",
        "action-viewsuppressed": "{{doc-action|viewsuppressed}}",
        "action-hideuser": "{{doc-action|hideuser}}",
        "action-ipblock-exempt": "{{doc-action|ipblock-exempt}}",
        "specialmute-label-mute-email": "Label for the checkbox that mutes/unmutes emails from the specified user.",
        "specialmute-header": "Used as header text on [[Special:Mute]]. Shown before the form with the muting options.\n* $1 - User selected for muting",
        "specialmute-error-invalid-user": "Error displayed when the username cannot be found.",
-       "specialmute-error-email-blacklist-disabled": "Error displayed when email blacklist is not enabled.",
-       "specialmute-error-email-preferences": "Error displayed when the user has not confirmed their email address.",
+       "specialmute-error-no-options": "Error displayed when there are no options available to mute on [[Special:Mute]].",
        "specialmute-email-footer": "Email footer in plain text linking to [[Special:Mute]] preselecting the sender to manage muting options.\n* $1 - Url linking to [[Special:Mute]].\n* $2 - The user sending the email.",
        "specialmute-login-required": "Error displayed when a user tries to access [[Special:Mute]] before logging in.",
        "mute-preferences": "Link in the sidebar to manage muting preferences for a user. It links to [[Special:Mute]] with the user in context as the subpage.",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "Password policy that enforces that a password is not in a list of 100,000 number of \"popular\" passwords.",
        "passwordpolicies-policyflag-forcechange": "Password policy flag that enforces changing invalid passwords on login.",
        "passwordpolicies-policyflag-suggestchangeonlogin": "Password policy flag that suggests changing invalid passwords on login.",
+       "mycustomjsredirectprotected": "Error message shown when user tries to edit their own JS page that is a foreign redirect without the 'mycustomjsredirectprotected' right. See also {{mw-msg|mycustomjsprotected}}.",
        "easydeflate-invaliddeflate": "Error message if the content passed to easydeflate was not deflated (compressed) properly",
        "unprotected-js": "Error message shown when trying to load javascript via action=raw that is not protected",
        "userlogout-continue": "Shown if user attempted to log out without a token specified. Probably the user clicked on an old link that hasn't been updated to use the new system. $1 - url that user should click on in order to log out."
index 513edf3..9548d6b 100644 (file)
@@ -37,6 +37,7 @@ class McTest extends Maintenance {
                        . " memcached server and shows a report" );
                $this->addOption( 'i', 'Number of iterations', false, true );
                $this->addOption( 'cache', 'Use servers from this $wgObjectCaches store', false, true );
+               $this->addOption( 'driver', 'Either "php" or "pecl"', false, true );
                $this->addArg( 'server[:port]', 'Memcached server to test, with optional port', false );
        }
 
@@ -66,41 +67,177 @@ class McTest extends Maintenance {
                # find out the longest server string to nicely align output later on
                $maxSrvLen = $servers ? max( array_map( 'strlen', $servers ) ) : 0;
 
+               $type = $this->getOption( 'driver', 'php' );
+               if ( $type === 'php' ) {
+                       $class = MemcachedPhpBagOStuff::class;
+               } elseif ( $type === 'pecl' ) {
+                       $class = MemcachedPeclBagOStuff::class;
+               } else {
+                       $this->fatalError( "Invalid driver type '$type'" );
+               }
+
                foreach ( $servers as $server ) {
-                       $this->output(
-                               str_pad( $server, $maxSrvLen ),
-                               $server # output channel
-                       );
+                       $this->output( str_pad( $server, $maxSrvLen ) . "\n" );
 
-                       $mcc = new MemcachedClient( [
-                               'persistant' => true,
+                       /** @var BagOStuff $mcc */
+                       $mcc = new $class( [
+                               'servers' => [ $server ],
+                               'persistent' => true,
                                'timeout' => $wgMemCachedTimeout
                        ] );
-                       $mcc->set_servers( [ $server ] );
-                       $set = 0;
-                       $incr = 0;
-                       $get = 0;
-                       $time_start = microtime( true );
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( $mcc->set( "test$i", $i ) ) {
-                                       $set++;
-                               }
+
+                       $this->benchmarkSingleKeyOps( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations );
+               }
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkSingleKeyOps( $mcc, $iterations ) {
+               $add = 0;
+               $set = 0;
+               $incr = 0;
+               $get = 0;
+               $delete = 0;
+
+               $keys = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keys[] = "test$i";
+               }
+
+               // Clear out any old values
+               $mcc->deleteMulti( $keys );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->add( $key, $i ) ) {
+                               $add++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( !is_null( $mcc->incr( "test$i", $i ) ) ) {
-                                       $incr++;
-                               }
+               }
+               $addMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->set( $key, $i ) ) {
+                               $set++;
+                       }
+               }
+               $setMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( !is_null( $mcc->incr( $key, $i ) ) ) {
+                               $incr++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               $value = $mcc->get( "test$i" );
-                               if ( $value == $i * 2 ) {
-                                       $get++;
-                               }
+               }
+               $incrMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       $value = $mcc->get( $key );
+                       if ( $value == $i * 2 ) {
+                               $get++;
                        }
-                       $exectime = microtime( true ) - $time_start;
+               }
+               $getMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
 
-                       $this->output( " set: $set   incr: $incr   get: $get time: $exectime", $server );
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->delete( $key ) ) {
+                               $delete++;
+                       }
                }
+               $delMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " add: $add/$iterations {$addMs}ms   " .
+                       "set: $set/$iterations {$setMs}ms   " .
+                       "incr: $incr/$iterations {$incrMs}ms   " .
+                       "get: $get/$iterations ({$getMs}ms)   " .
+                       "delete: $delete/$iterations ({$delMs}ms)\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations ) {
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'S' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600 ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (IB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (IB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (IB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (IB): $mDelOk {$mDelMs}ms\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations ) {
+               $flags = $mcc::WRITE_BACKGROUND;
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'A' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue, 0, $flags ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600, $flags ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList, $flags ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (DB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (DB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (DB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (DB): $mDelOk {$mDelMs}ms\n"
+               );
        }
 }
 
index 6298086..9455994 100644 (file)
@@ -1526,7 +1526,9 @@ return [
                'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.jqueryMsg",
                'packageFiles' => [
                        'mediawiki.jqueryMsg.js',
-                       [ 'name' => 'parserDefaults.json', 'callback' => function ( ResourceLoaderContext $context ) {
+                       [ 'name' => 'parserDefaults.json', 'callback' => function (
+                               ResourceLoaderContext $context, Config $config
+                       ) {
                                $tagData = Sanitizer::getRecognizedTagData();
                                $allowedHtmlElements = array_merge(
                                        array_keys( $tagData['htmlpairs'] ),
@@ -1537,7 +1539,7 @@ return [
                                );
 
                                $magicWords = [
-                                       'SITENAME' => $context->getConfig()->get( 'Sitename' ),
+                                       'SITENAME' => $config->get( 'Sitename' ),
                                ];
                                Hooks::run( 'ResourceLoaderJqueryMsgModuleMagicWords', [ $context, &$magicWords ] );
 
index 1210a50..660734e 100644 (file)
@@ -716,6 +716,7 @@ class GlobalTest extends MediaWikiTestCase {
         */
        public function testWfGlobalCacheKey() {
                $cache = ObjectCache::getLocalClusterInstance();
+               $this->hideDeprecated( 'wfGlobalCacheKey' );
                $this->assertEquals(
                        $cache->makeGlobalKey( 'foo', 123, 'bar' ),
                        wfGlobalCacheKey( 'foo', 123, 'bar' )
index 5b015b3..8a98217 100644 (file)
@@ -3,21 +3,25 @@
 namespace MediaWiki\Tests\Permissions;
 
 use Action;
+use ContentHandler;
 use FauxRequest;
-use MediaWiki\Session\SessionId;
-use MediaWiki\Session\TestUtils;
-use MediaWikiLangTestCase;
-use RequestContext;
-use stdClass;
-use Title;
-use User;
 use MediaWiki\Block\DatabaseBlock;
 use MediaWiki\Block\Restriction\NamespaceRestriction;
 use MediaWiki\Block\Restriction\PageRestriction;
 use MediaWiki\Block\SystemBlock;
+use MediaWiki\Linker\LinkTarget;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Permissions\PermissionManager;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionLookup;
 use Wikimedia\ScopedCallback;
+use MediaWiki\Session\SessionId;
+use MediaWiki\Session\TestUtils;
+use MediaWikiLangTestCase;
+use RequestContext;
+use stdClass;
+use Title;
+use User;
 use Wikimedia\TestingAccessWrapper;
 
 /**
@@ -698,6 +702,64 @@ class PermissionManagerTest extends MediaWikiLangTestCase {
                );
        }
 
+       public function testJsConfigRedirectEditPermissions() {
+               $revision = null;
+               $user = $this->getTestUser()->getUser();
+               $otherUser = $this->getTestUser( 'sysop' )->getUser();
+               $localJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo.js' );
+               $otherLocalJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo2.js' );
+               $nonlocalJsTitle = Title::newFromText( 'User:' . $otherUser->getName() . '/foo.js' );
+
+               $services = MediaWikiServices::getInstance();
+               $revisionLookup = $this->getMockBuilder( RevisionLookup::class )
+                       ->setMethods( [ 'getRevisionByTitle' ] )
+                       ->getMockForAbstractClass();
+               $revisionLookup->method( 'getRevisionByTitle' )
+                       ->willReturnCallback( function ( LinkTarget $page ) use (
+                               $services, &$revision, $localJsTitle
+                       ) {
+                               if ( $localJsTitle->equals( Title::newFromLinkTarget( $page ) ) ) {
+                                       return $revision;
+                               } else {
+                                       return $services->getRevisionLookup()->getRevisionByTitle( $page );
+                               }
+                       } );
+               $permissionManager = new PermissionManager(
+                       $services->getSpecialPageFactory(),
+                       $revisionLookup,
+                       [],
+                       [],
+                       false,
+                       false,
+                       [],
+                       [],
+                       [],
+                       MediaWikiServices::getInstance()->getNamespaceInfo()
+               );
+               $this->setService( 'PermissionManager', $permissionManager );
+
+               $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'editmyuserjs' ] );
+
+               $revision = $this->getJavascriptRevision( $localJsTitle, $user, '/* script */' );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $otherLocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [ [ 'mycustomjsredirectprotected', 'edit' ] ], $errors );
+
+               $permissionManager->overrideUserRightsForTesting( $user,
+                       [ 'edit', 'editmyuserjs', 'editmyuserjsredirect' ] );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+       }
+
        /**
         * @todo This test method should be split up into separate test methods and
         * data providers
@@ -1683,4 +1745,35 @@ class PermissionManagerTest extends MediaWikiLangTestCase {
                $this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) );
        }
 
+       /**
+        * Create a RevisionRecord with a single Javascript main slot.
+        * @param Title $title
+        * @param User $user
+        * @param string $text
+        * @return MutableRevisionRecord
+        */
+       private function getJavascriptRevision( Title $title, User $user, $text ) {
+               $content = ContentHandler::makeContent( $text, $title, CONTENT_MODEL_JAVASCRIPT );
+               $revision = new MutableRevisionRecord( $title );
+               $revision->setContent( 'main', $content );
+               return $revision;
+       }
+
+       /**
+        * Create a RevisionRecord with a single Javascript redirect main slot.
+        * @param Title $title
+        * @param Title $redirectTargetTitle
+        * @param User $user
+        * @return MutableRevisionRecord
+        */
+       private function getJavascriptRedirectRevision(
+               Title $title, Title $redirectTargetTitle, User $user
+       ) {
+               $content = ContentHandler::getForModelID( CONTENT_MODEL_JAVASCRIPT )
+                       ->makeRedirectContent( $redirectTargetTitle );
+               $revision = new MutableRevisionRecord( $title );
+               $revision->setContent( 'main', $content );
+               return $revision;
+       }
+
 }
index e31357c..a57745b 100644 (file)
@@ -35,10 +35,15 @@ class SpecialMuteTest extends SpecialPageTestBase {
 
        /**
         * @covers SpecialMute::execute
-        * @expectedExceptionMessage Muting users from sending you emails is not enabled
+        * @expectedExceptionMessage Mute features are unavailable
         * @expectedException ErrorPageError
         */
        public function testEmailBlacklistNotEnabled() {
+               $this->setTemporaryHook(
+                       'SpecialMuteModifyFormFields',
+                       null
+               );
+
                $this->setMwGlobals( [
                        'wgEnableUserEmailBlacklist' => false
                ] );
@@ -72,7 +77,7 @@ class SpecialMuteTest extends SpecialPageTestBase {
                $loggedInUser->confirmEmail();
                $loggedInUser->saveSettings();
 
-               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => 1 ], true );
+               $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => true ], true );
                list( $html, ) = $this->executeSpecialPage(
                        $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
                );
@@ -99,7 +104,7 @@ class SpecialMuteTest extends SpecialPageTestBase {
                $loggedInUser->confirmEmail();
                $loggedInUser->saveSettings();
 
-               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => false ], true );
+               $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => false ], true );
                list( $html, ) = $this->executeSpecialPage(
                        $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
                );
index b992a86..d7901be 100644 (file)
@@ -171,7 +171,7 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'wiki',
                        $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ),
-                       'get(): simple setting on an non-existing wiki'
+                       'get(): simple setting on a non-existing wiki'
                );
 
                // Fallback
@@ -209,12 +209,12 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'wiki',
                        $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ),
-                       'get(): fallback setting on an non-existing wiki'
+                       'get(): fallback setting on a non-existing wiki'
                );
                $this->assertEquals(
                        'tag',
                        $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ),
-                       'get(): fallback setting on an non-existing wiki (with wiki tag)'
+                       'get(): fallback setting on a non-existing wiki (with wiki tag)'
                );
 
                // Merging
@@ -263,12 +263,12 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        $common,
                        $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ),
-                       'get(): merging setting on an non-existing wiki'
+                       'get(): merging setting on a non-existing wiki'
                );
                $this->assertEquals(
                        $commonTag,
                        $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ),
-                       'get(): merging setting on an non-existing wiki (with tag)'
+                       'get(): merging setting on a non-existing wiki (with tag)'
                );
        }
 
@@ -324,7 +324,7 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'es wiki eswiki',
                        $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ),
-                       'get(): parameter replacement on an non-existing wiki'
+                       'get(): parameter replacement on a non-existing wiki'
                );
        }