Merge "Add .pipeline/ with dev image variant"
[lhc/web/wiklou.git] / includes / resourceloader / ResourceLoader.php
index 671fe86..d959ff6 100644 (file)
@@ -1,7 +1,5 @@
 <?php
 /**
- * Base class for resource loading system.
- *
  * 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
@@ -30,72 +28,55 @@ use Wikimedia\Rdbms\DBConnectionError;
 use Wikimedia\WrappedString;
 
 /**
- * Dynamic JavaScript and CSS resource loading system.
+ * @defgroup ResourceLoader ResourceLoader
  *
- * Most of the documentation is on the MediaWiki documentation wiki starting at:
- *    https://www.mediawiki.org/wiki/ResourceLoader
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
+ */
+
+/**
+ * ResourceLoader is a loading system for JavaScript and CSS resources.
+ *
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
+ *
+ * @ingroup ResourceLoader
+ * @since 1.17
  */
 class ResourceLoader implements LoggerAwareInterface {
-       /** @var int */
-       const CACHE_VERSION = 8;
+       /** @var Config */
+       protected $config;
+       /** @var MessageBlobStore */
+       protected $blobStore;
 
-       /** @var bool */
-       protected static $debugMode = null;
+       /** @var LoggerInterface */
+       private $logger;
 
-       /**
-        * Module name/ResourceLoaderModule object pairs
-        * @var array
-        */
+       /** @var ResourceLoaderModule[] Map of (module name => ResourceLoaderModule) */
        protected $modules = [];
-
-       /**
-        * Associative array mapping module name to info associative array
-        * @var array
-        */
+       /** @var array[] Map of (module name => associative info array) */
        protected $moduleInfos = [];
-
-       /** @var Config $config */
-       protected $config;
-
        /**
         * Associative array mapping framework ids to a list of names of test suite modules
         * like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ]
         * @var array
         */
        protected $testModuleNames = [];
+       /** @var string[] List of module names that contain QUnit test suites */
+       protected $testSuiteModuleNames = [];
 
-       /**
-        * E.g. [ 'source-id' => 'http://.../load.php' ]
-        * @var array
-        */
+       /** @var array Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
        protected $sources = [];
-
-       /**
-        * Errors accumulated during current respond() call.
-        * @var array
-        */
+       /** @var array Errors accumulated during current respond() call */
        protected $errors = [];
-
-       /**
-        * List of extra HTTP response headers provided by loaded modules.
-        *
-        * Populated by makeModuleResponse().
-        *
-        * @var array
-        */
+       /** @var string[] Extra HTTP response headers from modules loaded in makeModuleResponse() */
        protected $extraHeaders = [];
 
-       /**
-        * @var MessageBlobStore
-        */
-       protected $blobStore;
+       /** @var bool */
+       protected static $debugMode = null;
 
-       /**
-        * @var LoggerInterface
-        */
-       private $logger;
+       /** @var int */
+       const CACHE_VERSION = 8;
 
-       /** @var string JavaScript / CSS pragma to disable minification. **/
+       /** @var string JavaScript / CSS pragma to disable minification. * */
        const FILTER_NOMIN = '/*@nomin*/';
 
        /**
@@ -374,6 +355,7 @@ class ResourceLoader implements LoggerAwareInterface {
 
        /**
         * @internal For use by ServiceWiring only
+        * @codeCoverageIgnore
         */
        public function registerTestModules() {
                global $IP;
@@ -384,39 +366,37 @@ class ResourceLoader implements LoggerAwareInterface {
                                . 'Edit your <code>LocalSettings.php</code> to enable it.' );
                }
 
-               $testModules = [
-                       'qunit' => [],
-               ];
+               // This has a 'qunit' key for compat with the below hook.
+               $testModulesMeta = [ 'qunit' => [] ];
 
                // Get test suites from extensions
                // Avoid PHP 7.1 warning from passing $this by reference
                $rl = $this;
-               Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
+               Hooks::run( 'ResourceLoaderTestModules', [ &$testModulesMeta, &$rl ] );
                $extRegistry = ExtensionRegistry::getInstance();
                // In case of conflict, the deprecated hook has precedence.
-               $testModules['qunit'] += $extRegistry->getAttribute( 'QUnitTestModules' );
+               $testModules = $testModulesMeta['qunit'] + $extRegistry->getAttribute( 'QUnitTestModules' );
 
-               // Add the QUnit testrunner as implicit dependency to extension test suites.
-               foreach ( $testModules['qunit'] as &$module ) {
-                       // Shuck any single-module dependency as an array
+               $testSuiteModuleNames = [];
+               foreach ( $testModules as $name => &$module ) {
+                       // Turn any single-module dependency into an array
                        if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
                                $module['dependencies'] = [ $module['dependencies'] ];
                        }
 
+                       // Ensure the testrunner loads before any test suites
                        $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
-               }
 
-               // Get core test suites
-               $testModules['qunit'] =
-                       ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
+                       // Keep track of the test suites to load on SpecialJavaScriptTest
+                       $testSuiteModuleNames[] = $name;
+               }
 
-               foreach ( $testModules as $id => $names ) {
-                       // Register test modules
-                       $this->register( $testModules[$id] );
+               // Core test suites (their names have further precedence).
+               $testModules = ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
+               $testSuiteModuleNames[] = 'test.mediawiki.qunit.suites';
 
-                       // Keep track of their names so that they can be loaded together
-                       $this->testModuleNames[$id] = array_keys( $testModules[$id] );
-               }
+               $this->register( $testModules );
+               $this->testSuiteModuleNames = $testSuiteModuleNames;
        }
 
        /**
@@ -470,26 +450,14 @@ class ResourceLoader implements LoggerAwareInterface {
        }
 
        /**
-        * Get a list of test module names for one (or all) frameworks.
+        * Get a list of module names with QUnit test suites.
         *
-        * If the given framework id is unknkown, or if the in-object variable is not an array,
-        * then it will return an empty array.
-        *
-        * @param string $framework Get only the test module names for one
-        *   particular framework (optional)
+        * @internal For use by SpecialJavaScriptTest only
         * @return array
+        * @codeCoverageIgnore
         */
-       public function getTestModuleNames( $framework = 'all' ) {
-               /** @todo api siteinfo prop testmodulenames modulenames */
-               if ( $framework == 'all' ) {
-                       return $this->testModuleNames;
-               } elseif ( isset( $this->testModuleNames[$framework] )
-                       && is_array( $this->testModuleNames[$framework] )
-               ) {
-                       return $this->testModuleNames[$framework];
-               } else {
-                       return [];
-               }
+       public function getTestSuiteModuleNames() {
+               return $this->testSuiteModuleNames;
        }
 
        /**
@@ -585,13 +553,81 @@ class ResourceLoader implements LoggerAwareInterface {
        }
 
        /**
+        * @internal For use by ResourceLoaderStartUpModule only.
+        */
+       const HASH_LENGTH = 5;
+
+       /**
+        * Create a hash for module versioning purposes.
+        *
+        * This hash is used in three ways:
+        *
+        * - To differentiate between the current version and a past version
+        *   of a module by the same name.
+        *
+        *   In the cache key of localStorage in the browser (mw.loader.store).
+        *   This store keeps only one version of any given module. As long as the
+        *   next version the client encounters has a different hash from the last
+        *   version it saw, it will correctly discard it in favour of a network fetch.
+        *
+        *   A browser may evict a site's storage container for any reason (e.g. when
+        *   the user hasn't visited a site for some time, and/or when the device is
+        *   low on storage space). Anecdotally it seems devices rarely keep unused
+        *   storage beyond 2 weeks on mobile devices and 4 weeks on desktop.
+        *   But, there is no hard limit or expiration on localStorage.
+        *   ResourceLoader's Client also clears localStorage when the user changes
+        *   their language preference or when they (temporarily) use Debug Mode.
+        *
+        *   The only hard factors that reduce the range of possible versions are
+        *   1) the name and existence of a given module, and
+        *   2) the TTL for mw.loader.store, and
+        *   3) the `$wgResourceLoaderStorageVersion` configuration variable.
+        *
+        * - To identify a batch response of modules from load.php in an HTTP cache.
+        *
+        *   When fetching modules in a batch from load.php, a combined hash
+        *   is created by the JS code, and appended as query parameter.
+        *
+        *   In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache,
+        *   these urls are used to identify other previously cached responses.
+        *   The range of possible versions a given version has to be unique amongst
+        *   is determined by the maximum duration each response is stored for, which
+        *   is controlled by `$wgResourceLoaderMaxage['versioned']`.
+        *
+        * - To detect race conditions between multiple web servers in a MediaWiki
+        *   deployment of which some have the newer version and some still the older
+        *   version.
+        *
+        *   An HTTP request from a browser for the Startup manifest may be responded
+        *   to by a server with the newer version. The browser may then use that to
+        *   request a given module, which may then be responded to by a server with
+        *   the older version. To avoid caching this for too long (which would pollute
+        *   all other users without repairing itself), the combined hash that the JS
+        *   client adds to the url is verified by the server (in ::sendResponseHeaders).
+        *   If they don't match, we instruct cache proxies and clients to not cache
+        *   this response as long as they normally would. This is also the reason
+        *   that the algorithm used here in PHP must match the one used in JS.
+        *
+        * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and
+        * needs up to 7 chars in base 36.
+        * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga),
+        * (but with fnv132 we'd use very little of this range, mostly padding).
+        * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga).
+        * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega).
+        *
         * @since 1.26
         * @param string $value
         * @return string Hash
         */
        public static function makeHash( $value ) {
                $hash = hash( 'fnv132', $value );
-               return Wikimedia\base_convert( $hash, 16, 36, 7 );
+               // The base_convert will pad it (if too short),
+               // then substr() will trim it (if too long).
+               return substr(
+                       Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
+                       0,
+                       self::HASH_LENGTH
+               );
        }
 
        /**
@@ -699,8 +735,9 @@ class ResourceLoader implements LoggerAwareInterface {
                                // Do not allow private modules to be loaded from the web.
                                // This is a security issue, see T36907.
                                if ( $module->getGroup() === 'private' ) {
+                                       // Not a serious error, just means something is trying to access it (T101806)
                                        $this->logger->debug( "Request for private module '$name' denied" );
-                                       $this->errors[] = "Cannot show private module \"$name\"";
+                                       $this->errors[] = "Cannot build private module \"$name\"";
                                        continue;
                                }
                                $modules[$name] = $module;
@@ -739,6 +776,8 @@ class ResourceLoader implements LoggerAwareInterface {
                        if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
                                return; // output handled
                        }
+               } else {
+                       $fileCache = null;
                }
 
                // Generate a response
@@ -753,15 +792,17 @@ class ResourceLoader implements LoggerAwareInterface {
                        }
                }
 
-               // Save response to file cache unless there are errors
-               if ( isset( $fileCache ) && !$this->errors && $missing === [] ) {
-                       // Cache single modules and images...and other requests if there are enough hits
-                       if ( ResourceFileCache::useFileCache( $context ) ) {
-                               if ( $fileCache->isCacheWorthy() ) {
-                                       $fileCache->saveText( $response );
-                               } else {
-                                       $fileCache->incrMissesRecent( $context->getRequest() );
-                               }
+               // Consider saving the response to file cache (unless there are errors).
+               if ( $fileCache &&
+                       !$this->errors &&
+                       $missing === [] &&
+                       ResourceFileCache::useFileCache( $context )
+               ) {
+                       if ( $fileCache->isCacheWorthy() ) {
+                               // There were enough hits, save the response to the cache
+                               $fileCache->saveText( $response );
+                       } else {
+                               $fileCache->incrMissesRecent( $context->getRequest() );
                        }
                }
 
@@ -777,9 +818,9 @@ class ResourceLoader implements LoggerAwareInterface {
                        $errorText = implode( "\n\n", $this->errors );
                        $errorResponse = self::makeComment( $errorText );
                        if ( $context->shouldIncludeScripts() ) {
-                               $errorResponse .= 'if (window.console && console.error) {'
-                                       . Xml::encodeJsCall( 'console.error', [ $errorText ] )
-                                       . "}\n";
+                               $errorResponse .= 'if (window.console && console.error) { console.error('
+                                       . self::encodeJsonForScript( $errorText )
+                                       . "); }\n";
                        }
 
                        // Prepend error info to the response
@@ -1238,8 +1279,7 @@ MESSAGE;
        /**
         * Returns JS code which, when called, will register a given list of messages.
         *
-        * @param mixed $messages Either an associative array mapping message key to value, or a
-        *   JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
+        * @param mixed $messages Associative array mapping message key to value.
         * @return string JavaScript code
         */
        public static function makeMessageSetScript( $messages ) {
@@ -1288,8 +1328,8 @@ MESSAGE;
         *
         * @internal
         * @since 1.32
-        * @param bool|string|array $data
-        * @return string JSON
+        * @param mixed $data
+        * @return string|false JSON string, false on error
         */
        public static function encodeJsonForScript( $data ) {
                // Keep output as small as possible by disabling needless escape modes
@@ -1510,20 +1550,16 @@ MESSAGE;
         * @throws Exception
         */
        public static function makeConfigSetScript( array $configuration ) {
-               $js = Xml::encodeJsCall(
-                       'mw.config.set',
-                       [ $configuration ],
-                       self::inDebugMode()
-               );
-               if ( $js === false ) {
+               $json = self::encodeJsonForScript( $configuration );
+               if ( $json === false ) {
                        $e = new Exception(
                                'JSON serialization of config data failed. ' .
                                'This usually means the config data is not valid UTF-8.'
                        );
                        MWExceptionHandler::logException( $e );
-                       $js = Xml::encodeJsCall( 'mw.log.error', [ $e->__toString() ] );
+                       return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
                }
-               return $js;
+               return "mw.config.set($json);";
        }
 
        /**