\", we need to encode either '/' or '<'.","// By default PHP escapes '/'. Let's escape '<' instead which is less common","// and allows URLs to mostly remain readable.","\t\t$jsonFlags = JSON_UNESCAPED_SLASHES |","\t\t\tJSON_UNESCAPED_UNICODE |","\t\t\tJSON_HEX_TAG |","\t\t\tJSON_HEX_AMP;","\t\tif ( self::inDebugMode() ) {","\t\t\t$jsonFlags |= JSON_PRETTY_PRINT;","\t\treturn json_encode( $data, $jsonFlags );","\t * Returns a JS call to mw.loader.state, which sets the state of modules","\t * to a given value:","\t * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ):","\t * Set the state of modules with the given names to the given states","\t * @internal For use by ResourceLoaderStartUpModule","\t * @param array $states","\tpublic static function makeLoaderStateScript(","\t\tResourceLoaderContext $context, array $states","\t\treturn 'mw.loader.state('","\t\t\t. $context->encodeJson( $states )","\tprivate static function isEmptyObject( stdClass $obj ) {","\t\tforeach ( $obj as $key => $value ) {","\t\t\treturn false;","\t\treturn true;","\t * Remove empty values from the end of an array.","\t * Values considered empty:","\t * - null","\t * - []","\t * - new XmlJsCode( '{}' )","\t * - new stdClass()","\t * - (object)[]","\t * @param array &$array","\tprivate static function trimArray( array &$array ): void {","\t\t$i = count( $array );","\t\twhile ( $i-- ) {","\t\t\tif ( $array[$i] === null","\t\t\t\t|| $array[$i] === []","\t\t\t\t|| ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )","\t\t\t\t|| ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )","\t\t\t) {","\t\t\t\tunset( $array[$i] );","\t\t\t\tbreak;","\t * Format JS code which calls `mw.loader.register()` with the given parameters.","\t * @par Example","\t * @code","\t * ResourceLoader::makeLoaderRegisterScript( $context, [","\t * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],","\t * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],","\t * ...","\t * ] ):","\t * @endcode","\t * @internal For use by ResourceLoaderStartUpModule only","\t * @param array[] $modules Array of module registration arrays, each containing","\t * - string: module name","\t * - string: module version","\t * - array|null: List of dependencies (optional)","\t * - string|null: Module group (optional)","\t * - string|null: Name of foreign module source, or 'local' (optional)","\t * - string|null: Script body of a skip function (optional)","\t * @phan-param array $modules","\tpublic static function makeLoaderRegisterScript(","\t\tResourceLoaderContext $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.","\t\t$index = [];","\t\tforeach ( $modules as $i => &$module ) {","// Build module name index","\t\t\t$index[$module[0]] = $i;","\t\tforeach ( $modules as &$module ) {","\t\t\tif ( isset( $module[2] ) ) {","\t\t\t\tforeach ( $module[2] as &$dependency ) {","\t\t\t\t\tif ( isset( $index[$dependency] ) ) {","// Replace module name in dependency list with index","\t\t\t\t\t\t$dependency = $index[$dependency];","\t\tarray_walk( $modules, [ self::class, 'trimArray' ] );","\t\treturn 'mw.loader.register('","\t\t\t. $context->encodeJson( $modules )","\t * Format JS code which calls `mw.loader.addSource()` with the given parameters.","\t * - ResourceLoader::makeLoaderSourcesScript( $context,","\t * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]","\t * );","\t * Register sources with the given IDs and properties.","\t * @param array $sources","\tpublic static function makeLoaderSourcesScript(","\t\tResourceLoaderContext $context, array $sources","\t\treturn 'mw.loader.addSource('","\t\t\t. $context->encodeJson( $sources )","\t * Wrap JavaScript code to run after the startup module.","\t * @param string $script JavaScript code","\tpublic static function makeLoaderConditionalScript( $script ) {","// Adds a function to lazy-created RLQ","\t\treturn '(RLQ=window.RLQ||[]).push(function(){' .","\t\t\ttrim( $script ) . '});';","\t * Wrap JavaScript code to run after a required module.","\t * @param string|string[] $modules Module name(s)","\tpublic static function makeInlineCodeWithModule( $modules, $script ) {","// Adds an array to lazy-created RLQ","\t\treturn '(RLQ=window.RLQ||[]).push(['","\t\t\t. self::encodeJsonForScript( $modules ) . ','","\t\t\t. 'function(){' . trim( $script ) . '}'","\t\t\t. ']);';","\t * Make an HTML script that runs given JS code after startup and base modules.","\t * The code will be wrapped in a closure, and it will be executed by ResourceLoader's","\t * startup module if the client has adequate support for MediaWiki JavaScript code.","\t * @param string|null $nonce Content-Security-Policy nonce","\t * (from `OutputPage->getCSP()->getNonce()`)","\t * @return string|WrappedString HTML","\tpublic static function makeInlineScript( $script, $nonce = null ) {","\t\t$js = self::makeLoaderConditionalScript( $script );","\t\t$escNonce = '';","\t\tif ( $nonce === null ) {","\t\t\twfWarn( __METHOD__ . \" did not get nonce. Will break CSP\" );","\t\t} elseif ( $nonce !== false ) {","// If it was false, CSP is disabled, so no nonce attribute.","// Nonce should be only base64 characters, so should be safe,","// but better to be safely escaped than sorry.","\t\t\t$escNonce = ' nonce=\"' . htmlspecialchars( $nonce ) . '\"';","\t\treturn new WrappedString(","\t\t\tHtml::inlineScript( $js, $nonce ),","\t\t\t\"(RLQ=window.RLQ||[]).push(function(){\",","'});'","\t * Return JS code which will set the MediaWiki configuration array to","\t * the given value.","\t * @param array $configuration List of configuration values keyed by variable name","\t * @throws Exception","\tpublic static function makeConfigSetScript( array $configuration ) {","\t\t$json = self::encodeJsonForScript( $configuration );","\t\tif ( $json === false ) {","\t\t\t$e = new Exception(","\t\t\t\t'JSON serialization of config data failed. ' .","'This usually means the config data is not valid UTF-8.'","\t\t\tMWExceptionHandler::logException( $e );","\t\t\treturn 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';","\t\treturn \"mw.config.set($json);\";","\t * Convert an array of module names to a packed query string.","\t * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`","\t * becomes `'foo.bar,baz|bar.baz,quux'`.","\t * This process is reversed by ResourceLoader::expandModuleNames().","\t * See also mw.loader#buildModulesString() which is a port of this, used","\t * on the client-side.","\t * @param string[] $modules List of module names (strings)","\t * @return string Packed query string","\tpublic static function makePackedModulesString( array $modules ) {","\t\t$moduleMap = []; // [ prefix => [ suffixes ] ]","\t\tforeach ( $modules as $module ) {","\t\t\t$pos = strrpos( $module, '.' );","\t\t\t$prefix = $pos === false ? '' : substr( $module, 0, $pos );","\t\t\t$suffix = $pos === false ? $module : substr( $module, $pos + 1 );","\t\t\t$moduleMap[$prefix][] = $suffix;","\t\t$arr = [];","\t\tforeach ( $moduleMap as $prefix => $suffixes ) {","\t\t\t$p = $prefix === '' ? '' : $prefix . '.';","\t\t\t$arr[] = $p . implode( ',', $suffixes );","\t\treturn implode( '|', $arr );","\t * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to","\t * an array of module names like `[ 'jquery.foo', 'jquery.bar',","\t * 'jquery.ui.baz', 'jquery.ui.quux' ]`.","\t * This process is reversed by ResourceLoader::makePackedModulesString().","\t * @since 1.33","\t * @param string $modules Packed module name list","\t * @return string[] Array of module names","\tpublic static function expandModuleNames( $modules ) {","\t\t$retval = [];","\t\t$exploded = explode( '|', $modules );","\t\tforeach ( $exploded as $group ) {","\t\t\tif ( strpos( $group, ',' ) === false ) {","// This is not a set of modules in foo.bar,baz notation","// but a single module","\t\t\t\t$retval[] = $group;","// This is a set of modules in foo.bar,baz notation","\t\t\t\t$pos = strrpos( $group, '.' );","\t\t\t\tif ( $pos === false ) {","// Prefixless modules, i.e. without dots","\t\t\t\t\t$retval = array_merge( $retval, explode( ',', $group ) );","// We have a prefix and a bunch of suffixes","\t\t\t\t\t$prefix = substr( $group, 0, $pos ); // 'foo'","\t\t\t\t\t$suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]","\t\t\t\t\tforeach ( $suffixes as $suffix ) {","\t\t\t\t\t\t$retval[] = \"$prefix.$suffix\";","\t\treturn $retval;","\t * Determine whether debug mode is on.","\t * Order of priority is:","\t * - 1) Request parameter,","\t * - 2) Cookie,","\t * - 3) Site configuration.","\t * @return int","\tpublic static function inDebugMode() {","\t\tif ( self::$debugMode === null ) {","\t\t\tglobal $wgRequest, $wgResourceLoaderDebug;","\t\t\t$str = $wgRequest->getRawVal( 'debug',","\t\t\t\t$wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ? 'true' : '' )","\t\t\tself::$debugMode = ResourceLoaderContext::debugFromString( $str );","\t\treturn self::$debugMode;","\t * Reset static members used for caching.","\t * Global state and $wgRequest are evil, but we're using it right","\t * now and sometimes we need to be able to force ResourceLoader to","\t * re-evaluate the context because it has changed (e.g. in the test suite).","\t * @internal For use by unit tests","\tpublic static function clearCache() {","\t\tself::$debugMode = null;","\t * Build a load.php URL","\t * @param string $source Name of the ResourceLoader source","\t * @param array $extraQuery","\t * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.","\tpublic function createLoaderURL( $source, ResourceLoaderContext $context,","\t\tarray $extraQuery = []","\t\t$query = self::createLoaderQuery( $context, $extraQuery );","\t\t$script = $this->getLoadScript( $source );","\t\treturn wfAppendQuery( $script, $query );","\t * Helper for createLoaderURL()","\t * @see makeLoaderQuery","\t * @return array","\tprotected static function createLoaderQuery(","\t\tResourceLoaderContext $context, array $extraQuery = []","\t\treturn self::makeLoaderQuery(","\t\t\t$context->getModules(),","\t\t\t$context->getLanguage(),","\t\t\t$context->getSkin(),","\t\t\t$context->getUser(),","\t\t\t$context->getVersion(),","\t\t\t$context->getDebug(),","\t\t\t$context->getOnly(),","\t\t\t$context->getRequest()->getBool( 'printable' ),","\t\t\t$context->getRequest()->getBool( 'handheld' ),","\t\t\t$extraQuery","\t * Build a query array (array representation of query string) for load.php. Helper","\t * function for createLoaderURL().","\t * @param string $lang","\t * @param string $skin","\t * @param string|null $user","\t * @param string|null $version","\t * @param int $debug","\t * @param string|null $only","\t * @param bool $printable","\t * @param bool $handheld","\tpublic static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,","\t\t$version = null, $debug = ResourceLoaderContext::DEBUG_OFF, $only = null,","\t\t$printable = false, $handheld = false, array $extraQuery = []","\t\t$query = [","\t\t\t'modules' => self::makePackedModulesString( $modules ),","// Keep urls short by omitting query parameters that","// match the defaults assumed by ResourceLoaderContext.","// Note: This relies on the defaults either being insignificant or forever constant,","// as otherwise cached urls could change in meaning when the defaults change.","\t\tif ( $lang !== ResourceLoaderContext::DEFAULT_LANG ) {","\t\t\t$query['lang'] = $lang;","\t\tif ( $skin !== ResourceLoaderContext::DEFAULT_SKIN ) {","\t\t\t$query['skin'] = $skin;","\t\tif ( $debug !== ResourceLoaderContext::DEBUG_OFF ) {","\t\t\t$query['debug'] = strval( $debug );","\t\tif ( $user !== null ) {","\t\t\t$query['user'] = $user;","\t\tif ( $version !== null ) {","\t\t\t$query['version'] = $version;","\t\tif ( $only !== null ) {","\t\t\t$query['only'] = $only;","\t\tif ( $printable ) {","\t\t\t$query['printable'] = 1;","\t\tif ( $handheld ) {","\t\t\t$query['handheld'] = 1;","\t\t$query += $extraQuery;","// Make queries uniform in order","\t\tksort( $query );","\t\treturn $query;","\t * Check a module name for validity.","\t * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be","\t * at most 255 bytes.","\t * @param string $moduleName Module name to check","\t * @return bool Whether $moduleName is a valid module name","\tpublic static function isValidModuleName( $moduleName ) {","\t\t$len = strlen( $moduleName );","\t\treturn $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;","\t * Return a LESS compiler that is set up for use with MediaWiki.","\t * @param array $vars Associative array of variables that should be used","\t * for compilation. Since 1.32, this method no longer automatically includes","\t * global LESS vars from ResourceLoader::getLessVars (T191937).","\t * @param array $importDirs Additional directories to look in for @import (since 1.36)","\t * @throws MWException","\t * @return Less_Parser","\tpublic function getLessCompiler( array $vars = [], array $importDirs = [] ) {","// When called from the installer, it is possible that a required PHP extension","// is missing (at least for now; see T49564). If this is the case, throw an","// exception (caught by the installer) to prevent a fatal error later on.","\t\tif ( !class_exists( Less_Parser::class ) ) {","\t\t\tthrow new MWException( 'MediaWiki requires the less.php parser' );","\t\t$importDirs[] = \"$IP/resources/src/mediawiki.less\";","\t\t$parser = new Less_Parser;","\t\t$parser->ModifyVars( $vars );","// SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]","\t\t$parser->SetImportDirs( array_fill_keys( $importDirs, '' ) );","\t\t$parser->SetOption( 'relativeUrls', false );","\t\treturn $parser;","\t * Resolve a possibly relative URL against a base URL.","\t * The base URL must have a server and should have a protocol.","\t * A protocol-relative base expands to HTTPS.","\t * This is a standalone version of MediaWiki's wfExpandUrl (T32956).","\t * @internal For use by core ResourceLoader classes only","\t * @param string $base","\t * @param string $url","\t * @return string URL","\tpublic function expandUrl( string $base, string $url ): string {","// Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.","\t\t$isProtoRelative = strpos( $base, '//' ) === 0;","\t\tif ( $isProtoRelative ) {","\t\t\t$base = \"https:$base\";","// Net_URL2::resolve() takes care of throwing if $base doesn't have a server.","\t\t$baseUrl = new Net_URL2( $base );","\t\t$ret = $baseUrl->resolve( $url );","\t\t\t$ret->setScheme( false );","\t\treturn $ret->getURL();","\t * Get site configuration settings to expose to JavaScript on all pages via `mw.config`.","\t * @internal Exposed for use from Resources.php","\t * @param Config $conf","\tpublic static function getSiteConfigSettings(","\t\tResourceLoaderContext $context, Config $conf","\t): array {","// Namespace related preparation","// - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.","// - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.","\t\t$contLang = MediaWikiServices::getInstance()->getContentLanguage();","\t\t$namespaceIds = $contLang->getNamespaceIds();","\t\t$caseSensitiveNamespaces = [];","\t\t$nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();","\t\tforeach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {","\t\t\t$namespaceIds[$contLang->lc( $name )] = $index;","\t\t\tif ( !$nsInfo->isCapitalized( $index ) ) {","\t\t\t\t$caseSensitiveNamespaces[] = $index;","\t\t$illegalFileChars = $conf->get( 'IllegalFileChars' );","// Build list of variables","\t\t$skin = $context->getSkin();","// Start of supported and stable config vars (for use by extensions/gadgets).","\t\t$vars = [","\t\t\t'debug' => $context->getDebug(),","\t\t\t'skin' => $skin,","\t\t\t'stylepath' => $conf->get( 'StylePath' ),","\t\t\t'wgArticlePath' => $conf->get( 'ArticlePath' ),","\t\t\t'wgScriptPath' => $conf->get( 'ScriptPath' ),","\t\t\t'wgScript' => $conf->get( 'Script' ),","\t\t\t'wgSearchType' => $conf->get( 'SearchType' ),","\t\t\t'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ),","\t\t\t'wgServer' => $conf->get( 'Server' ),","\t\t\t'wgServerName' => $conf->get( 'ServerName' ),","\t\t\t'wgUserLanguage' => $context->getLanguage(),","\t\t\t'wgContentLanguage' => $contLang->getCode(),","\t\t\t'wgVersion' => MW_VERSION,","\t\t\t'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),","\t\t\t'wgNamespaceIds' => $namespaceIds,","\t\t\t'wgContentNamespaces' => $nsInfo->getContentNamespaces(),","\t\t\t'wgSiteName' => $conf->get( 'Sitename' ),","\t\t\t'wgDBname' => $conf->get( 'DBname' ),","\t\t\t'wgWikiID' => WikiMap::getCurrentWikiId(),","\t\t\t'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,","\t\t\t'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,","\t\t\t'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ),","// End of stable config vars.","// Internal variables for use by MediaWiki core and/or ResourceLoader.","\t\t$vars += [","// @internal For mediawiki.widgets","\t\t\t'wgUrlProtocols' => wfUrlProtocols(),","// @internal For mediawiki.page.watch","// Force object to avoid \"empty\" associative array from","// becoming [] instead of {} in JS (T36604)","\t\t\t'wgActionPaths' => (object)$conf->get( 'ActionPaths' ),","// @internal For mediawiki.language","\t\t\t'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ),","// @internal For mediawiki.Title","\t\t\t'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ),","// @internal For mediawiki.cookie","\t\t\t'wgCookiePrefix' => $conf->get( 'CookiePrefix' ),","\t\t\t'wgCookieDomain' => $conf->get( 'CookieDomain' ),","\t\t\t'wgCookiePath' => $conf->get( 'CookiePath' ),","\t\t\t'wgCookieExpiration' => $conf->get( 'CookieExpiration' ),","\t\t\t'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),","\t\t\t'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),","// @internal For mediawiki.ForeignUpload","\t\t\t'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ),","\t\t\t'wgEnableUploads' => $conf->get( 'EnableUploads' ),","\t\tHooks::runner()->onResourceLoaderGetConfigVars( $vars, $skin, $conf );","\t\treturn $vars;","Powered by Gitilestxt json"]}
blob: fbcb2c0ad20f6fda76de5775b0b3877d70a8cf9c [file] [log] [blame]
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
* @author Roan Kattouw
* @author Trevor Parscal
*/
use MediaWiki\HeaderCallback;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MediaWikiServices;
use MediaWiki\ResourceLoader\HookRunner;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Wikimedia\DependencyStore\DependencyStore;
use Wikimedia\DependencyStore\KeyValueDependencyStore;
use Wikimedia\Minify\CSSMin;
use Wikimedia\Minify\JavaScriptMinifier;
use Wikimedia\Rdbms\DBConnectionError;
use Wikimedia\Timestamp\ConvertibleTimestamp;
use Wikimedia\WrappedString;
/**
* @defgroup ResourceLoader ResourceLoader
*
* For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
*/
/**
* @defgroup ResourceLoaderHooks ResourceLoader Hooks
* @ingroup ResourceLoader
* @ingroup Hooks
*/
/**
* 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 Config */
protected $config;
/** @var MessageBlobStore */
protected $blobStore;
/** @var DependencyStore */
protected $depStore;
/** @var LoggerInterface */
private $logger;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var ResourceLoaderModule[] Map of (module name => ResourceLoaderModule) */
protected $modules = [];
/** @var array[] Map of (module name => associative info array) */
protected $moduleInfos = [];
/**
* 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 = [];
/** @var array Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
protected $sources = [];
/** @var array Errors accumulated during current respond() call */
protected $errors = [];
/** @var string[] Extra HTTP response headers from modules loaded in makeModuleResponse() */
protected $extraHeaders = [];
/** @var array Map of (module-variant => buffered DependencyStore updates) */
private $depStoreUpdateBuffer = [];
/** @var array Styles that are skin-specific and supplement or replace the
* default skinStyles of a FileModule. See $wgResourceModuleSkinStyles.
*/
private $moduleSkinStyles = [];
/** @var int|null */
protected static $debugMode = null;
/** @var int */
public const CACHE_VERSION = 8;
/** @var string */
private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule';
/** @var int Expiry (in seconds) of indirect dependency information for modules */
private const RL_MODULE_DEP_TTL = BagOStuff::TTL_WEEK;
/** @var string JavaScript / CSS pragma to disable minification. * */
public const FILTER_NOMIN = '/*@nomin*/';
/**
* Load information stored in the database and dependency tracking store about modules
*
* @param string[] $moduleNames Module names
* @param ResourceLoaderContext $context ResourceLoader-specific context of the request
*/
public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) {
// Load all tracked indirect file dependencies for the modules
$vary = ResourceLoaderModule::getVary( $context );
$entitiesByModule = [];
foreach ( $moduleNames as $moduleName ) {
$entitiesByModule[$moduleName] = "$moduleName|$vary";
}
$depsByEntity = $this->depStore->retrieveMulti(
self::RL_DEP_STORE_PREFIX,
$entitiesByModule
);
// Inject the indirect file dependencies for all the modules
foreach ( $moduleNames as $moduleName ) {
$module = $this->getModule( $moduleName );
if ( $module ) {
$entity = $entitiesByModule[$moduleName];
$deps = $depsByEntity[$entity];
$paths = ResourceLoaderModule::expandRelativePaths( $deps['paths'] );
$module->setFileDependencies( $context, $paths );
}
}
// Batched version of ResourceLoaderWikiModule::getTitleInfo
$dbr = wfGetDB( DB_REPLICA );
ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames );
// Prime in-object cache for message blobs for modules with messages
$modulesWithMessages = [];
foreach ( $moduleNames as $moduleName ) {
$module = $this->getModule( $moduleName );
if ( $module && $module->getMessages() ) {
$modulesWithMessages[$moduleName] = $module;
}
}
// Prime in-object cache for message blobs for modules with messages
$lang = $context->getLanguage();
$store = $this->getMessageBlobStore();
$blobs = $store->getBlobs( $modulesWithMessages, $lang );
foreach ( $blobs as $moduleName => $blob ) {
$modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang );
}
}
/**
* Run JavaScript or CSS data through a filter, caching the filtered result for future calls.
*
* Available filters are:
*
* - minify-js
* - minify-css
*
* If $data is empty, only contains whitespace or the filter was unknown,
* $data is returned unmodified.
*
* @param string $filter Name of filter to run
* @param string $data Text to filter, such as JavaScript or CSS text
* @param array<string,bool> $options Keys:
* - (bool) cache: Whether to allow caching this data. Default: true.
* @return string Filtered data or unfiltered data
*/
public static function filter( $filter, $data, array $options = [] ) {
if ( strpos( $data, self::FILTER_NOMIN ) !== false ) {
return $data;
}
if ( isset( $options['cache'] ) && $options['cache'] === false ) {
return self::applyFilter( $filter, $data ) ?? $data;
}
$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
$cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
$key = $cache->makeGlobalKey(
'resourceloader-filter',
$filter,
self::CACHE_VERSION,
md5( $data )
);
$result = $cache->get( $key );
if ( $result === false ) {
$stats->increment( "resourceloader_cache.$filter.miss" );
$result = self::applyFilter( $filter, $data );
$cache->set( $key, $result, 24 * 3600 );
} else {
$stats->increment( "resourceloader_cache.$filter.hit" );
}
if ( $result === null ) {
// Cached failure
$result = $data;
}
return $result;
}
/**
* @param string $filter
* @param string $data
* @return string|null
*/
private static function applyFilter( $filter, $data ) {
$data = trim( $data );
if ( $data ) {
try {
$data = ( $filter === 'minify-css' )
? CSSMin::minify( $data )
: JavaScriptMinifier::minify( $data );
} catch ( Exception $e ) {
MWExceptionHandler::logException( $e );
return null;
}
}
return $data;
}
/**
* Register core modules and runs registration hooks.
* @param Config $config
* @param LoggerInterface|null $logger [optional]
* @param DependencyStore|null $tracker [optional]
*/
public function __construct(
Config $config,
LoggerInterface $logger = null,
DependencyStore $tracker = null
) {
$this->logger = $logger ?: new NullLogger();
$services = MediaWikiServices::getInstance();
$this->config = $config;
$this->hookContainer = $services->getHookContainer();
$this->hookRunner = new HookRunner( $this->hookContainer );
// Add 'local' source first
$this->addSource( 'local', $config->get( 'LoadScript' ) );
// Special module that always exists
$this->register( 'startup', [ 'class' => ResourceLoaderStartUpModule::class ] );
$this->setMessageBlobStore(
new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() )
);
$tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() );
$this->setDependencyStore( $tracker );
}
/**
* @return Config
*/
public function getConfig() {
return $this->config;
}
/**
* @since 1.26
* @param LoggerInterface $logger
*/
public function setLogger( LoggerInterface $logger ) {
$this->logger = $logger;
}
/**
* @since 1.27
* @return LoggerInterface
*/
public function getLogger() {
return $this->logger;
}
/**
* @since 1.26
* @return MessageBlobStore
*/
public function getMessageBlobStore() {
return $this->blobStore;
}
/**
* @since 1.25
* @param MessageBlobStore $blobStore
*/
public function setMessageBlobStore( MessageBlobStore $blobStore ) {
$this->blobStore = $blobStore;
}
/**
* @since 1.35
* @param DependencyStore $tracker
*/
public function setDependencyStore( DependencyStore $tracker ) {
$this->depStore = $tracker;
}
/**
* @internal For use by ServiceWiring.php
* @param array $moduleSkinStyles
*/
public function setModuleSkinStyles( array $moduleSkinStyles ) {
$this->moduleSkinStyles = $moduleSkinStyles;
}
/**
* Register a module with the ResourceLoader system.
*
* @see $wgResourceModules for the available options.
* @param string|array[] $name Module name as a string or, array of module info arrays
* keyed by name.
* @param array|null $info Module info array. When using the first parameter to register
* multiple modules at once, this parameter is optional.
* @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas)
* @throws InvalidArgumentException If the module info is not an array
*/
public function register( $name, array $info = null ) {
// Allow multiple modules to be registered in one call
$registrations = is_array( $name ) ? $name : [ $name => $info ];
foreach ( $registrations as $name => $info ) {
// Warn on duplicate registrations
if ( isset( $this->moduleInfos[$name] ) ) {
// A module has already been registered by this name
$this->logger->warning(
'ResourceLoader duplicate registration warning. ' .
'Another module has already been registered as ' . $name
);
}
// Check validity
if ( !self::isValidModuleName( $name ) ) {
throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, "
. "see ResourceLoader::isValidModuleName()" );
}
if ( !is_array( $info ) ) {
throw new InvalidArgumentException(
'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info )
);
}
// Attach module
$this->moduleInfos[$name] = $info;
}
}
/**
* @internal For use by ServiceWiring only
* @codeCoverageIgnore
*/
public function registerTestModules(): void {
global $IP;
if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
throw new MWException( 'Attempt to register JavaScript test modules '
. 'but <code>$wgEnableJavaScriptTest</code> is false. '
. 'Edit your <code>LocalSettings.php</code> to enable it.' );
}
// This has a 'qunit' key for compat with the below hook.
$testModulesMeta = [ 'qunit' => [] ];
$this->hookRunner->onResourceLoaderTestModules( $testModulesMeta, $this );
$extRegistry = ExtensionRegistry::getInstance();
// In case of conflict, the deprecated hook has precedence.
$testModules = $testModulesMeta['qunit']
+ $extRegistry->getAttribute( 'QUnitTestModules' );
$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'][] = 'mediawiki.qunit-testrunner';
// Keep track of the test suites to load on SpecialJavaScriptTest
$testSuiteModuleNames[] = $name;
}
// Core test suites (their names have further precedence).
$testModules = ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
$testSuiteModuleNames[] = 'test.MediaWiki';
$this->register( $testModules );
$this->testSuiteModuleNames = $testSuiteModuleNames;
}
/**
* Add a foreign source of modules.
*
* Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z).
*
* @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ]
* @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for
* backwards-compatibility.
* @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key.
*/
public function addSource( $sources, $loadUrl = null ) {
if ( !is_array( $sources ) ) {
$sources = [ $sources => $loadUrl ];
}
foreach ( $sources as $id => $source ) {
// Disallow duplicates
if ( isset( $this->sources[$id] ) ) {
throw new RuntimeException( 'Cannot register source ' . $id . ' twice' );
}
// Support: MediaWiki 1.24 and earlier
if ( is_array( $source ) ) {
if ( !isset( $source['loadScript'] ) ) {
throw new InvalidArgumentException( 'Each source must have a "loadScript" key' );
}
$source = $source['loadScript'];
}
$this->sources[$id] = $source;
}
}
/**
* @return string[]
*/
public function getModuleNames() {
return array_keys( $this->moduleInfos );
}
/**
* Get a list of module names with QUnit test suites.
*
* @internal For use by SpecialJavaScriptTest only
* @return string[]
* @codeCoverageIgnore
*/
public function getTestSuiteModuleNames() {
return $this->testSuiteModuleNames;
}
/**
* Check whether a ResourceLoader module is registered
*
* @since 1.25
* @param string $name
* @return bool
*/
public function isModuleRegistered( $name ) {
return isset( $this->moduleInfos[$name] );
}
/**
* Get the ResourceLoaderModule object for a given module name.
*
* If an array of module parameters exists but a ResourceLoaderModule object has not
* yet been instantiated, this method will instantiate and cache that object such that
* subsequent calls simply return the same object.
*
* @param string $name Module name
* @return ResourceLoaderModule|null If module has been registered, return a
* ResourceLoaderModule instance. Otherwise, return null.
*/
public function getModule( $name ) {
if ( !isset( $this->modules[$name] ) ) {
if ( !isset( $this->moduleInfos[$name] ) ) {
// No such module
return null;
}
// Construct the requested module object
$info = $this->moduleInfos[$name];
if ( isset( $info['factory'] ) ) {
/** @var ResourceLoaderModule $object */
$object = call_user_func( $info['factory'], $info );
} else {
$class = $info['class'] ?? ResourceLoaderFileModule::class;
/** @var ResourceLoaderModule $object */
$object = new $class( $info );
}
$object->setConfig( $this->getConfig() );
$object->setLogger( $this->logger );
$object->setHookContainer( $this->hookContainer );
$object->setName( $name );
$object->setDependencyAccessCallbacks(
[ $this, 'loadModuleDependenciesInternal' ],
[ $this, 'saveModuleDependenciesInternal' ]
);
$object->setSkinStylesOverride( $this->moduleSkinStyles );
$this->modules[$name] = $object;
}
return $this->modules[$name];
}
/**
* @internal Exposed for letting getModule() pass the callable to DependencyStore
* @param string $moduleName Module name
* @param string $variant Language/skin variant
* @return string[] List of absolute file paths
*/
public function loadModuleDependenciesInternal( $moduleName, $variant ) {
$deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" );
return ResourceLoaderModule::expandRelativePaths( $deps['paths'] );
}
/**
* @internal Exposed for letting getModule() pass the callable to DependencyStore
* @param string $moduleName Module name
* @param string $variant Language/skin variant
* @param string[] $paths List of relative paths referenced during computation
* @param string[] $priorPaths List of relative paths tracked in the dependency store
*/
public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) {
$hasPendingUpdate = (bool)$this->depStoreUpdateBuffer;
$entity = "$moduleName|$variant";
if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) {
// Dependency store needs to be updated with the new path list
if ( $paths ) {
$deps = $this->depStore->newEntityDependencies( $paths, time() );
$this->depStoreUpdateBuffer[$entity] = $deps;
} else {
$this->depStoreUpdateBuffer[$entity] = null;
}
} elseif ( $priorPaths ) {
// Dependency store needs to store the existing path list for longer
$this->depStoreUpdateBuffer[$entity] = '*';
}
// Use a DeferrableUpdate to flush the buffered dependency updates...
if ( !$hasPendingUpdate ) {
DeferredUpdates::addCallableUpdate( function () {
$updatesByEntity = $this->depStoreUpdateBuffer;
$this->depStoreUpdateBuffer = []; // consume
$cache = ObjectCache::getLocalClusterInstance();
$scopeLocks = [];
$depsByEntity = [];
$entitiesUnreg = [];
$entitiesRenew = [];
foreach ( $updatesByEntity as $entity => $update ) {
$lockKey = $cache->makeKey( 'rl-deps', $entity );
$scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 );
if ( !$scopeLocks[$entity] ) {
// avoid duplicate write request slams (T124649)
// the lock must be specific to the current wiki (T247028)
continue;
}
if ( $update === null ) {
$entitiesUnreg[] = $entity;
} elseif ( $update === '*' ) {
$entitiesRenew[] = $entity;
} else {
$depsByEntity[$entity] = $update;
}
}
$ttl = self::RL_MODULE_DEP_TTL;
$this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl );
$this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg );
$this->depStore->renew( self::RL_DEP_STORE_PREFIX, $entitiesRenew, $ttl );
} );
}
}
/**
* Get the list of sources.
*
* @return array Like [ id => load.php url, ... ]
*/
public function getSources() {
return $this->sources;
}
/**
* Get the URL to the load.php endpoint for the given ResourceLoader source.
*
* @since 1.24
* @param string $source Source ID
* @return string
* @throws UnexpectedValueException If the source ID was not registered
*/
public function getLoadScript( $source ) {
if ( !isset( $this->sources[$source] ) ) {
throw new UnexpectedValueException( "Unknown source '$source'" );
}
return $this->sources[$source];
}
/**
* @internal For use by ResourceLoaderStartUpModule only.
*/
public 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 );
// 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
);
}
/**
* Add an error to the 'errors' array and log it.
*
* @internal For use by ResourceLoaderStartUpModule.
* @since 1.29
* @param Exception $e
* @param string $msg
* @param array $context
*/
public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) {
MWExceptionHandler::logException( $e );
$this->logger->warning(
$msg,
$context + [ 'exception' => $e ]
);
$this->errors[] = self::formatExceptionNoComment( $e );
}
/**
* Helper method to get and combine versions of multiple modules.
*
* @since 1.26
* @param ResourceLoaderContext $context
* @param string[] $moduleNames List of known module names
* @return string Hash
*/
public function getCombinedVersion( ResourceLoaderContext $context, array $moduleNames ) {
if ( !$moduleNames ) {
return '';
}
$hashes = array_map( function ( $module ) use ( $context ) {
try {
return $this->getModule( $module )->getVersionHash( $context );
} catch ( Exception $e ) {
// If modules fail to compute a version, don't fail the request (T152266)
// and still compute versions of other modules.
$this->outputErrorAndLog( $e,
'Calculating version for "{module}" failed: {exception}',
[
'module' => $module,
]
);
return '';
}
}, $moduleNames );
return self::makeHash( implode( '', $hashes ) );
}
/**
* Get the expected value of the 'version' query parameter.
*
* This is used by respond() to set a short Cache-Control header for requests with
* information newer than the current server has. This avoids pollution of edge caches.
* Typically during deployment. (T117587)
*
* This MUST match return value of `mw.loader#getCombinedVersion()` client-side.
*
* @since 1.28
* @param ResourceLoaderContext $context
* @param string[] $modules
* @return string Hash
*/
public function makeVersionQuery( ResourceLoaderContext $context, array $modules ) {
// 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.
$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 '';
}
$filtered[] = $name;
}
return $this->getCombinedVersion( $context, $filtered );
}
/**
* Output a response to a load request, including the content-type header.
*
* @param ResourceLoaderContext $context Context in which a response should be formed
*/
public function respond( ResourceLoaderContext $context ) {
// Buffer output to catch warnings. Normally we'd use ob_clean() on the
// top-level output buffer to clear warnings, but that breaks when ob_gzhandler
// is used: ob_clean() will clear the GZIP header in that case and it won't come
// back for subsequent output, resulting in invalid GZIP. So we have to wrap
// the whole thing in our own output buffer to be sure the active buffer
// doesn't use ob_gzhandler.
// See https://bugs.php.net/bug.php?id=36514
ob_start();
$this->measureResponseTime( RequestContext::getMain()->getTiming() );
// Find out which modules are missing and instantiate the others
$modules = [];
$missing = [];
foreach ( $context->getModules() as $name ) {
$module = $this->getModule( $name );
if ( $module ) {
// 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 build private module \"$name\"";
continue;
}
$modules[$name] = $module;
} else {
$missing[] = $name;
}
}
try {
// Preload for getCombinedVersion() and for batch makeModuleResponse()
$this->preloadModuleInfo( array_keys( $modules ), $context );
} catch ( Exception $e ) {
$this->outputErrorAndLog( $e, 'Preloading module info failed: {exception}' );
}
// Combine versions to propagate cache invalidation
$versionHash = '';
try {
$versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
} catch ( Exception $e ) {
$this->outputErrorAndLog( $e, 'Calculating version hash failed: {exception}' );
}
// See RFC 2616 § 3.11 Entity Tags
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
$etag = 'W/"' . $versionHash . '"';
// Try the client-side cache first
if ( $this->tryRespondNotModified( $context, $etag ) ) {
return; // output handled (buffers cleared)
}
// Use file cache if enabled and available...
if ( $this->config->get( 'UseFileCache' ) ) {
$fileCache = ResourceFileCache::newFromContext( $context );
if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
return; // output handled
}
} else {
$fileCache = null;
}
// Generate a response
$response = $this->makeModuleResponse( $context, $modules, $missing );
// Capture any PHP warnings from the output buffer and append them to the
// error list if we're in debug mode.
if ( $context->getDebug() ) {
$warnings = ob_get_contents();
if ( strlen( $warnings ) ) {
$this->errors[] = $warnings;
}
}
// 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() );
}
}
$this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders );
// Remove the output buffer and output the response
ob_end_clean();
if ( $context->getImageObj() && $this->errors ) {
// We can't show both the error messages and the response when it's an image.
$response = implode( "\n\n", $this->errors );
} elseif ( $this->errors ) {
$errorText = implode( "\n\n", $this->errors );
$errorResponse = self::makeComment( $errorText );
if ( $context->shouldIncludeScripts() ) {
$errorResponse .= 'if (window.console && console.error) { console.error('
. $context->encodeJson( $errorText )
. "); }\n";
}
// Prepend error info to the response
$response = $errorResponse . $response;
}
$this->errors = [];
// @phan-suppress-next-line SecurityCheck-XSS
echo $response;
}
protected function measureResponseTime( Timing $timing ) {
DeferredUpdates::addCallableUpdate( static function () use ( $timing ) {
$measure = $timing->measure( 'responseTime', 'requestStart', 'requestShutdown' );
if ( $measure !== false ) {
$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
$stats->timing( 'resourceloader.responseTime', $measure['duration'] * 1000 );
}
} );
}
/**
* Send main response headers to the client.
*
* Deals with Content-Type, CORS (for stylesheets), and caching.
*
* @param ResourceLoaderContext $context
* @param string $etag ETag header value
* @param bool $errors Whether there are errors in the response
* @param string[] $extra Array of extra HTTP response headers
*/
protected function sendResponseHeaders(
ResourceLoaderContext $context, $etag, $errors, array $extra = []
): void {
HeaderCallback::warnIfHeadersSent();
$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
// Use a short cache expiry so that updates propagate to clients quickly, if:
// - No version specified (shared resources, e.g. stylesheets)
// - There were errors (recover quickly)
// - Version mismatch (T117587, T47877)
if ( $context->getVersion() === null
|| $errors
|| $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
) {
$maxage = $rlMaxage['unversioned'];
// If a version was specified we can use a longer expiry time since changing
// version numbers causes cache misses
} else {
$maxage = $rlMaxage['versioned'];
}
if ( $context->getImageObj() ) {
// Output different headers if we're outputting textual errors.
if ( $errors ) {
header( 'Content-Type: text/plain; charset=utf-8' );
} else {
$context->getImageObj()->sendResponseHeaders( $context );
}
} elseif ( $context->getOnly() === 'styles' ) {
header( 'Content-Type: text/css; charset=utf-8' );
header( 'Access-Control-Allow-Origin: *' );
} else {
header( 'Content-Type: text/javascript; charset=utf-8' );
}
// See RFC 2616 § 14.19 ETag
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
header( 'ETag: ' . $etag );
if ( $context->getDebug() ) {
// Do not cache debug responses
header( 'Cache-Control: private, no-cache, must-revalidate' );
header( 'Pragma: no-cache' );
} else {
header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" );
header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) );
}
foreach ( $extra as $header ) {
header( $header );
}
}
/**
* Respond with HTTP 304 Not Modified if appropiate.
*
* If there's an If-None-Match header, respond with a 304 appropriately
* and clear out the output buffer. If the client cache is too old then do nothing.
*
* @param ResourceLoaderContext $context
* @param string $etag ETag header value
* @return bool True if HTTP 304 was sent and output handled
*/
protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
// See RFC 2616 § 14.26 If-None-Match
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
$clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
// Never send 304s in debug mode
if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
// There's another bug in ob_gzhandler (see also the comment at
// the top of this function) that causes it to gzip even empty
// responses, meaning it's impossible to produce a truly empty
// response (because the gzip header is always there). This is
// a problem because 304 responses have to be completely empty
// per the HTTP spec, and Firefox behaves buggily when they're not.
// See also https://bugs.php.net/bug.php?id=51579
// To work around this, we tear down all output buffering before
// sending the 304.
wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
HttpStatus::header( 304 );
$this->sendResponseHeaders( $context, $etag, false );
return true;
}
return false;
}
/**
* Send out code for a response from file cache if possible.
*
* @param ResourceFileCache $fileCache Cache object for this request URL
* @param ResourceLoaderContext $context Context in which to generate a response
* @param string $etag ETag header value
* @return bool If this found a cache file and handled the response
*/
protected function tryRespondFromFileCache(
ResourceFileCache $fileCache,
ResourceLoaderContext $context,
$etag
) {
$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
// Buffer output to catch warnings.
ob_start();
// Get the maximum age the cache can be
$maxage = $context->getVersion() === null
? $rlMaxage['unversioned']
: $rlMaxage['versioned'];
// Minimum timestamp the cache file must have
$minTime = time() - $maxage;
$good = $fileCache->isCacheGood( ConvertibleTimestamp::convert( TS_MW, $minTime ) );
if ( !$good ) {
try { // RL always hits the DB on file cache miss...
wfGetDB( DB_REPLICA );
} catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
$good = $fileCache->isCacheGood(); // cache existence check
}
}
if ( $good ) {
$ts = $fileCache->cacheTimestamp();
// Send content type and cache headers
$this->sendResponseHeaders( $context, $etag, false );
$response = $fileCache->fetchText();
// Capture any PHP warnings from the output buffer and append them to the
// response in a comment if we're in debug mode.
if ( $context->getDebug() ) {
$warnings = ob_get_contents();
if ( strlen( $warnings ) ) {
$response = self::makeComment( $warnings ) . $response;
}
}
// Remove the output buffer and output the response
ob_end_clean();
echo $response . "\n/* Cached {$ts} */";
return true; // cache hit
}
// Clear buffer
ob_end_clean();
return false; // cache miss
}
/**
* Generate a CSS or JS comment block.
*
* Only use this for public data, not error message details.
*
* @param string $text
* @return string
*/
public static function makeComment( $text ) {
$encText = str_replace( '*/', '* /', $text );
return "/*\n$encText\n*/\n";
}
/**
* Handle exception display.
*
* @param Throwable $e Exception to be shown to the user
* @return string Sanitized text in a CSS/JS comment that can be returned to the user
*/
public static function formatException( Throwable $e ) {
return self::makeComment( self::formatExceptionNoComment( $e ) );
}
/**
* Handle exception display.
*
* @since 1.25
* @param Throwable $e Exception to be shown to the user
* @return string Sanitized text that can be returned to the user
*/
protected static function formatExceptionNoComment( Throwable $e ) {
global $wgShowExceptionDetails;
if ( !$wgShowExceptionDetails ) {
return MWExceptionHandler::getPublicLogMessage( $e );
}
return MWExceptionHandler::getLogMessage( $e ) .
"\nBacktrace:\n" .
MWExceptionHandler::getRedactedTraceAsString( $e );
}
/**
* Generate code for a response.
*
* Calling this method also populates the `errors` and `headers` members,
* later used by respond().
*
* @param ResourceLoaderContext $context Context in which to generate a response
* @param ResourceLoaderModule[] $modules List of module objects keyed by module name
* @param string[] $missing List of requested module names that are unregistered (optional)
* @return string Response data
*/
public function makeModuleResponse( ResourceLoaderContext $context,
array $modules, array $missing = []
) {
$out = '';
$states = [];
if ( $modules === [] && $missing === [] ) {
return <<<MESSAGE
/* This file is the Web entry point for MediaWiki's ResourceLoader:
<https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
no modules were requested. Max made me put this here. */
MESSAGE;
}
$image = $context->getImageObj();
if ( $image ) {
$data = $image->getImageData( $context );
if ( $data === false ) {
$data = '';
$this->errors[] = 'Image generation failed';
}
return $data;
}
foreach ( $missing as $name ) {
$states[$name] = 'missing';
}
$filter = $context->getOnly() === 'styles' ? 'minify-css' : 'minify-js';
foreach ( $modules as $name => $module ) {
try {
$content = $module->getModuleContent( $context );
$implementKey = $name . '@' . $module->getVersionHash( $context );
$strContent = '';
if ( isset( $content['headers'] ) ) {
$this->extraHeaders = array_merge( $this->extraHeaders, $content['headers'] );
}
// Append output
switch ( $context->getOnly() ) {
case 'scripts':
$scripts = $content['scripts'];
if ( is_string( $scripts ) ) {
// Load scripts raw...
$strContent = $scripts;
} elseif ( is_array( $scripts ) ) {
// ...except when $scripts is an array of URLs or an associative array
$strContent = self::makeLoaderImplementScript(
$context,
$implementKey,
$scripts,
[],
[],
[]
);
}
break;
case 'styles':
$styles = $content['styles'];
// We no longer separate into media, they are all combined now with
// custom media type groups into @media .. {} sections as part of the css string.
// Module returns either an empty array or a numerical array with css strings.
$strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
break;
default:
$scripts = $content['scripts'] ?? '';
if ( is_string( $scripts ) ) {
if ( $name === 'site' || $name === 'user' ) {
// Legacy scripts that run in the global scope without a closure.
// mw.loader.implement will use globalEval if scripts is a string.
// Minify manually here, because general response minification is
// not effective due it being a string literal, not a function.
if ( !$context->getDebug() ) {
$scripts = self::filter( 'minify-js', $scripts ); // T107377
}
} else {
$scripts = new XmlJsCode( $scripts );
}
}
$strContent = self::makeLoaderImplementScript(
$context,
$implementKey,
$scripts,
$content['styles'] ?? [],
// @phan-suppress-next-line SecurityCheck-XSS
isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [],
$content['templates'] ?? []
);
break;
}
if ( !$context->getDebug() ) {
$strContent = self::filter( $filter, $strContent );
} else {
// In debug mode, separate each response by a new line.
// For example, between 'mw.loader.implement();' statements.
$strContent = self::ensureNewline( $strContent );
}
if ( $context->getOnly() === 'scripts' ) {
// Use a linebreak between module scripts (T162719)
$out .= self::ensureNewline( $strContent );
} else {
$out .= $strContent;
}
} catch ( Exception $e ) {
$this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' );
// Respond to client with error-state instead of module implementation
$states[$name] = 'error';
unset( $modules[$name] );
}
}
// Update module states
if ( $context->shouldIncludeScripts() && !$context->getRaw() ) {
if ( $modules && $context->getOnly() === 'scripts' ) {
// Set the state of modules loaded as only scripts to ready as
// they don't have an mw.loader.implement wrapper that sets the state
foreach ( $modules as $name => $module ) {
$states[$name] = 'ready';
}
}
// Set the state of modules we didn't respond to with mw.loader.implement
if ( $states ) {
$stateScript = self::makeLoaderStateScript( $context, $states );
if ( !$context->getDebug() ) {
$stateScript = self::filter( 'minify-js', $stateScript );
}
// Use a linebreak between module script and state script (T162719)
$out = self::ensureNewline( $out ) . $stateScript;
}
} elseif ( $states ) {
$this->errors[] = 'Problematic modules: '
. $context->encodeJson( $states );
}
return $out;
}
/**
* Ensure the string is either empty or ends in a line break
* @internal
* @param string $str
* @return string
*/
public static function ensureNewline( $str ) {
$end = substr( $str, -1 );
if ( $end === false || $end === '' || $end === "\n" ) {
return $str;
}
return $str . "\n";
}
/**
* Get names of modules that use a certain message.
*
* @param string $messageKey
* @return string[] List of module names
*/
public function getModulesByMessage( $messageKey ) {
$moduleNames = [];
foreach ( $this->getModuleNames() as $moduleName ) {
$module = $this->getModule( $moduleName );
if ( in_array( $messageKey, $module->getMessages() ) ) {
$moduleNames[] = $moduleName;
}
}
return $moduleNames;
}
/**
* 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
* 'files' and 'main' properties (see ResourceLoaderModule::getScript())
* @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs
* to CSS files keyed by media type
* @param mixed $messages List of messages associated with this module. May either be an
* associative array mapping message key to value, or a JSON-encoded message blob containing
* the same data, wrapped in an XmlJsCode object.
* @param array $templates Keys are name of templates and values are the source of
* the template.
* @return string JavaScript code
*/
private static function makeLoaderImplementScript(
ResourceLoaderContext $context, $name, $scripts, $styles, $messages, $templates
) {
if ( $scripts instanceof XmlJsCode ) {
if ( $scripts->value === '' ) {
$scripts = null;
} elseif ( $context->getDebug() ) {
// @phan-suppress-next-line SecurityCheck-XSS
$scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
} else {
// @phan-suppress-next-line SecurityCheck-XSS
$scripts = new XmlJsCode(
'function($,jQuery,require,module){' . self::ensureNewline( $scripts->value ) . '}'
);
}
} elseif ( is_array( $scripts ) && isset( $scripts['files'] ) ) {
$files = $scripts['files'];
foreach ( $files as $path => &$file ) {
// $file is changed (by reference) from a descriptor array to the content of the file
// All of these essentially do $file = $file['content'];, some just have wrapping around it
if ( $file['type'] === 'script' ) {
// Ensure that the script has a newline at the end to close any comment in the
// last line.
$content = self::ensureNewline( $file['content'] );
// Multi-file modules only get two parameters ($ and jQuery are being phased out)
if ( $context->getDebug() ) {
$file = new XmlJsCode( "function ( require, module ) {\n$content}" );
} else {
$file = new XmlJsCode( 'function(require,module){' . $content . '}' );
}
} else {
$file = $file['content'];
}
}
$scripts = XmlJsCode::encodeObject( [
'main' => $scripts['main'],
'files' => XmlJsCode::encodeObject( $files, $context->getDebug() )
], $context->getDebug() );
} elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
throw new InvalidArgumentException( 'Script must be a string or an array of URLs' );
}
// mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not
// arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead
// of "{}". Force them to objects.
$module = [
$name,
$scripts,
(object)$styles,
(object)$messages,
(object)$templates
];
self::trimArray( $module );
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 Associative array mapping message key to value.
* @return string JavaScript code
*/
public static function makeMessageSetScript( $messages ) {
return 'mw.messages.set('
. self::encodeJsonForScript( (object)$messages )
. ');';
}
/**
* Combines an associative array mapping media type to CSS into a
* single stylesheet with "@media" blocks.
*
* @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s)
* @return string[] CSS strings
*/
public static function makeCombinedStyles( array $stylePairs ) {
$out = [];
foreach ( $stylePairs as $media => $styles ) {
// ResourceLoaderFileModule::getStyle can return the styles
// as a string or an array of strings. This is to allow separation in
// the front-end.
$styles = (array)$styles;
foreach ( $styles as $style ) {
$style = trim( $style );
// Don't output an empty "@media print { }" block (T42498)
if ( $style !== '' ) {
// Transform the media type based on request params and config
// The way that this relies on $wgRequest to propagate request params is slightly evil
$media = OutputPage::transformCssMedia( $media );
if ( $media === '' || $media == 'all' ) {
$out[] = $style;
} elseif ( is_string( $media ) ) {
$out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
}
// else: skip
}
}
}
return $out;
}
/**
* Wrapper around json_encode that avoids needless escapes,
* and pretty-prints in debug mode.
*
* @internal For use within ResourceLoader classes only
* @since 1.32
* @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
// that PHP uses by default.
// However, while most module scripts are only served on HTTP responses
// for JavaScript, some modules can also be embedded in the HTML as inline
// scripts. This, and the fact that we sometimes need to export strings
// containing user-generated content and labels that may genuinely contain
// a sequences like "</script>", we need to encode either '/' or '<'.
// By default PHP escapes '/'. Let's escape '<' instead which is less common
// and allows URLs to mostly remain readable.
$jsonFlags = JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE |
JSON_HEX_TAG |
JSON_HEX_AMP;
if ( self::inDebugMode() ) {
$jsonFlags |= JSON_PRETTY_PRINT;
}
return json_encode( $data, $jsonFlags );
}
/**
* Returns a JS call to mw.loader.state, which sets the state of modules
* to a given value:
*
* - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ):
* Set the state of modules with the given names to the given states
*
* @internal For use by ResourceLoaderStartUpModule
* @param ResourceLoaderContext $context
* @param array<string,string> $states
* @return string JavaScript code
*/
public static function makeLoaderStateScript(
ResourceLoaderContext $context, array $states
) {
return 'mw.loader.state('
. $context->encodeJson( $states )
. ');';
}
private static function isEmptyObject( stdClass $obj ) {
foreach ( $obj as $key => $value ) {
return false;
}
return true;
}
/**
* Remove empty values from the end of an array.
*
* Values considered empty:
*
* - null
* - []
* - new XmlJsCode( '{}' )
* - new stdClass()
* - (object)[]
*
* @param array &$array
*/
private static function trimArray( array &$array ): void {
$i = count( $array );
while ( $i-- ) {
if ( $array[$i] === null
|| $array[$i] === []
|| ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' )
|| ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) )
) {
unset( $array[$i] );
} else {
break;
}
}
}
/**
* Format JS code which calls `mw.loader.register()` with the given parameters.
*
* @par Example
* @code
*
* ResourceLoader::makeLoaderRegisterScript( $context, [
* [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
* [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
* ...
* ] ):
* @endcode
*
* @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
* - array|null: List of dependencies (optional)
* - string|null: Module group (optional)
* - string|null: Name of foreign module source, or 'local' (optional)
* - string|null: Script body of a skip function (optional)
* @phan-param array<int,array{0:string,1:string,2?:?array,3?:?string,4?:?string,5?:?string}> $modules
* @return string JavaScript code
*/
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.
$index = [];
foreach ( $modules as $i => &$module ) {
// Build module name index
$index[$module[0]] = $i;
}
foreach ( $modules as &$module ) {
if ( isset( $module[2] ) ) {
foreach ( $module[2] as &$dependency ) {
if ( isset( $index[$dependency] ) ) {
// Replace module name in dependency list with index
$dependency = $index[$dependency];
}
}
}
}
array_walk( $modules, [ self::class, 'trimArray' ] );
return 'mw.loader.register('
. $context->encodeJson( $modules )
. ');';
}
/**
* Format JS code which calls `mw.loader.addSource()` with the given parameters.
*
* - 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 array<string,string> $sources
* @return string JavaScript code
*/
public static function makeLoaderSourcesScript(
ResourceLoaderContext $context, array $sources
) {
return 'mw.loader.addSource('
. $context->encodeJson( $sources )
. ');';
}
/**
* Wrap JavaScript code to run after the startup module.
*
* @param string $script JavaScript code
* @return string JavaScript code
*/
public static function makeLoaderConditionalScript( $script ) {
// Adds a function to lazy-created RLQ
return '(RLQ=window.RLQ||[]).push(function(){' .
trim( $script ) . '});';
}
/**
* Wrap JavaScript code to run after a required module.
*
* @since 1.32
* @param string|string[] $modules Module name(s)
* @param string $script JavaScript code
* @return string JavaScript code
*/
public static function makeInlineCodeWithModule( $modules, $script ) {
// Adds an array to lazy-created RLQ
return '(RLQ=window.RLQ||[]).push(['
. self::encodeJsonForScript( $modules ) . ','
. 'function(){' . trim( $script ) . '}'
. ']);';
}
/**
* Make an HTML script that runs given JS code after startup and base modules.
*
* The code will be wrapped in a closure, and it will be executed by ResourceLoader's
* startup module if the client has adequate support for MediaWiki JavaScript code.
*
* @param string $script JavaScript code
* @param string|null $nonce Content-Security-Policy nonce
* (from `OutputPage->getCSP()->getNonce()`)
* @return string|WrappedString HTML
*/
public static function makeInlineScript( $script, $nonce = null ) {
$js = self::makeLoaderConditionalScript( $script );
$escNonce = '';
if ( $nonce === null ) {
wfWarn( __METHOD__ . " did not get nonce. Will break CSP" );
} elseif ( $nonce !== false ) {
// If it was false, CSP is disabled, so no nonce attribute.
// Nonce should be only base64 characters, so should be safe,
// but better to be safely escaped than sorry.
$escNonce = ' nonce="' . htmlspecialchars( $nonce ) . '"';
}
return new WrappedString(
Html::inlineScript( $js, $nonce ),
"<script$escNonce>(RLQ=window.RLQ||[]).push(function(){",
'});</script>'
);
}
/**
* Return JS code which will set the MediaWiki configuration array to
* the given value.
*
* @param array $configuration List of configuration values keyed by variable name
* @return string JavaScript code
* @throws Exception
*/
public static function makeConfigSetScript( array $configuration ) {
$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 );
return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
}
return "mw.config.set($json);";
}
/**
* Convert an array of module names to a packed query string.
*
* For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]`
* becomes `'foo.bar,baz|bar.baz,quux'`.
*
* This process is reversed by ResourceLoader::expandModuleNames().
* See also mw.loader#buildModulesString() which is a port of this, used
* on the client-side.
*
* @param string[] $modules List of module names (strings)
* @return string Packed query string
*/
public static function makePackedModulesString( array $modules ) {
$moduleMap = []; // [ prefix => [ suffixes ] ]
foreach ( $modules as $module ) {
$pos = strrpos( $module, '.' );
$prefix = $pos === false ? '' : substr( $module, 0, $pos );
$suffix = $pos === false ? $module : substr( $module, $pos + 1 );
$moduleMap[$prefix][] = $suffix;
}
$arr = [];
foreach ( $moduleMap as $prefix => $suffixes ) {
$p = $prefix === '' ? '' : $prefix . '.';
$arr[] = $p . implode( ',', $suffixes );
}
return implode( '|', $arr );
}
/**
* Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to
* an array of module names like `[ 'jquery.foo', 'jquery.bar',
* 'jquery.ui.baz', 'jquery.ui.quux' ]`.
*
* This process is reversed by ResourceLoader::makePackedModulesString().
*
* @since 1.33
* @param string $modules Packed module name list
* @return string[] Array of module names
*/
public static function expandModuleNames( $modules ) {
$retval = [];
$exploded = explode( '|', $modules );
foreach ( $exploded as $group ) {
if ( strpos( $group, ',' ) === false ) {
// This is not a set of modules in foo.bar,baz notation
// but a single module
$retval[] = $group;
} else {
// This is a set of modules in foo.bar,baz notation
$pos = strrpos( $group, '.' );
if ( $pos === false ) {
// Prefixless modules, i.e. without dots
$retval = array_merge( $retval, explode( ',', $group ) );
} else {
// We have a prefix and a bunch of suffixes
$prefix = substr( $group, 0, $pos ); // 'foo'
$suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ]
foreach ( $suffixes as $suffix ) {
$retval[] = "$prefix.$suffix";
}
}
}
}
return $retval;
}
/**
* Determine whether debug mode is on.
*
* Order of priority is:
* - 1) Request parameter,
* - 2) Cookie,
* - 3) Site configuration.
*
* @return int
*/
public static function inDebugMode() {
if ( self::$debugMode === null ) {
global $wgRequest, $wgResourceLoaderDebug;
$str = $wgRequest->getRawVal( 'debug',
$wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ? 'true' : '' )
);
self::$debugMode = ResourceLoaderContext::debugFromString( $str );
}
return self::$debugMode;
}
/**
* Reset static members used for caching.
*
* Global state and $wgRequest are evil, but we're using it right
* now and sometimes we need to be able to force ResourceLoader to
* re-evaluate the context because it has changed (e.g. in the test suite).
*
* @internal For use by unit tests
* @codeCoverageIgnore
*/
public static function clearCache() {
self::$debugMode = null;
}
/**
* Build a load.php URL
*
* @since 1.24
* @param string $source Name of the ResourceLoader source
* @param ResourceLoaderContext $context
* @param array $extraQuery
* @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too.
*/
public function createLoaderURL( $source, ResourceLoaderContext $context,
array $extraQuery = []
) {
$query = self::createLoaderQuery( $context, $extraQuery );
$script = $this->getLoadScript( $source );
return wfAppendQuery( $script, $query );
}
/**
* Helper for createLoaderURL()
*
* @since 1.24
* @see makeLoaderQuery
* @param ResourceLoaderContext $context
* @param array $extraQuery
* @return array
*/
protected static function createLoaderQuery(
ResourceLoaderContext $context, array $extraQuery = []
) {
return self::makeLoaderQuery(
$context->getModules(),
$context->getLanguage(),
$context->getSkin(),
$context->getUser(),
$context->getVersion(),
$context->getDebug(),
$context->getOnly(),
$context->getRequest()->getBool( 'printable' ),
$context->getRequest()->getBool( 'handheld' ),
$extraQuery
);
}
/**
* Build a query array (array representation of query string) for load.php. Helper
* function for createLoaderURL().
*
* @param string[] $modules
* @param string $lang
* @param string $skin
* @param string|null $user
* @param string|null $version
* @param int $debug
* @param string|null $only
* @param bool $printable
* @param bool $handheld
* @param array $extraQuery
* @return array
*/
public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null,
$version = null, $debug = ResourceLoaderContext::DEBUG_OFF, $only = null,
$printable = false, $handheld = false, array $extraQuery = []
) {
$query = [
'modules' => self::makePackedModulesString( $modules ),
];
// Keep urls short by omitting query parameters that
// match the defaults assumed by ResourceLoaderContext.
// Note: This relies on the defaults either being insignificant or forever constant,
// as otherwise cached urls could change in meaning when the defaults change.
if ( $lang !== ResourceLoaderContext::DEFAULT_LANG ) {
$query['lang'] = $lang;
}
if ( $skin !== ResourceLoaderContext::DEFAULT_SKIN ) {
$query['skin'] = $skin;
}
if ( $debug !== ResourceLoaderContext::DEBUG_OFF ) {
$query['debug'] = strval( $debug );
}
if ( $user !== null ) {
$query['user'] = $user;
}
if ( $version !== null ) {
$query['version'] = $version;
}
if ( $only !== null ) {
$query['only'] = $only;
}
if ( $printable ) {
$query['printable'] = 1;
}
if ( $handheld ) {
$query['handheld'] = 1;
}
$query += $extraQuery;
// Make queries uniform in order
ksort( $query );
return $query;
}
/**
* Check a module name for validity.
*
* Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be
* at most 255 bytes.
*
* @param string $moduleName Module name to check
* @return bool Whether $moduleName is a valid module name
*/
public static function isValidModuleName( $moduleName ) {
$len = strlen( $moduleName );
return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len;
}
/**
* Return a LESS compiler that is set up for use with MediaWiki.
*
* @since 1.27
* @param array $vars Associative array of variables that should be used
* for compilation. Since 1.32, this method no longer automatically includes
* global LESS vars from ResourceLoader::getLessVars (T191937).
* @param array $importDirs Additional directories to look in for @import (since 1.36)
* @throws MWException
* @return Less_Parser
*/
public function getLessCompiler( array $vars = [], array $importDirs = [] ) {
global $IP;
// When called from the installer, it is possible that a required PHP extension
// is missing (at least for now; see T49564). If this is the case, throw an
// exception (caught by the installer) to prevent a fatal error later on.
if ( !class_exists( Less_Parser::class ) ) {
throw new MWException( 'MediaWiki requires the less.php parser' );
}
$importDirs[] = "$IP/resources/src/mediawiki.less";
$parser = new Less_Parser;
$parser->ModifyVars( $vars );
// SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ]
$parser->SetImportDirs( array_fill_keys( $importDirs, '' ) );
$parser->SetOption( 'relativeUrls', false );
return $parser;
}
/**
* Resolve a possibly relative URL against a base URL.
*
* The base URL must have a server and should have a protocol.
* A protocol-relative base expands to HTTPS.
*
* This is a standalone version of MediaWiki's wfExpandUrl (T32956).
*
* @internal For use by core ResourceLoader classes only
* @param string $base
* @param string $url
* @return string URL
*/
public function expandUrl( string $base, string $url ): string {
// Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do.
$isProtoRelative = strpos( $base, '//' ) === 0;
if ( $isProtoRelative ) {
$base = "https:$base";
}
// Net_URL2::resolve() takes care of throwing if $base doesn't have a server.
$baseUrl = new Net_URL2( $base );
$ret = $baseUrl->resolve( $url );
if ( $isProtoRelative ) {
$ret->setScheme( false );
}
return $ret->getURL();
}
/**
* Get site configuration settings to expose to JavaScript on all pages via `mw.config`.
*
* @internal Exposed for use from Resources.php
* @param ResourceLoaderContext $context
* @param Config $conf
* @return array
*/
public static function getSiteConfigSettings(
ResourceLoaderContext $context, Config $conf
): array {
// Namespace related preparation
// - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces.
// - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive.
$contLang = MediaWikiServices::getInstance()->getContentLanguage();
$namespaceIds = $contLang->getNamespaceIds();
$caseSensitiveNamespaces = [];
$nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) {
$namespaceIds[$contLang->lc( $name )] = $index;
if ( !$nsInfo->isCapitalized( $index ) ) {
$caseSensitiveNamespaces[] = $index;
}
}
$illegalFileChars = $conf->get( 'IllegalFileChars' );
// Build list of variables
$skin = $context->getSkin();
// Start of supported and stable config vars (for use by extensions/gadgets).
$vars = [
'debug' => $context->getDebug(),
'skin' => $skin,
'stylepath' => $conf->get( 'StylePath' ),
'wgArticlePath' => $conf->get( 'ArticlePath' ),
'wgScriptPath' => $conf->get( 'ScriptPath' ),
'wgScript' => $conf->get( 'Script' ),
'wgSearchType' => $conf->get( 'SearchType' ),
'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ),
'wgServer' => $conf->get( 'Server' ),
'wgServerName' => $conf->get( 'ServerName' ),
'wgUserLanguage' => $context->getLanguage(),
'wgContentLanguage' => $contLang->getCode(),
'wgVersion' => MW_VERSION,
'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(),
'wgNamespaceIds' => $namespaceIds,
'wgContentNamespaces' => $nsInfo->getContentNamespaces(),
'wgSiteName' => $conf->get( 'Sitename' ),
'wgDBname' => $conf->get( 'DBname' ),
'wgWikiID' => WikiMap::getCurrentWikiId(),
'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT,
'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ),
];
// End of stable config vars.
// Internal variables for use by MediaWiki core and/or ResourceLoader.
$vars += [
// @internal For mediawiki.widgets
'wgUrlProtocols' => wfUrlProtocols(),
// @internal For mediawiki.page.watch
// Force object to avoid "empty" associative array from
// becoming [] instead of {} in JS (T36604)
'wgActionPaths' => (object)$conf->get( 'ActionPaths' ),
// @internal For mediawiki.language
'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ),
// @internal For mediawiki.Title
'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ),
// @internal For mediawiki.cookie
'wgCookiePrefix' => $conf->get( 'CookiePrefix' ),
'wgCookieDomain' => $conf->get( 'CookieDomain' ),
'wgCookiePath' => $conf->get( 'CookiePath' ),
'wgCookieExpiration' => $conf->get( 'CookieExpiration' ),
// @internal For mediawiki.Title
'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ),
// @internal For mediawiki.ForeignUpload
'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ),
'wgEnableUploads' => $conf->get( 'EnableUploads' ),
];
Hooks::runner()->onResourceLoaderGetConfigVars( $vars, $skin, $conf );
return $vars;
}
}