X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=blobdiff_plain;f=includes%2Fresourceloader%2FResourceLoader.php;h=5d104d389a7d40c2b5c10c1d4a53cac38953bf60;hp=0785225d2ca39c376a324cdced2d317b5949d5db;hb=1791c928939f906627b9fb86c57ff8d9d626cbdb;hpb=529fc12d2ad2032337594389448fdb5b55802830 diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 0785225d2c..5d104d389a 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 . + * + * @ingroup ResourceLoader + * @since 1.17 */ class ResourceLoader implements LoggerAwareInterface { - /** @var Config $config */ + /** @var Config */ protected $config; /** @var MessageBlobStore */ protected $blobStore; @@ -547,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 + ); } /** @@ -615,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 ); } /** @@ -744,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 @@ -789,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']; @@ -1019,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': @@ -1045,6 +1131,7 @@ MESSAGE; } } $strContent = self::makeLoaderImplementScript( + $context, $implementKey, $scripts, $content['styles'] ?? [], @@ -1090,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 ); } @@ -1099,7 +1186,7 @@ MESSAGE; } } elseif ( $states ) { $this->errors[] = 'Problematic modules: ' - . self::encodeJsonForScript( $states ); + . $context->encodeJson( $states ); } return $out; @@ -1138,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 @@ -1152,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 . '}' ); @@ -1170,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'] . '}' ); @@ -1181,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.' ); } @@ -1199,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 ) { @@ -1255,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 @@ -1282,22 +1369,26 @@ 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 ): + * - ResourceLoader::makeLoaderStateScript( $context, $name, $state ): * Set the state of a single module called $name to $state * - * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ): + * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ): * Set the state of modules with the given names to the given states * + * @internal + * @param ResourceLoaderContext $context * @param array|string $states * @param string|null $state * @return string JavaScript code */ - public static function makeLoaderStateScript( $states, $state = null ) { + public static function makeLoaderStateScript( + ResourceLoaderContext $context, $states, $state = null + ) { if ( !is_array( $states ) ) { $states = [ $states => $state ]; } return 'mw.loader.state(' - . self::encodeJsonForScript( $states ) + . $context->encodeJson( $states ) . ');'; } @@ -1342,15 +1433,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 @@ -1360,7 +1451,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(). @@ -1383,7 +1476,7 @@ MESSAGE; array_walk( $modules, [ self::class, 'trimArray' ] ); return 'mw.loader.register(' - . self::encodeJsonForScript( $modules ) + . $context->encodeJson( $modules ) . ');'; } @@ -1391,22 +1484,28 @@ MESSAGE; * Returns JS code which calls mw.loader.addSource() with the given * parameters. Has two calling conventions: * - * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ): + * - ResourceLoader::makeLoaderSourcesScript( $context, $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. * + * @internal For use by ResourceLoaderStartUpModule only + * @param ResourceLoaderContext $context * @param string|array $sources Source ID * @param string|null $loadUrl load.php url * @return string JavaScript code */ - public static function makeLoaderSourcesScript( $sources, $loadUrl = null ) { + public static function makeLoaderSourcesScript( + ResourceLoaderContext $context, $sources, $loadUrl = null + ) { if ( !is_array( $sources ) ) { $sources = [ $sources => $loadUrl ]; } return 'mw.loader.addSource(' - . self::encodeJsonForScript( $sources ) + . $context->encodeJson( $sources ) . ');'; } @@ -1477,20 +1576,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);"; } /**