X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=blobdiff_plain;f=includes%2Fresourceloader%2FResourceLoader.php;h=ca83ff357d011e36d5589e59337ff6f58ce31836;hp=7e623b53a757f5f0502cad281c03f150ad5b9cec;hb=1b32592d9c64226226bf77baff4b1be944db2697;hpb=bc22ecd0c0a0234349e2cc3aacdeef411431bf0b diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 7e623b53a7..54ab6a1242 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -1,7 +1,5 @@ . + */ + +/** + * ResourceLoader is a loading system for JavaScript and CSS resources. + * + * For higher level documentation, see . * - * Most of the documentation is on the MediaWiki documentation wiki starting at: - * https://www.mediawiki.org/wiki/ResourceLoader + * @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 LocalSettings.php 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,25 +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' ) { - 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; } /** @@ -584,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 + ); } /** @@ -652,24 +689,29 @@ class ResourceLoader implements LoggerAwareInterface { * * @since 1.28 * @param ResourceLoaderContext $context + * @param string[]|null $modules * @return string Hash */ - public function makeVersionQuery( ResourceLoaderContext $context ) { + public function makeVersionQuery( ResourceLoaderContext $context, array $modules = null ) { + if ( $modules === null ) { + wfDeprecated( __METHOD__ . ' without $modules', '1.34' ); + $modules = $context->getModules(); + } // As of MediaWiki 1.28, the server and client use the same algorithm for combining // version hashes. There is no technical reason for this to be same, and for years the // implementations differed. If getCombinedVersion in PHP (used for StartupModule and // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version' // query parameter), then this method must continue to match the JS one. - $moduleNames = []; - foreach ( $context->getModules() as $name ) { + $filtered = []; + foreach ( $modules as $name ) { if ( !$this->getModule( $name ) ) { // If a versioned request contains a missing module, the version is a mismatch // as the client considered a module (and version) we don't have. return ''; } - $moduleNames[] = $name; + $filtered[] = $name; } - return $this->getCombinedVersion( $context, $moduleNames ); + return $this->getCombinedVersion( $context, $filtered ); } /** @@ -698,8 +740,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; @@ -738,6 +781,8 @@ class ResourceLoader implements LoggerAwareInterface { if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) { return; // output handled } + } else { + $fileCache = null; } // Generate a response @@ -752,15 +797,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() ); } } @@ -776,9 +823,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(' + . $context->encodeJson( $errorText ) + . "); }\n"; } // Prepend error info to the response @@ -821,7 +868,7 @@ class ResourceLoader implements LoggerAwareInterface { // - Version mismatch (T117587, T47877) if ( is_null( $context->getVersion() ) || $errors - || $context->getVersion() !== $this->makeVersionQuery( $context ) + || $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() ) ) { $maxage = $rlMaxage['unversioned']['client']; $smaxage = $rlMaxage['unversioned']['server']; @@ -1051,7 +1098,14 @@ MESSAGE; $strContent = $scripts; } elseif ( is_array( $scripts ) ) { // ...except when $scripts is an array of URLs or an associative array - $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] ); + $strContent = self::makeLoaderImplementScript( + $context, + $implementKey, + $scripts, + [], + [], + [] + ); } break; case 'styles': @@ -1077,6 +1131,7 @@ MESSAGE; } } $strContent = self::makeLoaderImplementScript( + $context, $implementKey, $scripts, $content['styles'] ?? [], @@ -1122,7 +1177,7 @@ MESSAGE; // Set the state of modules we didn't respond to with mw.loader.implement if ( $states ) { - $stateScript = self::makeLoaderStateScript( $states ); + $stateScript = self::makeLoaderStateScript( $context, $states ); if ( !$context->getDebug() ) { $stateScript = self::filter( 'minify-js', $stateScript ); } @@ -1131,7 +1186,7 @@ MESSAGE; } } elseif ( $states ) { $this->errors[] = 'Problematic modules: ' - . self::encodeJsonForScript( $states ); + . $context->encodeJson( $states ); } return $out; @@ -1170,6 +1225,7 @@ MESSAGE; /** * Return JS code that calls mw.loader.implement with given module properties. * + * @param ResourceLoaderContext $context * @param string $name Module name or implement key (format "`[name]@[version]`") * @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure), * list of URLs to JavaScript files, string of JavaScript for `$.globalEval`, or array with @@ -1184,13 +1240,13 @@ MESSAGE; * @throws MWException * @return string JavaScript code */ - protected static function makeLoaderImplementScript( - $name, $scripts, $styles, $messages, $templates + private static function makeLoaderImplementScript( + ResourceLoaderContext $context, $name, $scripts, $styles, $messages, $templates ) { if ( $scripts instanceof XmlJsCode ) { if ( $scripts->value === '' ) { $scripts = null; - } elseif ( self::inDebugMode() ) { + } elseif ( $context->getDebug() ) { $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" ); } else { $scripts = new XmlJsCode( 'function($,jQuery,require,module){' . $scripts->value . '}' ); @@ -1202,7 +1258,7 @@ MESSAGE; // All of these essentially do $file = $file['content'];, some just have wrapping around it if ( $file['type'] === 'script' ) { // Multi-file modules only get two parameters ($ and jQuery are being phased out) - if ( self::inDebugMode() ) { + if ( $context->getDebug() ) { $file = new XmlJsCode( "function ( require, module ) {\n{$file['content']}\n}" ); } else { $file = new XmlJsCode( 'function(require,module){' . $file['content'] . '}' ); @@ -1213,8 +1269,8 @@ MESSAGE; } $scripts = XmlJsCode::encodeObject( [ 'main' => $scripts['main'], - 'files' => XmlJsCode::encodeObject( $files, self::inDebugMode() ) - ], self::inDebugMode() ); + 'files' => XmlJsCode::encodeObject( $files, $context->getDebug() ) + ], $context->getDebug() ); } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) { throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' ); } @@ -1231,14 +1287,13 @@ MESSAGE; ]; self::trimArray( $module ); - return Xml::encodeJsCall( 'mw.loader.implement', $module, self::inDebugMode() ); + return Xml::encodeJsCall( 'mw.loader.implement', $module, $context->getDebug() ); } /** * 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 ) { @@ -1287,8 +1342,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 @@ -1311,25 +1366,22 @@ MESSAGE; } /** - * Returns a JS call to mw.loader.state, which sets the state of one - * ore more modules to a given value. Has two calling conventions: - * - * - ResourceLoader::makeLoaderStateScript( $name, $state ): - * Set the state of a single module called $name to $state + * Returns a JS call to mw.loader.state, which sets the state of modules + * to a given value: * - * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ): + * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ): * Set the state of modules with the given names to the given states * - * @param array|string $states - * @param string|null $state + * @internal + * @param ResourceLoaderContext $context + * @param array $states * @return string JavaScript code */ - public static function makeLoaderStateScript( $states, $state = null ) { - if ( !is_array( $states ) ) { - $states = [ $states => $state ]; - } + public static function makeLoaderStateScript( + ResourceLoaderContext $context, array $states + ) { return 'mw.loader.state(' - . self::encodeJsonForScript( $states ) + . $context->encodeJson( $states ) . ');'; } @@ -1374,15 +1426,15 @@ MESSAGE; * @par Example * @code * - * ResourceLoader::makeLoaderRegisterScript( [ + * ResourceLoader::makeLoaderRegisterScript( $context, [ * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ], * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ], * ... * ] ): * @endcode * - * @internal - * @since 1.32 + * @internal For use by ResourceLoaderStartUpModule only + * @param ResourceLoaderContext $context * @param array $modules Array of module registration arrays, each containing * - string: module name * - string: module version @@ -1392,7 +1444,9 @@ MESSAGE; * - string|null: Script body of a skip function (optional) * @return string JavaScript code */ - public static function makeLoaderRegisterScript( array $modules ) { + public static function makeLoaderRegisterScript( + ResourceLoaderContext $context, array $modules + ) { // Optimisation: Transform dependency names into indexes when possible // to produce smaller output. They are expanded by mw.loader.register on // the other end using resolveIndexedDependencies(). @@ -1415,30 +1469,29 @@ MESSAGE; array_walk( $modules, [ self::class, 'trimArray' ] ); return 'mw.loader.register(' - . self::encodeJsonForScript( $modules ) + . $context->encodeJson( $modules ) . ');'; } /** * Returns JS code which calls mw.loader.addSource() with the given - * parameters. Has two calling conventions: + * parameters. * - * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ): - * Register a single source - * - * - ResourceLoader::makeLoaderSourcesScript( [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] ); + * - ResourceLoader::makeLoaderSourcesScript( $context, + * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] + * ); * Register sources with the given IDs and properties. * - * @param string|array $sources Source ID - * @param string|null $loadUrl load.php url + * @internal For use by ResourceLoaderStartUpModule only + * @param ResourceLoaderContext $context + * @param array $sources * @return string JavaScript code */ - public static function makeLoaderSourcesScript( $sources, $loadUrl = null ) { - if ( !is_array( $sources ) ) { - $sources = [ $sources => $loadUrl ]; - } + public static function makeLoaderSourcesScript( + ResourceLoaderContext $context, array $sources + ) { return 'mw.loader.addSource(' - . self::encodeJsonForScript( $sources ) + . $context->encodeJson( $sources ) . ');'; } @@ -1509,20 +1562,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);"; } /** @@ -1768,10 +1817,11 @@ MESSAGE; * Get global LESS variables. * * @since 1.27 - * @deprecated since 1.32 Use ResourceLoderModule::getLessVars() instead. + * @deprecated since 1.32 Use ResourceLoaderModule::getLessVars() instead. * @return array Map of variable names to string CSS values. */ public function getLessVars() { + wfDeprecated( __METHOD__, '1.32' ); return []; } }