diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/resourceloader |
first commit
Diffstat (limited to 'www/wiki/includes/resourceloader')
31 files changed, 8352 insertions, 0 deletions
diff --git a/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php b/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php new file mode 100644 index 00000000..418d17f3 --- /dev/null +++ b/www/wiki/includes/resourceloader/DerivativeResourceLoaderContext.php @@ -0,0 +1,199 @@ +<?php +/** + * Derivative context for ResourceLoader modules. + * + * 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 Kunal Mehta + */ + +/** + * Allows changing specific properties of a context object, + * without changing the main one. Inspired by DerivativeContext. + * + * @since 1.24 + */ +class DerivativeResourceLoaderContext extends ResourceLoaderContext { + const INHERIT_VALUE = -1; + + /** + * @var ResourceLoaderContext + */ + private $context; + + protected $modules = self::INHERIT_VALUE; + protected $language = self::INHERIT_VALUE; + protected $direction = self::INHERIT_VALUE; + protected $skin = self::INHERIT_VALUE; + protected $user = self::INHERIT_VALUE; + protected $debug = self::INHERIT_VALUE; + protected $only = self::INHERIT_VALUE; + protected $version = self::INHERIT_VALUE; + protected $raw = self::INHERIT_VALUE; + + public function __construct( ResourceLoaderContext $context ) { + $this->context = $context; + } + + public function getModules() { + if ( $this->modules === self::INHERIT_VALUE ) { + return $this->context->getModules(); + } + return $this->modules; + } + + /** + * @param string[] $modules + */ + public function setModules( array $modules ) { + $this->modules = $modules; + } + + public function getLanguage() { + if ( $this->language === self::INHERIT_VALUE ) { + return $this->context->getLanguage(); + } + return $this->language; + } + + /** + * @param string $language + */ + public function setLanguage( $language ) { + $this->language = $language; + // Invalidate direction since it is based on language + $this->direction = null; + $this->hash = null; + } + + public function getDirection() { + if ( $this->direction === self::INHERIT_VALUE ) { + return $this->context->getDirection(); + } + if ( $this->direction === null ) { + $this->direction = Language::factory( $this->getLanguage() )->getDir(); + } + return $this->direction; + } + + /** + * @param string $direction + */ + public function setDirection( $direction ) { + $this->direction = $direction; + $this->hash = null; + } + + public function getSkin() { + if ( $this->skin === self::INHERIT_VALUE ) { + return $this->context->getSkin(); + } + return $this->skin; + } + + /** + * @param string $skin + */ + public function setSkin( $skin ) { + $this->skin = $skin; + $this->hash = null; + } + + public function getUser() { + if ( $this->user === self::INHERIT_VALUE ) { + return $this->context->getUser(); + } + return $this->user; + } + + /** + * @param string|null $user + */ + public function setUser( $user ) { + $this->user = $user; + $this->hash = null; + $this->userObj = null; + } + + public function getDebug() { + if ( $this->debug === self::INHERIT_VALUE ) { + return $this->context->getDebug(); + } + return $this->debug; + } + + /** + * @param bool $debug + */ + public function setDebug( $debug ) { + $this->debug = $debug; + $this->hash = null; + } + + public function getOnly() { + if ( $this->only === self::INHERIT_VALUE ) { + return $this->context->getOnly(); + } + return $this->only; + } + + /** + * @param string|null $only + */ + public function setOnly( $only ) { + $this->only = $only; + $this->hash = null; + } + + public function getVersion() { + if ( $this->version === self::INHERIT_VALUE ) { + return $this->context->getVersion(); + } + return $this->version; + } + + /** + * @param string|null $version + */ + public function setVersion( $version ) { + $this->version = $version; + $this->hash = null; + } + + public function getRaw() { + if ( $this->raw === self::INHERIT_VALUE ) { + return $this->context->getRaw(); + } + return $this->raw; + } + + /** + * @param bool $raw + */ + public function setRaw( $raw ) { + $this->raw = $raw; + } + + public function getRequest() { + return $this->context->getRequest(); + } + + public function getResourceLoader() { + return $this->context->getResourceLoader(); + } + +} diff --git a/www/wiki/includes/resourceloader/ResourceLoader.php b/www/wiki/includes/resourceloader/ResourceLoader.php new file mode 100644 index 00000000..90c31406 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoader.php @@ -0,0 +1,1731 @@ +<?php +/** + * Base class for resource loading system. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (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\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Wikimedia\Rdbms\DBConnectionError; +use Wikimedia\WrappedString; + +/** + * Dynamic JavaScript and CSS resource loading system. + * + * Most of the documentation is on the MediaWiki documentation wiki starting at: + * https://www.mediawiki.org/wiki/ResourceLoader + */ +class ResourceLoader implements LoggerAwareInterface { + /** @var int */ + protected static $filterCacheVersion = 7; + + /** @var bool */ + protected static $debugMode = null; + + /** @var array */ + private $lessVars = null; + + /** + * Module name/ResourceLoaderModule object pairs + * @var array + */ + protected $modules = []; + + /** + * Associative array mapping module name to info associative array + * @var array + */ + protected $moduleInfos = []; + + /** @var Config $config */ + protected $config; + + /** + * Associative array mapping framework ids to a list of names of test suite modules + * like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ] + * @var array + */ + protected $testModuleNames = []; + + /** + * E.g. [ 'source-id' => 'http://.../load.php' ] + * @var array + */ + protected $sources = []; + + /** + * Errors accumulated during current respond() call. + * @var array + */ + protected $errors = []; + + /** + * List of extra HTTP response headers provided by loaded modules. + * + * Populated by makeModuleResponse(). + * + * @var array + */ + protected $extraHeaders = []; + + /** + * @var MessageBlobStore + */ + protected $blobStore; + + /** + * @var LoggerInterface + */ + private $logger; + + /** @var string JavaScript / CSS pragma to disable minification. **/ + const FILTER_NOMIN = '/*@nomin*/'; + + /** + * Load information stored in the database about modules. + * + * This method grabs modules dependencies from the database and updates modules + * objects. + * + * This is not inside the module code because it is much faster to + * request all of the information at once than it is to have each module + * requests its own information. This sacrifice of modularity yields a substantial + * performance improvement. + * + * @param array $moduleNames List of module names to preload information for + * @param ResourceLoaderContext $context Context to load the information within + */ + public function preloadModuleInfo( array $moduleNames, ResourceLoaderContext $context ) { + if ( !$moduleNames ) { + // Or else Database*::select() will explode, plus it's cheaper! + return; + } + $dbr = wfGetDB( DB_REPLICA ); + $skin = $context->getSkin(); + $lang = $context->getLanguage(); + + // Batched version of ResourceLoaderModule::getFileDependencies + $vary = "$skin|$lang"; + $res = $dbr->select( 'module_deps', [ 'md_module', 'md_deps' ], [ + 'md_module' => $moduleNames, + 'md_skin' => $vary, + ], __METHOD__ + ); + + // Prime in-object cache for file dependencies + $modulesWithDeps = []; + foreach ( $res as $row ) { + $module = $this->getModule( $row->md_module ); + if ( $module ) { + $module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths( + FormatJson::decode( $row->md_deps, true ) + ) ); + $modulesWithDeps[] = $row->md_module; + } + } + // Register the absence of a dependency row too + foreach ( array_diff( $moduleNames, $modulesWithDeps ) as $name ) { + $module = $this->getModule( $name ); + if ( $module ) { + $this->getModule( $name )->setFileDependencies( $context, [] ); + } + } + + // Batched version of ResourceLoaderWikiModule::getTitleInfo + ResourceLoaderWikiModule::preloadTitleInfo( $context, $dbr, $moduleNames ); + + // Prime in-object cache for message blobs for modules with messages + $modules = []; + foreach ( $moduleNames as $name ) { + $module = $this->getModule( $name ); + if ( $module && $module->getMessages() ) { + $modules[$name] = $module; + } + } + $store = $this->getMessageBlobStore(); + $blobs = $store->getBlobs( $modules, $lang ); + foreach ( $blobs as $name => $blob ) { + $modules[$name]->setMessageBlob( $blob, $lang ); + } + } + + /** + * Run JavaScript or CSS data through a filter, caching the filtered result for future calls. + * + * Available filters are: + * + * - minify-js \see JavaScriptMinifier::minify + * - minify-css \see CSSMin::minify + * + * 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 $options Keys: + * - (bool) cache: Whether to allow caching this data. Default: true. + * @return string Filtered data, or a comment containing an error message + */ + 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 ); + } + + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); + + $key = $cache->makeGlobalKey( + 'resourceloader', + 'filter', + $filter, + self::$filterCacheVersion, 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; + } + + 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 [optional] + * @param LoggerInterface $logger [optional] + */ + public function __construct( Config $config = null, LoggerInterface $logger = null ) { + global $IP; + + $this->logger = $logger ?: new NullLogger(); + + if ( !$config ) { + $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' ); + $config = MediaWikiServices::getInstance()->getMainConfig(); + } + $this->config = $config; + + // Add 'local' source first + $this->addSource( 'local', $config->get( 'LoadScript' ) ); + + // Add other sources + $this->addSource( $config->get( 'ResourceLoaderSources' ) ); + + // Register core modules + $this->register( include "$IP/resources/Resources.php" ); + // Register extension modules + $this->register( $config->get( 'ResourceModules' ) ); + + // Avoid PHP 7.1 warning from passing $this by reference + $rl = $this; + Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] ); + + if ( $config->get( 'EnableJavaScriptTest' ) === true ) { + $this->registerTestModules(); + } + + $this->setMessageBlobStore( new MessageBlobStore( $this, $this->logger ) ); + } + + /** + * @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; + } + + /** + * Register a module with the ResourceLoader system. + * + * @param mixed $name Name of module as a string or List of name/object pairs as an array + * @param array $info Module info array. For backwards compatibility with 1.17alpha, + * this may also be a ResourceLoaderModule object. Optional when using + * multiple-registration calling style. + * @throws MWException If a duplicate module registration is attempted + * @throws MWException If a module name contains illegal characters (pipes or commas) + * @throws MWException If something other than a ResourceLoaderModule is being registered + * @return bool False if there were any errors, in which case one or more modules were + * not registered + */ + public function register( $name, $info = null ) { + $moduleSkinStyles = $this->config->get( 'ResourceModuleSkinStyles' ); + + // 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 $name for validity + if ( !self::isValidModuleName( $name ) ) { + throw new MWException( "ResourceLoader module name '$name' is invalid, " + . "see ResourceLoader::isValidModuleName()" ); + } + + // Attach module + if ( $info instanceof ResourceLoaderModule ) { + $this->moduleInfos[$name] = [ 'object' => $info ]; + $info->setName( $name ); + $this->modules[$name] = $info; + } elseif ( is_array( $info ) ) { + // New calling convention + $this->moduleInfos[$name] = $info; + } else { + throw new MWException( + 'ResourceLoader module info type error for module \'' . $name . + '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')' + ); + } + + // Last-minute changes + + // Apply custom skin-defined styles to existing modules. + if ( $this->isFileModule( $name ) ) { + foreach ( $moduleSkinStyles as $skinName => $skinStyles ) { + // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles. + if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) { + continue; + } + + // If $name is preceded with a '+', the defined style files will be added to 'default' + // skinStyles, otherwise 'default' will be ignored as it normally would be. + if ( isset( $skinStyles[$name] ) ) { + $paths = (array)$skinStyles[$name]; + $styleFiles = []; + } elseif ( isset( $skinStyles['+' . $name] ) ) { + $paths = (array)$skinStyles['+' . $name]; + $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ? + (array)$this->moduleInfos[$name]['skinStyles']['default'] : + []; + } else { + continue; + } + + // Add new file paths, remapping them to refer to our directories and not use settings + // from the module we're modifying, which come from the base definition. + list( $localBasePath, $remoteBasePath ) = + ResourceLoaderFileModule::extractBasePaths( $skinStyles ); + + foreach ( $paths as $path ) { + $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath ); + } + + $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles; + } + } + } + } + + public function registerTestModules() { + 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.' ); + } + + // Get core test suites + $testModules = []; + $testModules['qunit'] = []; + // Get other test suites (e.g. from extensions) + // Avoid PHP 7.1 warning from passing $this by reference + $rl = $this; + Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] ); + + // Add the testrunner (which configures QUnit) to the dependencies. + // Since it must be ready before any of the test suites are executed. + foreach ( $testModules['qunit'] as &$module ) { + // Make sure all test modules are top-loading so that when QUnit starts + // on document-ready, it will run once and finish. If some tests arrive + // later (possibly after QUnit has already finished) they will be ignored. + $module['position'] = 'top'; + $module['dependencies'][] = 'test.mediawiki.qunit.testrunner'; + } + + $testModules['qunit'] = + ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit']; + + foreach ( $testModules as $id => $names ) { + // Register test modules + $this->register( $testModules[$id] ); + + // Keep track of their names so that they can be loaded together + $this->testModuleNames[$id] = array_keys( $testModules[$id] ); + } + } + + /** + * 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 $id Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ] + * @param string|array $loadUrl load.php url (string), or array with loadUrl key for + * backwards-compatibility. + * @throws MWException + */ + public function addSource( $id, $loadUrl = null ) { + // Allow multiple sources to be registered in one call + if ( is_array( $id ) ) { + foreach ( $id as $key => $value ) { + $this->addSource( $key, $value ); + } + return; + } + + // Disallow duplicates + if ( isset( $this->sources[$id] ) ) { + throw new MWException( + 'ResourceLoader duplicate source addition error. ' . + 'Another source has already been registered as ' . $id + ); + } + + // Pre 1.24 backwards-compatibility + if ( is_array( $loadUrl ) ) { + if ( !isset( $loadUrl['loadScript'] ) ) { + throw new MWException( + __METHOD__ . ' was passed an array with no "loadScript" key.' + ); + } + + $loadUrl = $loadUrl['loadScript']; + } + + $this->sources[$id] = $loadUrl; + } + + /** + * Get a list of module names. + * + * @return array List of module names + */ + public function getModuleNames() { + return array_keys( $this->moduleInfos ); + } + + /** + * Get a list of test module names for one (or all) frameworks. + * + * If the given framework id is unknkown, or if the in-object variable is not an array, + * then it will return an empty array. + * + * @param string $framework Get only the test module names for one + * particular framework (optional) + * @return array + */ + public function getTestModuleNames( $framework = 'all' ) { + /** @todo api siteinfo prop testmodulenames modulenames */ + if ( $framework == 'all' ) { + return $this->testModuleNames; + } elseif ( isset( $this->testModuleNames[$framework] ) + && is_array( $this->testModuleNames[$framework] ) + ) { + return $this->testModuleNames[$framework]; + } else { + return []; + } + } + + /** + * 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 object + $info = $this->moduleInfos[$name]; + /** @var ResourceLoaderModule $object */ + if ( isset( $info['object'] ) ) { + // Object given in info array + $object = $info['object']; + } elseif ( isset( $info['factory'] ) ) { + $object = call_user_func( $info['factory'], $info ); + $object->setConfig( $this->getConfig() ); + $object->setLogger( $this->logger ); + } else { + if ( !isset( $info['class'] ) ) { + $class = ResourceLoaderFileModule::class; + } else { + $class = $info['class']; + } + /** @var ResourceLoaderModule $object */ + $object = new $class( $info ); + $object->setConfig( $this->getConfig() ); + $object->setLogger( $this->logger ); + } + $object->setName( $name ); + $this->modules[$name] = $object; + } + + return $this->modules[$name]; + } + + /** + * Return whether the definition of a module corresponds to a simple ResourceLoaderFileModule + * or one of its subclasses. + * + * @param string $name Module name + * @return bool + */ + protected function isFileModule( $name ) { + if ( !isset( $this->moduleInfos[$name] ) ) { + return false; + } + $info = $this->moduleInfos[$name]; + if ( isset( $info['object'] ) ) { + return false; + } + if ( + isset( $info['class'] ) && + $info['class'] !== ResourceLoaderFileModule::class && + !is_subclass_of( $info['class'], ResourceLoaderFileModule::class ) + ) { + return false; + } + return true; + } + + /** + * 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 + * @throws MWException On an invalid $source name + * @return string + */ + public function getLoadScript( $source ) { + if ( !isset( $this->sources[$source] ) ) { + throw new MWException( "The $source source was never registered in ResourceLoader." ); + } + return $this->sources[$source]; + } + + /** + * @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 ); + } + + /** + * Add an error to the 'errors' array and log it. + * + * Should only be called from within respond(). + * + * @since 1.29 + * @param Exception $e + * @param string $msg + * @param array $context + */ + protected 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, do still consider the versions + // of other modules - don't set an empty string E-Tag for the whole request. + // See also T152266 and StartupModule::getModuleRegistrations(). + $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 + * @return string Hash + */ + public function makeVersionQuery( ResourceLoaderContext $context ) { + // 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 ) { + 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; + } + return $this->getCombinedVersion( $context, $moduleNames ); + } + + /** + * 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' ) { + $this->logger->debug( "Request for private module '$name' denied" ); + $this->errors[] = "Cannot show 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 + } + } + + // 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; + } + } + + // Save response to file cache unless there are errors + if ( isset( $fileCache ) && !$this->errors && !count( $missing ) ) { + // Cache single modules and images...and other requests if there are enough hits + if ( ResourceFileCache::useFileCache( $context ) ) { + if ( $fileCache->isCacheWorthy() ) { + $fileCache->saveText( $response ); + } else { + $fileCache->incrMissesRecent( $context->getRequest() ); + } + } + } + + $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) {' + . Xml::encodeJsCall( 'console.error', [ $errorText ] ) + . "}\n"; + } + + // Prepend error info to the response + $response = $errorResponse . $response; + } + + $this->errors = []; + echo $response; + } + + protected function measureResponseTime( Timing $timing ) { + DeferredUpdates::addCallableUpdate( 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 + * @return void + */ + protected function sendResponseHeaders( + ResourceLoaderContext $context, $etag, $errors, array $extra = [] + ) { + \MediaWiki\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 ( is_null( $context->getVersion() ) + || $errors + || $context->getVersion() !== $this->makeVersionQuery( $context ) + ) { + $maxage = $rlMaxage['unversioned']['client']; + $smaxage = $rlMaxage['unversioned']['server']; + // If a version was specified we can use a longer expiry time since changing + // version numbers causes cache misses + } else { + $maxage = $rlMaxage['versioned']['client']; + $smaxage = $rlMaxage['versioned']['server']; + } + 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=$smaxage" ); + $exp = min( $maxage, $smaxage ); + header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) ); + } + 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 = is_null( $context->getVersion() ) + ? $rlMaxage['unversioned']['server'] + : $rlMaxage['versioned']['server']; + // Minimum timestamp the cache file must have + $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) ); + 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 Exception $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( $e ) { + return self::makeComment( self::formatExceptionNoComment( $e ) ); + } + + /** + * Handle exception display. + * + * @since 1.25 + * @param Exception $e Exception to be shown to the user + * @return string Sanitized text that can be returned to the user + */ + protected static function formatExceptionNoComment( $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 ( !count( $modules ) && !count( $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'; + } + + // Generate output + $isRaw = false; + + $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 + $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] ); + } + break; + case 'styles': + $styles = $content['styles']; + // We no longer seperate 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 = isset( $content['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 ( !self::inDebugMode() ) { + $scripts = self::filter( 'minify-js', $scripts ); // T107377 + } + } else { + $scripts = new XmlJsCode( $scripts ); + } + } + $strContent = self::makeLoaderImplementScript( + $implementKey, + $scripts, + isset( $content['styles'] ) ? $content['styles'] : [], + isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [], + isset( $content['templates'] ) ? $content['templates'] : [] + ); + break; + } + + if ( !$context->getDebug() ) { + $strContent = self::filter( $filter, $strContent ); + } + + if ( $context->getOnly() === 'scripts' ) { + // Use a linebreak between module scripts (T162719) + $out .= $this->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] ); + } + $isRaw |= $module->isRaw(); + } + + // Update module states + if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) { + if ( count( $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 ( count( $states ) ) { + $stateScript = self::makeLoaderStateScript( $states ); + if ( !$context->getDebug() ) { + $stateScript = self::filter( 'minify-js', $stateScript ); + } + // Use a linebreak between module script and state script (T162719) + $out = $this->ensureNewline( $out ) . $stateScript; + } + } else { + if ( count( $states ) ) { + $this->errors[] = 'Problematic modules: ' . + FormatJson::encode( $states, self::inDebugMode() ); + } + } + + return $out; + } + + /** + * Ensure the string is either empty or ends in a line break + * @param string $str + * @return string + */ + private function ensureNewline( $str ) { + $end = substr( $str, -1 ); + if ( $end === false || $end === "\n" ) { + return $str; + } + return $str . "\n"; + } + + /** + * Get names of modules that use a certain message. + * + * @param string $messageKey + * @return array 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 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, or a string of JavaScript for `$.globalEval`. + * @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. + * @throws MWException + * @return string JavaScript code + */ + protected static function makeLoaderImplementScript( + $name, $scripts, $styles, $messages, $templates + ) { + if ( $scripts instanceof XmlJsCode ) { + if ( self::inDebugMode() ) { + $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" ); + } else { + $scripts = new XmlJsCode( 'function($,jQuery,require,module){'. $scripts->value . '}' ); + } + } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) { + throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' ); + } + // 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, self::inDebugMode() ); + } + + /** + * 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. + * @return string JavaScript code + */ + public static function makeMessageSetScript( $messages ) { + return Xml::encodeJsCall( + 'mw.messages.set', + [ (object)$messages ], + self::inDebugMode() + ); + } + + /** + * Combines an associative array mapping media type to CSS into a + * single stylesheet with "@media" blocks. + * + * @param array $stylePairs Array keyed by media type containing (arrays of) CSS strings + * @return array + */ + 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; + } + + /** + * Returns a JS call to mw.loader.state, which sets the state of a + * module or modules to a given value. Has two calling conventions: + * + * - ResourceLoader::makeLoaderStateScript( $name, $state ): + * Set the state of a single module called $name to $state + * + * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ): + * Set the state of modules with the given names to the given states + * + * @param string $name + * @param string $state + * @return string JavaScript code + */ + public static function makeLoaderStateScript( $name, $state = null ) { + if ( is_array( $name ) ) { + return Xml::encodeJsCall( + 'mw.loader.state', + [ $name ], + self::inDebugMode() + ); + } else { + return Xml::encodeJsCall( + 'mw.loader.state', + [ $name, $state ], + self::inDebugMode() + ); + } + } + + /** + * Returns JS code which calls the script given by $script. The script will + * be called with local variables name, version, dependencies and group, + * which will have values corresponding to $name, $version, $dependencies + * and $group as supplied. + * + * @param string $name Module name + * @param string $version Module version hash + * @param array $dependencies List of module names on which this module depends + * @param string $group Group which the module is in. + * @param string $source Source of the module, or 'local' if not foreign. + * @param string $script JavaScript code + * @return string JavaScript code + */ + public static function makeCustomLoaderScript( $name, $version, $dependencies, + $group, $source, $script + ) { + $script = str_replace( "\n", "\n\t", trim( $script ) ); + return Xml::encodeJsCall( + "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )", + [ $name, $version, $dependencies, $group, $source ], + self::inDebugMode() + ); + } + + 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 ) { + $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; + } + } + } + + /** + * Returns JS code which calls mw.loader.register with the given + * parameters. Has three calling conventions: + * + * - ResourceLoader::makeLoaderRegisterScript( $name, $version, + * $dependencies, $group, $source, $skip + * ): + * Register a single module. + * + * - ResourceLoader::makeLoaderRegisterScript( [ $name1, $name2 ] ): + * Register modules with the given names. + * + * - ResourceLoader::makeLoaderRegisterScript( [ + * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ], + * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ], + * ... + * ] ): + * Registers modules with the given names and parameters. + * + * @param string $name Module name + * @param string $version Module version hash + * @param array $dependencies List of module names on which this module depends + * @param string $group Group which the module is in + * @param string $source Source of the module, or 'local' if not foreign + * @param string $skip Script body of the skip function + * @return string JavaScript code + */ + public static function makeLoaderRegisterScript( $name, $version = null, + $dependencies = null, $group = null, $source = null, $skip = null + ) { + if ( is_array( $name ) ) { + // Build module name index + $index = []; + foreach ( $name as $i => &$module ) { + $index[$module[0]] = $i; + } + + // Transform dependency names into indexes when possible, they will be resolved by + // mw.loader.register on the other end + foreach ( $name as &$module ) { + if ( isset( $module[2] ) ) { + foreach ( $module[2] as &$dependency ) { + if ( isset( $index[$dependency] ) ) { + $dependency = $index[$dependency]; + } + } + } + } + + array_walk( $name, [ 'self', 'trimArray' ] ); + + return Xml::encodeJsCall( + 'mw.loader.register', + [ $name ], + self::inDebugMode() + ); + } else { + $registration = [ $name, $version, $dependencies, $group, $source, $skip ]; + self::trimArray( $registration ); + return Xml::encodeJsCall( + 'mw.loader.register', + $registration, + self::inDebugMode() + ); + } + } + + /** + * Returns JS code which calls mw.loader.addSource() with the given + * parameters. Has two calling conventions: + * + * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ): + * Register a single source + * + * - ResourceLoader::makeLoaderSourcesScript( [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] ); + * Register sources with the given IDs and properties. + * + * @param string $id Source ID + * @param string $loadUrl load.php url + * @return string JavaScript code + */ + public static function makeLoaderSourcesScript( $id, $loadUrl = null ) { + if ( is_array( $id ) ) { + return Xml::encodeJsCall( + 'mw.loader.addSource', + [ $id ], + self::inDebugMode() + ); + } else { + return Xml::encodeJsCall( + 'mw.loader.addSource', + [ $id, $loadUrl ], + self::inDebugMode() + ); + } + } + + /** + * Wraps JavaScript code to run after startup and base modules. + * + * @param string $script JavaScript code + * @return string JavaScript code + */ + public static function makeLoaderConditionalScript( $script ) { + return '(window.RLQ=window.RLQ||[]).push(function(){' . + trim( $script ) . '});'; + } + + /** + * Returns an HTML script tag 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 + * @return WrappedString HTML + */ + public static function makeInlineScript( $script ) { + $js = self::makeLoaderConditionalScript( $script ); + return new WrappedString( + Html::inlineScript( $js ), + '<script>(window.RLQ=window.RLQ||[]).push(function(){', + '});</script>' + ); + } + + /** + * Returns 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 + */ + public static function makeConfigSetScript( array $configuration ) { + return Xml::encodeJsCall( + 'mw.config.set', + [ $configuration ], + self::inDebugMode() + ); + } + + /** + * 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 ResourceLoaderContext::expandModuleNames(). + * See also mw.loader#buildModulesString() which is a port of this, used + * on the client-side. + * + * @param array $modules List of module names (strings) + * @return string Packed query string + */ + public static function makePackedModulesString( $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 ); + } + + /** + * Determine whether debug mode was requested + * Order of priority is 1) request param, 2) cookie, 3) $wg setting + * @return bool + */ + public static function inDebugMode() { + if ( self::$debugMode === null ) { + global $wgRequest, $wgResourceLoaderDebug; + self::$debugMode = $wgRequest->getFuzzyBool( 'debug', + $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ) + ); + } + 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). + */ + 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, + $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, $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 array $modules + * @param string $lang + * @param string $skin + * @param string $user + * @param string $version + * @param bool $debug + * @param string $only + * @param bool $printable + * @param bool $handheld + * @param array $extraQuery + * + * @return array + */ + public static function makeLoaderQuery( $modules, $lang, $skin, $user = null, + $version = null, $debug = false, $only = null, $printable = false, + $handheld = false, $extraQuery = [] + ) { + $query = [ + 'modules' => self::makePackedModulesString( $modules ), + 'lang' => $lang, + 'skin' => $skin, + 'debug' => $debug ? 'true' : 'false', + ]; + 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 ) { + return strcspn( $moduleName, '!,|', 0, 255 ) === strlen( $moduleName ); + } + + /** + * Returns LESS compiler set up for use with MediaWiki + * + * @since 1.27 + * @param array $extraVars Associative array of extra (i.e., other than the + * globally-configured ones) that should be used for compilation. + * @throws MWException + * @return Less_Parser + */ + public function getLessCompiler( $extraVars = [] ) { + // 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' ) ) { + throw new MWException( 'MediaWiki requires the less.php parser' ); + } + + $parser = new Less_Parser; + $parser->ModifyVars( array_merge( $this->getLessVars(), $extraVars ) ); + $parser->SetImportDirs( + array_fill_keys( $this->config->get( 'ResourceLoaderLESSImportPaths' ), '' ) + ); + $parser->SetOption( 'relativeUrls', false ); + + return $parser; + } + + /** + * Get global LESS variables. + * + * @since 1.27 + * @return array Map of variable names to string CSS values. + */ + public function getLessVars() { + if ( $this->lessVars === null ) { + $this->lessVars = $this->config->get( 'ResourceLoaderLESSVars' ); + } + return $this->lessVars; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php b/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php new file mode 100644 index 00000000..545fd3bd --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderClientHtml.php @@ -0,0 +1,472 @@ +<?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 + */ + +use Wikimedia\WrappedStringList; + +/** + * Bootstrap a ResourceLoader client on an HTML page. + * + * @since 1.28 + */ +class ResourceLoaderClientHtml { + + /** @var ResourceLoaderContext */ + private $context; + + /** @var ResourceLoader */ + private $resourceLoader; + + /** @var array */ + private $options; + + /** @var array */ + private $config = []; + + /** @var array */ + private $modules = []; + + /** @var array */ + private $moduleStyles = []; + + /** @var array */ + private $moduleScripts = []; + + /** @var array */ + private $exemptStates = []; + + /** @var array */ + private $data; + + /** + * @param ResourceLoaderContext $context + * @param array $options [optional] Array of options + * - 'target': Custom parameter passed to StartupModule. + */ + public function __construct( ResourceLoaderContext $context, array $options = [] ) { + $this->context = $context; + $this->resourceLoader = $context->getResourceLoader(); + $this->options = $options; + } + + /** + * Set mw.config variables. + * + * @param array $vars Array of key/value pairs + */ + public function setConfig( array $vars ) { + foreach ( $vars as $key => $value ) { + $this->config[$key] = $value; + } + } + + /** + * Ensure one or more modules are loaded. + * + * @param array $modules Array of module names + */ + public function setModules( array $modules ) { + $this->modules = $modules; + } + + /** + * Ensure the styles of one or more modules are loaded. + * + * @deprecated since 1.28 + * @param array $modules Array of module names + */ + public function setModuleStyles( array $modules ) { + $this->moduleStyles = $modules; + } + + /** + * Ensure the scripts of one or more modules are loaded. + * + * @deprecated since 1.28 + * @param array $modules Array of module names + */ + public function setModuleScripts( array $modules ) { + $this->moduleScripts = $modules; + } + + /** + * Set state of special modules that are handled by the caller manually. + * + * See OutputPage::buildExemptModules() for use cases. + * + * @param array $states Module state keyed by module name + */ + public function setExemptStates( array $states ) { + $this->exemptStates = $states; + } + + /** + * @return array + */ + private function getData() { + if ( $this->data ) { + // @codeCoverageIgnoreStart + return $this->data; + // @codeCoverageIgnoreEnd + } + + $rl = $this->resourceLoader; + $data = [ + 'states' => [ + // moduleName => state + ], + 'general' => [], + 'styles' => [], + 'scripts' => [], + // Embedding for private modules + 'embed' => [ + 'styles' => [], + 'general' => [], + ], + + ]; + + foreach ( $this->modules as $name ) { + $module = $rl->getModule( $name ); + if ( !$module ) { + continue; + } + + $context = $this->getContext( $module->getGroup(), ResourceLoaderModule::TYPE_COMBINED ); + if ( $module->isKnownEmpty( $context ) ) { + // Avoid needless request or embed for empty module + $data['states'][$name] = 'ready'; + continue; + } + + if ( $module->shouldEmbedModule( $this->context ) ) { + // Embed via mw.loader.implement per T36907. + $data['embed']['general'][] = $name; + // Avoid duplicate request from mw.loader + $data['states'][$name] = 'loading'; + } else { + // Load via mw.loader.load() + $data['general'][] = $name; + } + } + + foreach ( $this->moduleStyles as $name ) { + $module = $rl->getModule( $name ); + if ( !$module ) { + continue; + } + + if ( $module->getType() !== ResourceLoaderModule::LOAD_STYLES ) { + $logger = $rl->getLogger(); + $logger->error( 'Unexpected general module "{module}" in styles queue.', [ + 'module' => $name, + ] ); + continue; + } + + // Stylesheet doesn't trigger mw.loader callback. + // Set "ready" state to allow script modules to depend on this module (T87871). + // And to avoid duplicate requests at run-time from mw.loader. + $data['states'][$name] = 'ready'; + + $group = $module->getGroup(); + $context = $this->getContext( $group, ResourceLoaderModule::TYPE_STYLES ); + // Avoid needless request for empty module + if ( !$module->isKnownEmpty( $context ) ) { + if ( $module->shouldEmbedModule( $this->context ) ) { + // Embed via style element + $data['embed']['styles'][] = $name; + } else { + // Load from load.php?only=styles via <link rel=stylesheet> + $data['styles'][] = $name; + } + } + } + + foreach ( $this->moduleScripts as $name ) { + $module = $rl->getModule( $name ); + if ( !$module ) { + continue; + } + + $group = $module->getGroup(); + $context = $this->getContext( $group, ResourceLoaderModule::TYPE_SCRIPTS ); + if ( $module->isKnownEmpty( $context ) ) { + // Avoid needless request for empty module + $data['states'][$name] = 'ready'; + } else { + // Load from load.php?only=scripts via <script src></script> + $data['scripts'][] = $name; + + // Avoid duplicate request from mw.loader + $data['states'][$name] = 'loading'; + } + } + + return $data; + } + + /** + * @return array Attribute key-value pairs for the HTML document element + */ + public function getDocumentAttributes() { + return [ 'class' => 'client-nojs' ]; + } + + /** + * The order of elements in the head is as follows: + * - Inline scripts. + * - Stylesheets. + * - Async external script-src. + * + * Reasons: + * - Script execution may be blocked on preceeding stylesheets. + * - Async scripts are not blocked on stylesheets. + * - Inline scripts can't be asynchronous. + * - For styles, earlier is better. + * + * @return string|WrappedStringList HTML + */ + public function getHeadHtml() { + $data = $this->getData(); + $chunks = []; + + // Change "client-nojs" class to client-js. This allows easy toggling of UI components. + // This happens synchronously on every page view to avoid flashes of wrong content. + // See also #getDocumentAttributes() and /resources/src/startup.js. + $chunks[] = Html::inlineScript( + 'document.documentElement.className = document.documentElement.className' + . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );' + ); + + // Inline RLQ: Set page variables + if ( $this->config ) { + $chunks[] = ResourceLoader::makeInlineScript( + ResourceLoader::makeConfigSetScript( $this->config ) + ); + } + + // Inline RLQ: Initial module states + $states = array_merge( $this->exemptStates, $data['states'] ); + if ( $states ) { + $chunks[] = ResourceLoader::makeInlineScript( + ResourceLoader::makeLoaderStateScript( $states ) + ); + } + + // Inline RLQ: Embedded modules + if ( $data['embed']['general'] ) { + $chunks[] = $this->getLoad( + $data['embed']['general'], + ResourceLoaderModule::TYPE_COMBINED + ); + } + + // Inline RLQ: Load general modules + if ( $data['general'] ) { + $chunks[] = ResourceLoader::makeInlineScript( + Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] ) + ); + } + + // Inline RLQ: Load only=scripts + if ( $data['scripts'] ) { + $chunks[] = $this->getLoad( + $data['scripts'], + ResourceLoaderModule::TYPE_SCRIPTS + ); + } + + // External stylesheets + if ( $data['styles'] ) { + $chunks[] = $this->getLoad( + $data['styles'], + ResourceLoaderModule::TYPE_STYLES + ); + } + + // Inline stylesheets (embedded only=styles) + if ( $data['embed']['styles'] ) { + $chunks[] = $this->getLoad( + $data['embed']['styles'], + ResourceLoaderModule::TYPE_STYLES + ); + } + + // Async scripts. Once the startup is loaded, inline RLQ scripts will run. + // Pass-through a custom 'target' from OutputPage (T143066). + $startupQuery = isset( $this->options['target'] ) + ? [ 'target' => (string)$this->options['target'] ] + : []; + $chunks[] = $this->getLoad( + 'startup', + ResourceLoaderModule::TYPE_SCRIPTS, + $startupQuery + ); + + return WrappedStringList::join( "\n", $chunks ); + } + + /** + * @return string|WrappedStringList HTML + */ + public function getBodyHtml() { + return ''; + } + + private function getContext( $group, $type ) { + return self::makeContext( $this->context, $group, $type ); + } + + private function getLoad( $modules, $only, array $extraQuery = [] ) { + return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery ); + } + + private static function makeContext( ResourceLoaderContext $mainContext, $group, $type, + array $extraQuery = [] + ) { + // Create new ResourceLoaderContext so that $extraQuery may trigger isRaw(). + $req = new FauxRequest( array_merge( $mainContext->getRequest()->getValues(), $extraQuery ) ); + // Set 'only' if not combined + $req->setVal( 'only', $type === ResourceLoaderModule::TYPE_COMBINED ? null : $type ); + // Remove user parameter in most cases + if ( $group !== 'user' && $group !== 'private' ) { + $req->setVal( 'user', null ); + } + $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req ); + // Allow caller to setVersion() and setModules() + return new DerivativeResourceLoaderContext( $context ); + } + + /** + * Explicily load or embed modules on a page. + * + * @param ResourceLoaderContext $mainContext + * @param array $modules One or more module names + * @param string $only ResourceLoaderModule TYPE_ class constant + * @param array $extraQuery [optional] Array with extra query parameters for the request + * @return string|WrappedStringList HTML + */ + public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only, + array $extraQuery = [] + ) { + $rl = $mainContext->getResourceLoader(); + $chunks = []; + + // Sort module names so requests are more uniform + sort( $modules ); + + if ( $mainContext->getDebug() && count( $modules ) > 1 ) { + $chunks = []; + // Recursively call us for every item + foreach ( $modules as $name ) { + $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery ); + } + return new WrappedStringList( "\n", $chunks ); + } + + // Create keyed-by-source and then keyed-by-group list of module objects from modules list + $sortedModules = []; + foreach ( $modules as $name ) { + $module = $rl->getModule( $name ); + if ( !$module ) { + $rl->getLogger()->warning( 'Unknown module "{module}"', [ 'module' => $name ] ); + continue; + } + $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module; + } + + foreach ( $sortedModules as $source => $groups ) { + foreach ( $groups as $group => $grpModules ) { + $context = self::makeContext( $mainContext, $group, $only, $extraQuery ); + + // Separate sets of linked and embedded modules while preserving order + $moduleSets = []; + $idx = -1; + foreach ( $grpModules as $name => $module ) { + $shouldEmbed = $module->shouldEmbedModule( $context ); + if ( !$moduleSets || $moduleSets[$idx][0] !== $shouldEmbed ) { + $moduleSets[++$idx] = [ $shouldEmbed, [] ]; + } + $moduleSets[$idx][1][$name] = $module; + } + + // Link/embed each set + foreach ( $moduleSets as list( $embed, $moduleSet ) ) { + $context->setModules( array_keys( $moduleSet ) ); + if ( $embed ) { + // Decide whether to use style or script element + if ( $only == ResourceLoaderModule::TYPE_STYLES ) { + $chunks[] = Html::inlineStyle( + $rl->makeModuleResponse( $context, $moduleSet ) + ); + } else { + $chunks[] = ResourceLoader::makeInlineScript( + $rl->makeModuleResponse( $context, $moduleSet ) + ); + } + } else { + // See if we have one or more raw modules + $isRaw = false; + foreach ( $moduleSet as $key => $module ) { + $isRaw |= $module->isRaw(); + } + + // Special handling for the user group; because users might change their stuff + // on-wiki like user pages, or user preferences; we need to find the highest + // timestamp of these user-changeable modules so we can ensure cache misses on change + // This should NOT be done for the site group (T29564) because anons get that too + // and we shouldn't be putting timestamps in CDN-cached HTML + if ( $group === 'user' ) { + // Must setModules() before makeVersionQuery() + $context->setVersion( $rl->makeVersionQuery( $context ) ); + } + + $url = $rl->createLoaderURL( $source, $context, $extraQuery ); + + // Decide whether to use 'style' or 'script' element + if ( $only === ResourceLoaderModule::TYPE_STYLES ) { + $chunk = Html::linkedStyle( $url ); + } else { + if ( $context->getRaw() || $isRaw ) { + $chunk = Html::element( 'script', [ + // In SpecialJavaScriptTest, QUnit must load synchronous + 'async' => !isset( $extraQuery['sync'] ), + 'src' => $url + ] ); + } else { + $chunk = ResourceLoader::makeInlineScript( + Xml::encodeJsCall( 'mw.loader.load', [ $url ] ) + ); + } + } + + if ( $group == 'noscript' ) { + $chunks[] = Html::rawElement( 'noscript', [], $chunk ); + } else { + $chunks[] = $chunk; + } + } + } + } + } + + return new WrappedStringList( "\n", $chunks ); + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderContext.php b/www/wiki/includes/resourceloader/ResourceLoaderContext.php new file mode 100644 index 00000000..c4e9884a --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderContext.php @@ -0,0 +1,395 @@ +<?php +/** + * Context for ResourceLoader modules. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; + +/** + * Object passed around to modules which contains information about the state + * of a specific loader request. + */ +class ResourceLoaderContext implements MessageLocalizer { + protected $resourceLoader; + protected $request; + protected $logger; + + // Module content vary + protected $skin; + protected $language; + protected $debug; + protected $user; + + // Request vary (in addition to cache vary) + protected $modules; + protected $only; + protected $version; + protected $raw; + protected $image; + protected $variant; + protected $format; + + protected $direction; + protected $hash; + protected $userObj; + protected $imageObj; + + /** + * @param ResourceLoader $resourceLoader + * @param WebRequest $request + */ + public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) { + $this->resourceLoader = $resourceLoader; + $this->request = $request; + $this->logger = $resourceLoader->getLogger(); + + // Future developers: Use WebRequest::getRawVal() instead getVal(). + // The getVal() method performs slow Language+UTF logic. (f303bb9360) + + // List of modules + $modules = $request->getRawVal( 'modules' ); + $this->modules = $modules ? self::expandModuleNames( $modules ) : []; + + // Various parameters + $this->user = $request->getRawVal( 'user' ); + $this->debug = $request->getFuzzyBool( + 'debug', + $resourceLoader->getConfig()->get( 'ResourceLoaderDebug' ) + ); + $this->only = $request->getRawVal( 'only', null ); + $this->version = $request->getRawVal( 'version', null ); + $this->raw = $request->getFuzzyBool( 'raw' ); + + // Image requests + $this->image = $request->getRawVal( 'image' ); + $this->variant = $request->getRawVal( 'variant' ); + $this->format = $request->getRawVal( 'format' ); + + $this->skin = $request->getRawVal( 'skin' ); + $skinnames = Skin::getSkinNames(); + // If no skin is specified, or we don't recognize the skin, use the default skin + if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) { + $this->skin = $resourceLoader->getConfig()->get( 'DefaultSkin' ); + } + } + + /** + * 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(). + * + * @param string $modules Packed module name list + * @return array 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; + } + + /** + * Return a dummy ResourceLoaderContext object suitable for passing into + * things that don't "really" need a context. + * @return ResourceLoaderContext + */ + public static function newDummyContext() { + return new self( new ResourceLoader( + MediaWikiServices::getInstance()->getMainConfig(), + LoggerFactory::getInstance( 'resourceloader' ) + ), new FauxRequest( [] ) ); + } + + /** + * @return ResourceLoader + */ + public function getResourceLoader() { + return $this->resourceLoader; + } + + /** + * @return WebRequest + */ + public function getRequest() { + return $this->request; + } + + /** + * @since 1.27 + * @return \Psr\Log\LoggerInterface + */ + public function getLogger() { + return $this->logger; + } + + /** + * @return array + */ + public function getModules() { + return $this->modules; + } + + /** + * @return string + */ + public function getLanguage() { + if ( $this->language === null ) { + // Must be a valid language code after this point (T64849) + // Only support uselang values that follow built-in conventions (T102058) + $lang = $this->getRequest()->getRawVal( 'lang', '' ); + // Stricter version of RequestContext::sanitizeLangCode() + if ( !Language::isValidBuiltInCode( $lang ) ) { + $lang = $this->getResourceLoader()->getConfig()->get( 'LanguageCode' ); + } + $this->language = $lang; + } + return $this->language; + } + + /** + * @return string + */ + public function getDirection() { + if ( $this->direction === null ) { + $this->direction = $this->getRequest()->getRawVal( 'dir' ); + if ( !$this->direction ) { + // Determine directionality based on user language (T8100) + $this->direction = Language::factory( $this->getLanguage() )->getDir(); + } + } + return $this->direction; + } + + /** + * @return string + */ + public function getSkin() { + return $this->skin; + } + + /** + * @return string|null + */ + public function getUser() { + return $this->user; + } + + /** + * Get a Message object with context set. See wfMessage for parameters. + * + * @since 1.27 + * @param string|string[]|MessageSpecifier $key Message key, or array of keys, + * or a MessageSpecifier. + * @param mixed $args,... + * @return Message + */ + public function msg( $key ) { + return call_user_func_array( 'wfMessage', func_get_args() ) + ->inLanguage( $this->getLanguage() ) + // Use a dummy title because there is no real title + // for this endpoint, and the cache won't vary on it + // anyways. + ->title( Title::newFromText( 'Dwimmerlaik' ) ); + } + + /** + * Get the possibly-cached User object for the specified username + * + * @since 1.25 + * @return User + */ + public function getUserObj() { + if ( $this->userObj === null ) { + $username = $this->getUser(); + if ( $username ) { + // Use provided username if valid, fallback to anonymous user + $this->userObj = User::newFromName( $username ) ?: new User; + } else { + // Anonymous user + $this->userObj = new User; + } + } + + return $this->userObj; + } + + /** + * @return bool + */ + public function getDebug() { + return $this->debug; + } + + /** + * @return string|null + */ + public function getOnly() { + return $this->only; + } + + /** + * @see ResourceLoaderModule::getVersionHash + * @see ResourceLoaderClientHtml::makeLoad + * @return string|null + */ + public function getVersion() { + return $this->version; + } + + /** + * @return bool + */ + public function getRaw() { + return $this->raw; + } + + /** + * @return string|null + */ + public function getImage() { + return $this->image; + } + + /** + * @return string|null + */ + public function getVariant() { + return $this->variant; + } + + /** + * @return string|null + */ + public function getFormat() { + return $this->format; + } + + /** + * If this is a request for an image, get the ResourceLoaderImage object. + * + * @since 1.25 + * @return ResourceLoaderImage|bool false if a valid object cannot be created + */ + public function getImageObj() { + if ( $this->imageObj === null ) { + $this->imageObj = false; + + if ( !$this->image ) { + return $this->imageObj; + } + + $modules = $this->getModules(); + if ( count( $modules ) !== 1 ) { + return $this->imageObj; + } + + $module = $this->getResourceLoader()->getModule( $modules[0] ); + if ( !$module || !$module instanceof ResourceLoaderImageModule ) { + return $this->imageObj; + } + + $image = $module->getImage( $this->image, $this ); + if ( !$image ) { + return $this->imageObj; + } + + $this->imageObj = $image; + } + + return $this->imageObj; + } + + /** + * @return bool + */ + public function shouldIncludeScripts() { + return $this->getOnly() === null || $this->getOnly() === 'scripts'; + } + + /** + * @return bool + */ + public function shouldIncludeStyles() { + return $this->getOnly() === null || $this->getOnly() === 'styles'; + } + + /** + * @return bool + */ + public function shouldIncludeMessages() { + return $this->getOnly() === null; + } + + /** + * All factors that uniquely identify this request, except 'modules'. + * + * The list of modules is excluded here for legacy reasons as most callers already + * split up handling of individual modules. Including it here would massively fragment + * the cache and decrease its usefulness. + * + * E.g. Used by RequestFileCache to form a cache key for storing the reponse output. + * + * @return string + */ + public function getHash() { + if ( !isset( $this->hash ) ) { + $this->hash = implode( '|', [ + // Module content vary + $this->getLanguage(), + $this->getSkin(), + $this->getDebug(), + $this->getUser(), + // Request vary + $this->getOnly(), + $this->getVersion(), + $this->getRaw(), + $this->getImage(), + $this->getVariant(), + $this->getFormat(), + ] ); + } + return $this->hash; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php b/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php new file mode 100644 index 00000000..2a6af715 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderEditToolbarModule.php @@ -0,0 +1,44 @@ +<?php +/** + * ResourceLoader module for the edit toolbar. + * + * 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 + */ + +/** + * ResourceLoader module for the edit toolbar. + * + * @since 1.24 + */ +class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule { + /** + * Get language-specific LESS variables for this module. + * + * @since 1.27 + * @param ResourceLoaderContext $context + * @return array + */ + protected function getLessVars( ResourceLoaderContext $context ) { + $vars = parent::getLessVars( $context ); + $language = Language::factory( $context->getLanguage() ); + foreach ( $language->getImageFiles() as $key => $value ) { + $vars[$key] = CSSMin::serializeStringValue( $value ); + } + return $vars; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php new file mode 100644 index 00000000..f2f3383f --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderFileModule.php @@ -0,0 +1,1038 @@ +<?php +/** + * ResourceLoader module based on local JavaScript/CSS files. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * ResourceLoader module based on local JavaScript/CSS files. + */ +class ResourceLoaderFileModule extends ResourceLoaderModule { + + /** @var string Local base path, see __construct() */ + protected $localBasePath = ''; + + /** @var string Remote base path, see __construct() */ + protected $remoteBasePath = ''; + + /** @var array Saves a list of the templates named by the modules. */ + protected $templates = []; + + /** + * @var array List of paths to JavaScript files to always include + * @par Usage: + * @code + * [ [file-path], [file-path], ... ] + * @endcode + */ + protected $scripts = []; + + /** + * @var array List of JavaScript files to include when using a specific language + * @par Usage: + * @code + * [ [language-code] => [ [file-path], [file-path], ... ], ... ] + * @endcode + */ + protected $languageScripts = []; + + /** + * @var array List of JavaScript files to include when using a specific skin + * @par Usage: + * @code + * [ [skin-name] => [ [file-path], [file-path], ... ], ... ] + * @endcode + */ + protected $skinScripts = []; + + /** + * @var array List of paths to JavaScript files to include in debug mode + * @par Usage: + * @code + * [ [skin-name] => [ [file-path], [file-path], ... ], ... ] + * @endcode + */ + protected $debugScripts = []; + + /** + * @var array List of paths to CSS files to always include + * @par Usage: + * @code + * [ [file-path], [file-path], ... ] + * @endcode + */ + protected $styles = []; + + /** + * @var array List of paths to CSS files to include when using specific skins + * @par Usage: + * @code + * [ [file-path], [file-path], ... ] + * @endcode + */ + protected $skinStyles = []; + + /** + * @var array List of modules this module depends on + * @par Usage: + * @code + * [ [file-path], [file-path], ... ] + * @endcode + */ + protected $dependencies = []; + + /** + * @var string File name containing the body of the skip function + */ + protected $skipFunction = null; + + /** + * @var array List of message keys used by this module + * @par Usage: + * @code + * [ [message-key], [message-key], ... ] + * @endcode + */ + protected $messages = []; + + /** @var string Name of group to load this module in */ + protected $group; + + /** @var bool Link to raw files in debug mode */ + protected $debugRaw = true; + + /** @var bool Whether mw.loader.state() call should be omitted */ + protected $raw = false; + + protected $targets = [ 'desktop' ]; + + /** @var bool Whether CSSJanus flipping should be skipped for this module */ + protected $noflip = false; + + /** + * @var bool Whether getStyleURLsForDebug should return raw file paths, + * or return load.php urls + */ + protected $hasGeneratedStyles = false; + + /** + * @var array Place where readStyleFile() tracks file dependencies + * @par Usage: + * @code + * [ [file-path], [file-path], ... ] + * @endcode + */ + protected $localFileRefs = []; + + /** + * @var array Place where readStyleFile() tracks file dependencies for non-existent files. + * Used in tests to detect missing dependencies. + */ + protected $missingLocalFileRefs = []; + + /** + * Constructs a new module from an options array. + * + * @param array $options List of options; if not given or empty, an empty module will be + * constructed + * @param string $localBasePath Base path to prepend to all local paths in $options. Defaults + * to $IP + * @param string $remoteBasePath Base path to prepend to all remote paths in $options. Defaults + * to $wgResourceBasePath + * + * Below is a description for the $options array: + * @throws InvalidArgumentException + * @par Construction options: + * @code + * [ + * // Base path to prepend to all local paths in $options. Defaults to $IP + * 'localBasePath' => [base path], + * // Base path to prepend to all remote paths in $options. Defaults to $wgResourceBasePath + * 'remoteBasePath' => [base path], + * // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath + * 'remoteExtPath' => [base path], + * // Equivalent of remoteBasePath, but relative to $wgStylePath + * 'remoteSkinPath' => [base path], + * // Scripts to always include + * 'scripts' => [file path string or array of file path strings], + * // Scripts to include in specific language contexts + * 'languageScripts' => [ + * [language code] => [file path string or array of file path strings], + * ], + * // Scripts to include in specific skin contexts + * 'skinScripts' => [ + * [skin name] => [file path string or array of file path strings], + * ], + * // Scripts to include in debug contexts + * 'debugScripts' => [file path string or array of file path strings], + * // Modules which must be loaded before this module + * 'dependencies' => [module name string or array of module name strings], + * 'templates' => [ + * [template alias with file.ext] => [file path to a template file], + * ], + * // Styles to always load + * 'styles' => [file path string or array of file path strings], + * // Styles to include in specific skin contexts + * 'skinStyles' => [ + * [skin name] => [file path string or array of file path strings], + * ], + * // Messages to always load + * 'messages' => [array of message key strings], + * // Group which this module should be loaded together with + * 'group' => [group name string], + * // Function that, if it returns true, makes the loader skip this module. + * // The file must contain valid JavaScript for execution in a private function. + * // The file must not contain the "function () {" and "}" wrapper though. + * 'skipFunction' => [file path] + * ] + * @endcode + */ + public function __construct( + $options = [], + $localBasePath = null, + $remoteBasePath = null + ) { + // Flag to decide whether to automagically add the mediawiki.template module + $hasTemplates = false; + // localBasePath and remoteBasePath both have unbelievably long fallback chains + // and need to be handled separately. + list( $this->localBasePath, $this->remoteBasePath ) = + self::extractBasePaths( $options, $localBasePath, $remoteBasePath ); + + // Extract, validate and normalise remaining options + foreach ( $options as $member => $option ) { + switch ( $member ) { + // Lists of file paths + case 'scripts': + case 'debugScripts': + case 'styles': + $this->{$member} = (array)$option; + break; + case 'templates': + $hasTemplates = true; + $this->{$member} = (array)$option; + break; + // Collated lists of file paths + case 'languageScripts': + case 'skinScripts': + case 'skinStyles': + if ( !is_array( $option ) ) { + throw new InvalidArgumentException( + "Invalid collated file path list error. " . + "'$option' given, array expected." + ); + } + foreach ( $option as $key => $value ) { + if ( !is_string( $key ) ) { + throw new InvalidArgumentException( + "Invalid collated file path list key error. " . + "'$key' given, string expected." + ); + } + $this->{$member}[$key] = (array)$value; + } + break; + case 'deprecated': + $this->deprecated = $option; + break; + // Lists of strings + case 'dependencies': + case 'messages': + case 'targets': + // Normalise + $option = array_values( array_unique( (array)$option ) ); + sort( $option ); + + $this->{$member} = $option; + break; + // Single strings + case 'group': + case 'skipFunction': + $this->{$member} = (string)$option; + break; + // Single booleans + case 'debugRaw': + case 'raw': + case 'noflip': + $this->{$member} = (bool)$option; + break; + } + } + if ( $hasTemplates ) { + $this->dependencies[] = 'mediawiki.template'; + // Ensure relevant template compiler module gets loaded + foreach ( $this->templates as $alias => $templatePath ) { + if ( is_int( $alias ) ) { + $alias = $templatePath; + } + $suffix = explode( '.', $alias ); + $suffix = end( $suffix ); + $compilerModule = 'mediawiki.template.' . $suffix; + if ( $suffix !== 'html' && !in_array( $compilerModule, $this->dependencies ) ) { + $this->dependencies[] = $compilerModule; + } + } + } + } + + /** + * Extract a pair of local and remote base paths from module definition information. + * Implementation note: the amount of global state used in this function is staggering. + * + * @param array $options Module definition + * @param string $localBasePath Path to use if not provided in module definition. Defaults + * to $IP + * @param string $remoteBasePath Path to use if not provided in module definition. Defaults + * to $wgResourceBasePath + * @return array Array( localBasePath, remoteBasePath ) + */ + public static function extractBasePaths( + $options = [], + $localBasePath = null, + $remoteBasePath = null + ) { + global $IP, $wgResourceBasePath; + + // The different ways these checks are done, and their ordering, look very silly, + // but were preserved for backwards-compatibility just in case. Tread lightly. + + if ( $localBasePath === null ) { + $localBasePath = $IP; + } + if ( $remoteBasePath === null ) { + $remoteBasePath = $wgResourceBasePath; + } + + if ( isset( $options['remoteExtPath'] ) ) { + global $wgExtensionAssetsPath; + $remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath']; + } + + if ( isset( $options['remoteSkinPath'] ) ) { + global $wgStylePath; + $remoteBasePath = $wgStylePath . '/' . $options['remoteSkinPath']; + } + + if ( array_key_exists( 'localBasePath', $options ) ) { + $localBasePath = (string)$options['localBasePath']; + } + + if ( array_key_exists( 'remoteBasePath', $options ) ) { + $remoteBasePath = (string)$options['remoteBasePath']; + } + + return [ $localBasePath, $remoteBasePath ]; + } + + /** + * Gets all scripts for a given context concatenated together. + * + * @param ResourceLoaderContext $context Context in which to generate script + * @return string JavaScript code for $context + */ + public function getScript( ResourceLoaderContext $context ) { + $files = $this->getScriptFiles( $context ); + return $this->getDeprecationInformation() . $this->readScriptFiles( $files ); + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getScriptURLsForDebug( ResourceLoaderContext $context ) { + $urls = []; + foreach ( $this->getScriptFiles( $context ) as $file ) { + $urls[] = OutputPage::transformResourcePath( + $this->getConfig(), + $this->getRemotePath( $file ) + ); + } + return $urls; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return $this->debugRaw; + } + + /** + * Get all styles for a given context. + * + * @param ResourceLoaderContext $context + * @return array CSS code for $context as an associative array mapping media type to CSS text. + */ + public function getStyles( ResourceLoaderContext $context ) { + $styles = $this->readStyleFiles( + $this->getStyleFiles( $context ), + $this->getFlip( $context ), + $context + ); + // Collect referenced files + $this->saveFileDependencies( $context, $this->localFileRefs ); + + return $styles; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getStyleURLsForDebug( ResourceLoaderContext $context ) { + if ( $this->hasGeneratedStyles ) { + // Do the default behaviour of returning a url back to load.php + // but with only=styles. + return parent::getStyleURLsForDebug( $context ); + } + // Our module consists entirely of real css files, + // in debug mode we can load those directly. + $urls = []; + foreach ( $this->getStyleFiles( $context ) as $mediaType => $list ) { + $urls[$mediaType] = []; + foreach ( $list as $file ) { + $urls[$mediaType][] = OutputPage::transformResourcePath( + $this->getConfig(), + $this->getRemotePath( $file ) + ); + } + } + return $urls; + } + + /** + * Gets list of message keys used by this module. + * + * @return array List of message keys + */ + public function getMessages() { + return $this->messages; + } + + /** + * Gets the name of the group this module should be loaded in. + * + * @return string Group name + */ + public function getGroup() { + return $this->group; + } + + /** + * Gets list of names of modules this module depends on. + * @param ResourceLoaderContext|null $context + * @return array List of module names + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return $this->dependencies; + } + + /** + * Get the skip function. + * @return null|string + * @throws MWException + */ + public function getSkipFunction() { + if ( !$this->skipFunction ) { + return null; + } + + $localPath = $this->getLocalPath( $this->skipFunction ); + if ( !file_exists( $localPath ) ) { + throw new MWException( __METHOD__ . ": skip function file not found: \"$localPath\"" ); + } + $contents = $this->stripBom( file_get_contents( $localPath ) ); + if ( $this->getConfig()->get( 'ResourceLoaderValidateStaticJS' ) ) { + $contents = $this->validateScriptFile( $localPath, $contents ); + } + return $contents; + } + + /** + * @return bool + */ + public function isRaw() { + return $this->raw; + } + + /** + * Disable module content versioning. + * + * This class uses getDefinitionSummary() instead, to avoid filesystem overhead + * involved with building the full module content inside a startup request. + * + * @return bool + */ + public function enableModuleContentVersion() { + return false; + } + + /** + * Helper method to gather file hashes for getDefinitionSummary. + * + * This function is context-sensitive, only computing hashes of files relevant to the + * given language, skin, etc. + * + * @see ResourceLoaderModule::getFileDependencies + * @param ResourceLoaderContext $context + * @return array + */ + protected function getFileHashes( ResourceLoaderContext $context ) { + $files = []; + + // Flatten style files into $files + $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' ); + foreach ( $styles as $styleFiles ) { + $files = array_merge( $files, $styleFiles ); + } + + $skinFiles = self::collateFilePathListByOption( + self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ), + 'media', + 'all' + ); + foreach ( $skinFiles as $styleFiles ) { + $files = array_merge( $files, $styleFiles ); + } + + // Final merge, this should result in a master list of dependent files + $files = array_merge( + $files, + $this->scripts, + $this->templates, + $context->getDebug() ? $this->debugScripts : [], + $this->getLanguageScripts( $context->getLanguage() ), + self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ) + ); + if ( $this->skipFunction ) { + $files[] = $this->skipFunction; + } + $files = array_map( [ $this, 'getLocalPath' ], $files ); + // File deps need to be treated separately because they're already prefixed + $files = array_merge( $files, $this->getFileDependencies( $context ) ); + // Filter out any duplicates from getFileDependencies() and others. + // Most commonly introduced by compileLessFile(), which always includes the + // entry point Less file we already know about. + $files = array_values( array_unique( $files ) ); + + // Don't include keys or file paths here, only the hashes. Including that would needlessly + // cause global cache invalidation when files move or if e.g. the MediaWiki path changes. + // Any significant ordering is already detected by the definition summary. + return array_map( [ __CLASS__, 'safeFileHash' ], $files ); + } + + /** + * Get the definition summary for this module. + * + * @param ResourceLoaderContext $context + * @return array + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + $summary = parent::getDefinitionSummary( $context ); + + $options = []; + foreach ( [ + // The following properties are omitted because they don't affect the module reponse: + // - localBasePath (Per T104950; Changes when absolute directory name changes. If + // this affects 'scripts' and other file paths, getFileHashes accounts for that.) + // - remoteBasePath (Per T104950) + // - dependencies (provided via startup module) + // - targets + // - group (provided via startup module) + 'scripts', + 'debugScripts', + 'styles', + 'languageScripts', + 'skinScripts', + 'skinStyles', + 'messages', + 'templates', + 'skipFunction', + 'debugRaw', + 'raw', + ] as $member ) { + $options[$member] = $this->{$member}; + }; + + $summary[] = [ + 'options' => $options, + 'fileHashes' => $this->getFileHashes( $context ), + 'messageBlob' => $this->getMessageBlob( $context ), + ]; + + $lessVars = $this->getLessVars( $context ); + if ( $lessVars ) { + $summary[] = [ 'lessVars' => $lessVars ]; + } + + return $summary; + } + + /** + * @param string|ResourceLoaderFilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof ResourceLoaderFilePath ) { + return $path->getLocalPath(); + } + + return "{$this->localBasePath}/$path"; + } + + /** + * @param string|ResourceLoaderFilePath $path + * @return string + */ + protected function getRemotePath( $path ) { + if ( $path instanceof ResourceLoaderFilePath ) { + return $path->getRemotePath(); + } + + return "{$this->remoteBasePath}/$path"; + } + + /** + * Infer the stylesheet language from a stylesheet file path. + * + * @since 1.22 + * @param string $path + * @return string The stylesheet language name + */ + public function getStyleSheetLang( $path ) { + return preg_match( '/\.less$/i', $path ) ? 'less' : 'css'; + } + + /** + * Collates file paths by option (where provided). + * + * @param array $list List of file paths in any combination of index/path + * or path/options pairs + * @param string $option Option name + * @param mixed $default Default value if the option isn't set + * @return array List of file paths, collated by $option + */ + protected static function collateFilePathListByOption( array $list, $option, $default ) { + $collatedFiles = []; + foreach ( (array)$list as $key => $value ) { + if ( is_int( $key ) ) { + // File name as the value + if ( !isset( $collatedFiles[$default] ) ) { + $collatedFiles[$default] = []; + } + $collatedFiles[$default][] = $value; + } elseif ( is_array( $value ) ) { + // File name as the key, options array as the value + $optionValue = isset( $value[$option] ) ? $value[$option] : $default; + if ( !isset( $collatedFiles[$optionValue] ) ) { + $collatedFiles[$optionValue] = []; + } + $collatedFiles[$optionValue][] = $key; + } + } + return $collatedFiles; + } + + /** + * Get a list of element that match a key, optionally using a fallback key. + * + * @param array $list List of lists to select from + * @param string $key Key to look for in $map + * @param string $fallback Key to look for in $list if $key doesn't exist + * @return array List of elements from $map which matched $key or $fallback, + * or an empty list in case of no match + */ + protected static function tryForKey( array $list, $key, $fallback = null ) { + if ( isset( $list[$key] ) && is_array( $list[$key] ) ) { + return $list[$key]; + } elseif ( is_string( $fallback ) + && isset( $list[$fallback] ) + && is_array( $list[$fallback] ) + ) { + return $list[$fallback]; + } + return []; + } + + /** + * Get a list of file paths for all scripts in this module, in order of proper execution. + * + * @param ResourceLoaderContext $context + * @return array List of file paths + */ + protected function getScriptFiles( ResourceLoaderContext $context ) { + $files = array_merge( + $this->scripts, + $this->getLanguageScripts( $context->getLanguage() ), + self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ) + ); + if ( $context->getDebug() ) { + $files = array_merge( $files, $this->debugScripts ); + } + + return array_unique( $files, SORT_REGULAR ); + } + + /** + * Get the set of language scripts for the given language, + * possibly using a fallback language. + * + * @param string $lang + * @return array + */ + private function getLanguageScripts( $lang ) { + $scripts = self::tryForKey( $this->languageScripts, $lang ); + if ( $scripts ) { + return $scripts; + } + $fallbacks = Language::getFallbacksFor( $lang ); + foreach ( $fallbacks as $lang ) { + $scripts = self::tryForKey( $this->languageScripts, $lang ); + if ( $scripts ) { + return $scripts; + } + } + + return []; + } + + /** + * Get a list of file paths for all styles in this module, in order of proper inclusion. + * + * @param ResourceLoaderContext $context + * @return array List of file paths + */ + public function getStyleFiles( ResourceLoaderContext $context ) { + return array_merge_recursive( + self::collateFilePathListByOption( $this->styles, 'media', 'all' ), + self::collateFilePathListByOption( + self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ), + 'media', + 'all' + ) + ); + } + + /** + * Gets a list of file paths for all skin styles in the module used by + * the skin. + * + * @param string $skinName The name of the skin + * @return array A list of file paths collated by media type + */ + protected function getSkinStyleFiles( $skinName ) { + return self::collateFilePathListByOption( + self::tryForKey( $this->skinStyles, $skinName ), + 'media', + 'all' + ); + } + + /** + * Gets a list of file paths for all skin style files in the module, + * for all available skins. + * + * @return array A list of file paths collated by media type + */ + protected function getAllSkinStyleFiles() { + $styleFiles = []; + $internalSkinNames = array_keys( Skin::getSkinNames() ); + $internalSkinNames[] = 'default'; + + foreach ( $internalSkinNames as $internalSkinName ) { + $styleFiles = array_merge_recursive( + $styleFiles, + $this->getSkinStyleFiles( $internalSkinName ) + ); + } + + return $styleFiles; + } + + /** + * Returns all style files and all skin style files used by this module. + * + * @return array + */ + public function getAllStyleFiles() { + $collatedStyleFiles = array_merge_recursive( + self::collateFilePathListByOption( $this->styles, 'media', 'all' ), + $this->getAllSkinStyleFiles() + ); + + $result = []; + + foreach ( $collatedStyleFiles as $media => $styleFiles ) { + foreach ( $styleFiles as $styleFile ) { + $result[] = $this->getLocalPath( $styleFile ); + } + } + + return $result; + } + + /** + * Gets the contents of a list of JavaScript files. + * + * @param array $scripts List of file paths to scripts to read, remap and concetenate + * @throws MWException + * @return string Concatenated and remapped JavaScript data from $scripts + */ + protected function readScriptFiles( array $scripts ) { + if ( empty( $scripts ) ) { + return ''; + } + $js = ''; + foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) { + $localPath = $this->getLocalPath( $fileName ); + if ( !file_exists( $localPath ) ) { + throw new MWException( __METHOD__ . ": script file not found: \"$localPath\"" ); + } + $contents = $this->stripBom( file_get_contents( $localPath ) ); + if ( $this->getConfig()->get( 'ResourceLoaderValidateStaticJS' ) ) { + // Static files don't really need to be checked as often; unlike + // on-wiki module they shouldn't change unexpectedly without + // admin interference. + $contents = $this->validateScriptFile( $fileName, $contents ); + } + $js .= $contents . "\n"; + } + return $js; + } + + /** + * Gets the contents of a list of CSS files. + * + * @param array $styles List of media type/list of file paths pairs, to read, remap and + * concetenate + * @param bool $flip + * @param ResourceLoaderContext $context + * + * @throws MWException + * @return array List of concatenated and remapped CSS data from $styles, + * keyed by media type + * + * @since 1.27 Calling this method without a ResourceLoaderContext instance + * is deprecated. + */ + public function readStyleFiles( array $styles, $flip, $context = null ) { + if ( $context === null ) { + wfDeprecated( __METHOD__ . ' without a ResourceLoader context', '1.27' ); + $context = ResourceLoaderContext::newDummyContext(); + } + + if ( empty( $styles ) ) { + return []; + } + foreach ( $styles as $media => $files ) { + $uniqueFiles = array_unique( $files, SORT_REGULAR ); + $styleFiles = []; + foreach ( $uniqueFiles as $file ) { + $styleFiles[] = $this->readStyleFile( $file, $flip, $context ); + } + $styles[$media] = implode( "\n", $styleFiles ); + } + return $styles; + } + + /** + * Reads a style file. + * + * This method can be used as a callback for array_map() + * + * @param string $path File path of style file to read + * @param bool $flip + * @param ResourceLoaderContext $context + * + * @return string CSS data in script file + * @throws MWException If the file doesn't exist + */ + protected function readStyleFile( $path, $flip, $context ) { + $localPath = $this->getLocalPath( $path ); + $remotePath = $this->getRemotePath( $path ); + if ( !file_exists( $localPath ) ) { + $msg = __METHOD__ . ": style file not found: \"$localPath\""; + wfDebugLog( 'resourceloader', $msg ); + throw new MWException( $msg ); + } + + if ( $this->getStyleSheetLang( $localPath ) === 'less' ) { + $style = $this->compileLessFile( $localPath, $context ); + $this->hasGeneratedStyles = true; + } else { + $style = $this->stripBom( file_get_contents( $localPath ) ); + } + + if ( $flip ) { + $style = CSSJanus::transform( $style, true, false ); + } + $localDir = dirname( $localPath ); + $remoteDir = dirname( $remotePath ); + // Get and register local file references + $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir ); + foreach ( $localFileRefs as $file ) { + if ( file_exists( $file ) ) { + $this->localFileRefs[] = $file; + } else { + $this->missingLocalFileRefs[] = $file; + } + } + // Don't cache this call. remap() ensures data URIs embeds are up to date, + // and urls contain correct content hashes in their query string. (T128668) + return CSSMin::remap( $style, $localDir, $remoteDir, true ); + } + + /** + * Get whether CSS for this module should be flipped + * @param ResourceLoaderContext $context + * @return bool + */ + public function getFlip( $context ) { + return $context->getDirection() === 'rtl' && !$this->noflip; + } + + /** + * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile'] + * + * @return array Array of strings + */ + public function getTargets() { + return $this->targets; + } + + /** + * Get the module's load type. + * + * @since 1.28 + * @return string + */ + public function getType() { + $canBeStylesOnly = !( + // All options except 'styles', 'skinStyles' and 'debugRaw' + $this->scripts + || $this->debugScripts + || $this->templates + || $this->languageScripts + || $this->skinScripts + || $this->dependencies + || $this->messages + || $this->skipFunction + || $this->raw + ); + return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL; + } + + /** + * Compile a LESS file into CSS. + * + * Keeps track of all used files and adds them to localFileRefs. + * + * @since 1.22 + * @since 1.27 Added $context paramter. + * @throws Exception If less.php encounters a parse error + * @param string $fileName File path of LESS source + * @param ResourceLoaderContext $context Context in which to generate script + * @return string CSS source + */ + protected function compileLessFile( $fileName, ResourceLoaderContext $context ) { + static $cache; + + if ( !$cache ) { + $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); + } + + // Construct a cache key from the LESS file name and a hash digest + // of the LESS variables used for compilation. + $vars = $this->getLessVars( $context ); + ksort( $vars ); + $varsHash = hash( 'md4', serialize( $vars ) ); + $cacheKey = $cache->makeGlobalKey( 'LESS', $fileName, $varsHash ); + $cachedCompile = $cache->get( $cacheKey ); + + // If we got a cached value, we have to validate it by getting a + // checksum of all the files that were loaded by the parser and + // ensuring it matches the cached entry's. + if ( isset( $cachedCompile['hash'] ) ) { + $contentHash = FileContentsHasher::getFileContentsHash( $cachedCompile['files'] ); + if ( $contentHash === $cachedCompile['hash'] ) { + $this->localFileRefs = array_merge( $this->localFileRefs, $cachedCompile['files'] ); + return $cachedCompile['css']; + } + } + + $compiler = $context->getResourceLoader()->getLessCompiler( $vars ); + $css = $compiler->parseFile( $fileName )->getCss(); + $files = $compiler->AllParsedFiles(); + $this->localFileRefs = array_merge( $this->localFileRefs, $files ); + + // Cache for 24 hours (86400 seconds). + $cache->set( $cacheKey, [ + 'css' => $css, + 'files' => $files, + 'hash' => FileContentsHasher::getFileContentsHash( $files ), + ], 3600 * 24 ); + + return $css; + } + + /** + * Takes named templates by the module and returns an array mapping. + * @return array Templates mapping template alias to content + * @throws MWException + */ + public function getTemplates() { + $templates = []; + + foreach ( $this->templates as $alias => $templatePath ) { + // Alias is optional + if ( is_int( $alias ) ) { + $alias = $templatePath; + } + $localPath = $this->getLocalPath( $templatePath ); + if ( file_exists( $localPath ) ) { + $content = file_get_contents( $localPath ); + $templates[$alias] = $this->stripBom( $content ); + } else { + $msg = __METHOD__ . ": template file not found: \"$localPath\""; + wfDebugLog( 'resourceloader', $msg ); + throw new MWException( $msg ); + } + } + return $templates; + } + + /** + * Takes an input string and removes the UTF-8 BOM character if present + * + * We need to remove these after reading a file, because we concatenate our files and + * the BOM character is not valid in the middle of a string. + * We already assume UTF-8 everywhere, so this should be safe. + * + * @param string $input + * @return string Input minus the intial BOM char + */ + protected function stripBom( $input ) { + if ( substr_compare( "\xef\xbb\xbf", $input, 0, 3 ) === 0 ) { + return substr( $input, 3 ); + } + return $input; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php b/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php new file mode 100644 index 00000000..3cf09d82 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderFilePath.php @@ -0,0 +1,71 @@ +<?php +/** + * An object to represent a path to a JavaScript/CSS file, along with a remote + * and local base path, for use with ResourceLoaderFileModule. + * + * 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 + */ + +/** + * An object to represent a path to a JavaScript/CSS file, along with a remote + * and local base path, for use with ResourceLoaderFileModule. + */ +class ResourceLoaderFilePath { + + /** @var string Local base path */ + protected $localBasePath; + + /** @var string Remote base path */ + protected $remoteBasePath; + + /** + * @var string Path to the file */ + protected $path; + + /** + * @param string $path Path to the file. + * @param string $localBasePath Base path to prepend when generating a local path. + * @param string $remoteBasePath Base path to prepend when generating a remote path. + */ + public function __construct( $path, $localBasePath, $remoteBasePath ) { + $this->path = $path; + $this->localBasePath = $localBasePath; + $this->remoteBasePath = $remoteBasePath; + } + + /** + * @return string + */ + public function getLocalPath() { + return "{$this->localBasePath}/{$this->path}"; + } + + /** + * @return string + */ + public function getRemotePath() { + return "{$this->remoteBasePath}/{$this->path}"; + } + + /** + * @return string + */ + public function getPath() { + return $this->path; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php b/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php new file mode 100644 index 00000000..4d215d6f --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderForeignApiModule.php @@ -0,0 +1,33 @@ +<?php +/** + * ResourceLoader module for mediawiki.ForeignApi that has dynamically + * generated dependencies, via a hook usable by extensions. + * + * 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 + */ + +/** + * ResourceLoader module for mediawiki.ForeignApi and its generated data + */ +class ResourceLoaderForeignApiModule extends ResourceLoaderFileModule { + public function getDependencies( ResourceLoaderContext $context = null ) { + $dependencies = $this->dependencies; + Hooks::run( 'ResourceLoaderForeignApiModules', [ &$dependencies, $context ] ); + return $dependencies; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderImage.php b/www/wiki/includes/resourceloader/ResourceLoaderImage.php new file mode 100644 index 00000000..d38a1750 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderImage.php @@ -0,0 +1,413 @@ +<?php +/** + * Class encapsulating an image used in a ResourceLoaderImageModule. + * + * 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 + */ + +/** + * Class encapsulating an image used in a ResourceLoaderImageModule. + * + * @since 1.25 + */ +class ResourceLoaderImage { + + /** + * Map of allowed file extensions to their MIME types. + * @var array + */ + protected static $fileTypes = [ + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'jpg' => 'image/jpg', + ]; + + /** + * @param string $name Image name + * @param string $module Module name + * @param string|array $descriptor Path to image file, or array structure containing paths + * @param string $basePath Directory to which paths in descriptor refer + * @param array $variants + * @throws InvalidArgumentException + */ + public function __construct( $name, $module, $descriptor, $basePath, $variants ) { + $this->name = $name; + $this->module = $module; + $this->descriptor = $descriptor; + $this->basePath = $basePath; + $this->variants = $variants; + + // Expand shorthands: + // [ "en,de,fr" => "foo.svg" ] + // → [ "en" => "foo.svg", "de" => "foo.svg", "fr" => "foo.svg" ] + if ( is_array( $this->descriptor ) && isset( $this->descriptor['lang'] ) ) { + foreach ( array_keys( $this->descriptor['lang'] ) as $langList ) { + if ( strpos( $langList, ',' ) !== false ) { + $this->descriptor['lang'] += array_fill_keys( + explode( ',', $langList ), + $this->descriptor['lang'][$langList] + ); + unset( $this->descriptor['lang'][$langList] ); + } + } + } + // Remove 'deprecated' key + if ( is_array( $this->descriptor ) ) { + unset( $this->descriptor[ 'deprecated' ] ); + } + + // Ensure that all files have common extension. + $extensions = []; + $descriptor = (array)$this->descriptor; + array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) { + $extensions[] = pathinfo( $path, PATHINFO_EXTENSION ); + } ); + $extensions = array_unique( $extensions ); + if ( count( $extensions ) !== 1 ) { + throw new InvalidArgumentException( + "File type for different image files of '$name' not the same in module '$module'" + ); + } + $ext = $extensions[0]; + if ( !isset( self::$fileTypes[$ext] ) ) { + throw new InvalidArgumentException( + "Invalid file type for image files of '$name' (valid: svg, png, gif, jpg) in module '$module'" + ); + } + $this->extension = $ext; + } + + /** + * Get name of this image. + * + * @return string + */ + public function getName() { + return $this->name; + } + + /** + * Get name of the module this image belongs to. + * + * @return string + */ + public function getModule() { + return $this->module; + } + + /** + * Get the list of variants this image can be converted to. + * + * @return string[] + */ + public function getVariants() { + return array_keys( $this->variants ); + } + + /** + * Get the path to image file for given context. + * + * @param ResourceLoaderContext $context Any context + * @return string + */ + public function getPath( ResourceLoaderContext $context ) { + $desc = $this->descriptor; + if ( is_string( $desc ) ) { + return $this->basePath . '/' . $desc; + } + if ( isset( $desc['lang'] ) ) { + $contextLang = $context->getLanguage(); + if ( isset( $desc['lang'][$contextLang] ) ) { + return $this->basePath . '/' . $desc['lang'][$contextLang]; + } + $fallbacks = Language::getFallbacksFor( $contextLang ); + foreach ( $fallbacks as $lang ) { + // Images will fallback to 'default' instead of 'en', except for 'en-*' variants + if ( + ( $lang !== 'en' || substr( $contextLang, 0, 3 ) === 'en-' ) && + isset( $desc['lang'][$lang] ) + ) { + return $this->basePath . '/' . $desc['lang'][$lang]; + } + } + } + if ( isset( $desc[$context->getDirection()] ) ) { + return $this->basePath . '/' . $desc[$context->getDirection()]; + } + return $this->basePath . '/' . $desc['default']; + } + + /** + * Get the extension of the image. + * + * @param string $format Format to get the extension for, 'original' or 'rasterized' + * @return string Extension without leading dot, e.g. 'png' + */ + public function getExtension( $format = 'original' ) { + if ( $format === 'rasterized' && $this->extension === 'svg' ) { + return 'png'; + } + return $this->extension; + } + + /** + * Get the MIME type of the image. + * + * @param string $format Format to get the MIME type for, 'original' or 'rasterized' + * @return string + */ + public function getMimeType( $format = 'original' ) { + $ext = $this->getExtension( $format ); + return self::$fileTypes[$ext]; + } + + /** + * Get the load.php URL that will produce this image. + * + * @param ResourceLoaderContext $context Any context + * @param string $script URL to load.php + * @param string|null $variant Variant to get the URL for + * @param string $format Format to get the URL for, 'original' or 'rasterized' + * @return string + */ + public function getUrl( ResourceLoaderContext $context, $script, $variant, $format ) { + $query = [ + 'modules' => $this->getModule(), + 'image' => $this->getName(), + 'variant' => $variant, + 'format' => $format, + 'lang' => $context->getLanguage(), + 'skin' => $context->getSkin(), + 'version' => $context->getVersion(), + ]; + + return wfAppendQuery( $script, $query ); + } + + /** + * Get the data: URI that will produce this image. + * + * @param ResourceLoaderContext $context Any context + * @param string|null $variant Variant to get the URI for + * @param string $format Format to get the URI for, 'original' or 'rasterized' + * @return string + */ + public function getDataUri( ResourceLoaderContext $context, $variant, $format ) { + $type = $this->getMimeType( $format ); + $contents = $this->getImageData( $context, $variant, $format ); + return CSSMin::encodeStringAsDataURI( $contents, $type ); + } + + /** + * Get actual image data for this image. This can be saved to a file or sent to the browser to + * produce the converted image. + * + * Call getExtension() or getMimeType() with the same $format argument to learn what file type the + * returned data uses. + * + * @param ResourceLoaderContext $context Image context, or any context if $variant and $format + * given. + * @param string|null $variant Variant to get the data for. Optional; if given, overrides the data + * from $context. + * @param string $format Format to get the data for, 'original' or 'rasterized'. Optional; if + * given, overrides the data from $context. + * @return string|false Possibly binary image data, or false on failure + * @throws MWException If the image file doesn't exist + */ + public function getImageData( ResourceLoaderContext $context, $variant = false, $format = false ) { + if ( $variant === false ) { + $variant = $context->getVariant(); + } + if ( $format === false ) { + $format = $context->getFormat(); + } + + $path = $this->getPath( $context ); + if ( !file_exists( $path ) ) { + throw new MWException( "File '$path' does not exist" ); + } + + if ( $this->getExtension() !== 'svg' ) { + return file_get_contents( $path ); + } + + if ( $variant && isset( $this->variants[$variant] ) ) { + $data = $this->variantize( $this->variants[$variant], $context ); + } else { + $data = file_get_contents( $path ); + } + + if ( $format === 'rasterized' ) { + $data = $this->rasterize( $data ); + if ( !$data ) { + wfDebugLog( 'ResourceLoaderImage', __METHOD__ . " failed to rasterize for $path" ); + } + } + + return $data; + } + + /** + * Send response headers (using the header() function) that are necessary to correctly serve the + * image data for this image, as returned by getImageData(). + * + * Note that the headers are independent of the language or image variant. + * + * @param ResourceLoaderContext $context Image context + */ + public function sendResponseHeaders( ResourceLoaderContext $context ) { + $format = $context->getFormat(); + $mime = $this->getMimeType( $format ); + $filename = $this->getName() . '.' . $this->getExtension( $format ); + + header( 'Content-Type: ' . $mime ); + header( 'Content-Disposition: ' . + FileBackend::makeContentDisposition( 'inline', $filename ) ); + } + + /** + * Convert this image, which is assumed to be SVG, to given variant. + * + * @param array $variantConf Array with a 'color' key, its value will be used as fill color + * @param ResourceLoaderContext $context Image context + * @return string New SVG file data + */ + protected function variantize( $variantConf, ResourceLoaderContext $context ) { + $dom = new DomDocument; + $dom->loadXML( file_get_contents( $this->getPath( $context ) ) ); + $root = $dom->documentElement; + $wrapper = $dom->createElement( 'g' ); + while ( $root->firstChild ) { + $wrapper->appendChild( $root->firstChild ); + } + $root->appendChild( $wrapper ); + $wrapper->setAttribute( 'fill', $variantConf['color'] ); + return $dom->saveXML(); + } + + /** + * Massage the SVG image data for converters which don't understand some path data syntax. + * + * This is necessary for rsvg and ImageMagick when compiled with rsvg support. + * Upstream bug is https://bugzilla.gnome.org/show_bug.cgi?id=620923, fixed 2014-11-10, so + * this will be needed for a while. (T76852) + * + * @param string $svg SVG image data + * @return string Massaged SVG image data + */ + protected function massageSvgPathdata( $svg ) { + $dom = new DomDocument; + $dom->loadXML( $svg ); + foreach ( $dom->getElementsByTagName( 'path' ) as $node ) { + $pathData = $node->getAttribute( 'd' ); + // Make sure there is at least one space between numbers, and that leading zero is not omitted. + // rsvg has issues with syntax like "M-1-2" and "M.445.483" and especially "M-.445-.483". + $pathData = preg_replace( '/(-?)(\d*\.\d+|\d+)/', ' ${1}0$2 ', $pathData ); + // Strip unnecessary leading zeroes for prettiness, not strictly necessary + $pathData = preg_replace( '/([ -])0(\d)/', '$1$2', $pathData ); + $node->setAttribute( 'd', $pathData ); + } + return $dom->saveXML(); + } + + /** + * Convert passed image data, which is assumed to be SVG, to PNG. + * + * @param string $svg SVG image data + * @return string|bool PNG image data, or false on failure + */ + protected function rasterize( $svg ) { + /** + * This code should be factored out to a separate method on SvgHandler, or perhaps a separate + * class, with a separate set of configuration settings. + * + * This is a distinct use case from regular SVG rasterization: + * * We can skip many sanity and security checks (as the images come from a trusted source, + * rather than from the user). + * * We need to provide extra options to some converters to achieve acceptable quality for very + * small images, which might cause performance issues in the general case. + * * We want to directly pass image data to the converter, rather than a file path. + * + * See https://phabricator.wikimedia.org/T76473#801446 for examples of what happens with the + * default settings. + * + * For now, we special-case rsvg (used in WMF production) and do a messy workaround for other + * converters. + */ + + global $wgSVGConverter, $wgSVGConverterPath; + + $svg = $this->massageSvgPathdata( $svg ); + + // Sometimes this might be 'rsvg-secure'. Long as it's rsvg. + if ( strpos( $wgSVGConverter, 'rsvg' ) === 0 ) { + $command = 'rsvg-convert'; + if ( $wgSVGConverterPath ) { + $command = wfEscapeShellArg( "$wgSVGConverterPath/" ) . $command; + } + + $process = proc_open( + $command, + [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ], + $pipes + ); + + if ( is_resource( $process ) ) { + fwrite( $pipes[0], $svg ); + fclose( $pipes[0] ); + $png = stream_get_contents( $pipes[1] ); + fclose( $pipes[1] ); + proc_close( $process ); + + return $png ?: false; + } + return false; + + } else { + // Write input to and read output from a temporary file + $tempFilenameSvg = tempnam( wfTempDir(), 'ResourceLoaderImage' ); + $tempFilenamePng = tempnam( wfTempDir(), 'ResourceLoaderImage' ); + + file_put_contents( $tempFilenameSvg, $svg ); + + $metadata = SVGMetadataExtractor::getMetadata( $tempFilenameSvg ); + if ( !isset( $metadata['width'] ) || !isset( $metadata['height'] ) ) { + unlink( $tempFilenameSvg ); + return false; + } + + $handler = new SvgHandler; + $res = $handler->rasterize( + $tempFilenameSvg, + $tempFilenamePng, + $metadata['width'], + $metadata['height'] + ); + unlink( $tempFilenameSvg ); + + $png = null; + if ( $res === true ) { + $png = file_get_contents( $tempFilenamePng ); + unlink( $tempFilenamePng ); + } + + return $png ?: false; + } + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php b/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php new file mode 100644 index 00000000..5e329e84 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderImageModule.php @@ -0,0 +1,471 @@ +<?php +/** + * ResourceLoader module for generated and embedded images. + * + * 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 Trevor Parscal + */ + +/** + * ResourceLoader module for generated and embedded images. + * + * @since 1.25 + */ +class ResourceLoaderImageModule extends ResourceLoaderModule { + + protected $definition = null; + + /** + * Local base path, see __construct() + * @var string + */ + protected $localBasePath = ''; + + protected $origin = self::ORIGIN_CORE_SITEWIDE; + + protected $images = []; + protected $variants = []; + protected $prefix = null; + protected $selectorWithoutVariant = '.{prefix}-{name}'; + protected $selectorWithVariant = '.{prefix}-{name}-{variant}'; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Constructs a new module from an options array. + * + * @param array $options List of options; if not given or empty, an empty module will be + * constructed + * @param string $localBasePath Base path to prepend to all local paths in $options. Defaults + * to $IP + * + * Below is a description for the $options array: + * @par Construction options: + * @code + * [ + * // Base path to prepend to all local paths in $options. Defaults to $IP + * 'localBasePath' => [base path], + * // Path to JSON file that contains any of the settings below + * 'data' => [file path string] + * // CSS class prefix to use in all style rules + * 'prefix' => [CSS class prefix], + * // Alternatively: Format of CSS selector to use in all style rules + * 'selector' => [CSS selector template, variables: {prefix} {name} {variant}], + * // Alternatively: When using variants + * 'selectorWithoutVariant' => [CSS selector template, variables: {prefix} {name}], + * 'selectorWithVariant' => [CSS selector template, variables: {prefix} {name} {variant}], + * // List of variants that may be used for the image files + * 'variants' => [ + * // This level of nesting can be omitted if you use the same images for every skin + * [skin name (or 'default')] => [ + * [variant name] => [ + * 'color' => [color string, e.g. '#ffff00'], + * 'global' => [boolean, if true, this variant is available + * for all images of this type], + * ], + * ... + * ], + * ... + * ], + * // List of image files and their options + * 'images' => [ + * // This level of nesting can be omitted if you use the same images for every skin + * [skin name (or 'default')] => [ + * [icon name] => [ + * 'file' => [file path string or array whose values are file path strings + * and whose keys are 'default', 'ltr', 'rtl', a single + * language code like 'en', or a list of language codes like + * 'en,de,ar'], + * 'variants' => [array of variant name strings, variants + * available for this image], + * ], + * ... + * ], + * ... + * ], + * ] + * @endcode + * @throws InvalidArgumentException + */ + public function __construct( $options = [], $localBasePath = null ) { + $this->localBasePath = self::extractLocalBasePath( $options, $localBasePath ); + + $this->definition = $options; + } + + /** + * Parse definition and external JSON data, if referenced. + */ + protected function loadFromDefinition() { + if ( $this->definition === null ) { + return; + } + + $options = $this->definition; + $this->definition = null; + + if ( isset( $options['data'] ) ) { + $dataPath = $this->localBasePath . '/' . $options['data']; + $data = json_decode( file_get_contents( $dataPath ), true ); + $options = array_merge( $data, $options ); + } + + // Accepted combinations: + // * prefix + // * selector + // * selectorWithoutVariant + selectorWithVariant + // * prefix + selector + // * prefix + selectorWithoutVariant + selectorWithVariant + + $prefix = isset( $options['prefix'] ) && $options['prefix']; + $selector = isset( $options['selector'] ) && $options['selector']; + $selectorWithoutVariant = isset( $options['selectorWithoutVariant'] ) + && $options['selectorWithoutVariant']; + $selectorWithVariant = isset( $options['selectorWithVariant'] ) + && $options['selectorWithVariant']; + + if ( $selectorWithoutVariant && !$selectorWithVariant ) { + throw new InvalidArgumentException( + "Given 'selectorWithoutVariant' but no 'selectorWithVariant'." + ); + } + if ( $selectorWithVariant && !$selectorWithoutVariant ) { + throw new InvalidArgumentException( + "Given 'selectorWithVariant' but no 'selectorWithoutVariant'." + ); + } + if ( $selector && $selectorWithVariant ) { + throw new InvalidArgumentException( + "Incompatible 'selector' and 'selectorWithVariant'+'selectorWithoutVariant' given." + ); + } + if ( !$prefix && !$selector && !$selectorWithVariant ) { + throw new InvalidArgumentException( + "None of 'prefix', 'selector' or 'selectorWithVariant'+'selectorWithoutVariant' given." + ); + } + + foreach ( $options as $member => $option ) { + switch ( $member ) { + case 'images': + case 'variants': + if ( !is_array( $option ) ) { + throw new InvalidArgumentException( + "Invalid list error. '$option' given, array expected." + ); + } + if ( !isset( $option['default'] ) ) { + // Backwards compatibility + $option = [ 'default' => $option ]; + } + foreach ( $option as $skin => $data ) { + if ( !is_array( $option ) ) { + throw new InvalidArgumentException( + "Invalid list error. '$option' given, array expected." + ); + } + } + $this->{$member} = $option; + break; + + case 'prefix': + case 'selectorWithoutVariant': + case 'selectorWithVariant': + $this->{$member} = (string)$option; + break; + + case 'selector': + $this->selectorWithoutVariant = $this->selectorWithVariant = (string)$option; + } + } + } + + /** + * Get CSS class prefix used by this module. + * @return string + */ + public function getPrefix() { + $this->loadFromDefinition(); + return $this->prefix; + } + + /** + * Get CSS selector templates used by this module. + * @return string + */ + public function getSelectors() { + $this->loadFromDefinition(); + return [ + 'selectorWithoutVariant' => $this->selectorWithoutVariant, + 'selectorWithVariant' => $this->selectorWithVariant, + ]; + } + + /** + * Get a ResourceLoaderImage object for given image. + * @param string $name Image name + * @param ResourceLoaderContext $context + * @return ResourceLoaderImage|null + */ + public function getImage( $name, ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $images = $this->getImages( $context ); + return isset( $images[$name] ) ? $images[$name] : null; + } + + /** + * Get ResourceLoaderImage objects for all images. + * @param ResourceLoaderContext $context + * @return ResourceLoaderImage[] Array keyed by image name + */ + public function getImages( ResourceLoaderContext $context ) { + $skin = $context->getSkin(); + if ( !isset( $this->imageObjects ) ) { + $this->loadFromDefinition(); + $this->imageObjects = []; + } + if ( !isset( $this->imageObjects[$skin] ) ) { + $this->imageObjects[$skin] = []; + if ( !isset( $this->images[$skin] ) ) { + $this->images[$skin] = isset( $this->images['default'] ) ? + $this->images['default'] : + []; + } + foreach ( $this->images[$skin] as $name => $options ) { + $fileDescriptor = is_string( $options ) ? $options : $options['file']; + + $allowedVariants = array_merge( + ( is_array( $options ) && isset( $options['variants'] ) ) ? $options['variants'] : [], + $this->getGlobalVariants( $context ) + ); + if ( isset( $this->variants[$skin] ) ) { + $variantConfig = array_intersect_key( + $this->variants[$skin], + array_fill_keys( $allowedVariants, true ) + ); + } else { + $variantConfig = []; + } + + $image = new ResourceLoaderImage( + $name, + $this->getName(), + $fileDescriptor, + $this->localBasePath, + $variantConfig + ); + $this->imageObjects[$skin][$image->getName()] = $image; + } + } + + return $this->imageObjects[$skin]; + } + + /** + * Get list of variants in this module that are 'global', i.e., available + * for every image regardless of image options. + * @param ResourceLoaderContext $context + * @return string[] + */ + public function getGlobalVariants( ResourceLoaderContext $context ) { + $skin = $context->getSkin(); + if ( !isset( $this->globalVariants ) ) { + $this->loadFromDefinition(); + $this->globalVariants = []; + } + if ( !isset( $this->globalVariants[$skin] ) ) { + $this->globalVariants[$skin] = []; + if ( !isset( $this->variants[$skin] ) ) { + $this->variants[$skin] = isset( $this->variants['default'] ) ? + $this->variants['default'] : + []; + } + foreach ( $this->variants[$skin] as $name => $config ) { + if ( isset( $config['global'] ) && $config['global'] ) { + $this->globalVariants[$skin][] = $name; + } + } + } + + return $this->globalVariants[$skin]; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getStyles( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + + // Build CSS rules + $rules = []; + $script = $context->getResourceLoader()->getLoadScript( $this->getSource() ); + $selectors = $this->getSelectors(); + + foreach ( $this->getImages( $context ) as $name => $image ) { + $declarations = $this->getStyleDeclarations( $context, $image, $script ); + $selector = strtr( + $selectors['selectorWithoutVariant'], + [ + '{prefix}' => $this->getPrefix(), + '{name}' => $name, + '{variant}' => '', + ] + ); + $rules[] = "$selector {\n\t$declarations\n}"; + + foreach ( $image->getVariants() as $variant ) { + $declarations = $this->getStyleDeclarations( $context, $image, $script, $variant ); + $selector = strtr( + $selectors['selectorWithVariant'], + [ + '{prefix}' => $this->getPrefix(), + '{name}' => $name, + '{variant}' => $variant, + ] + ); + $rules[] = "$selector {\n\t$declarations\n}"; + } + } + + $style = implode( "\n", $rules ); + return [ 'all' => $style ]; + } + + /** + * @param ResourceLoaderContext $context + * @param ResourceLoaderImage $image Image to get the style for + * @param string $script URL to load.php + * @param string|null $variant Variant to get the style for + * @return string + */ + private function getStyleDeclarations( + ResourceLoaderContext $context, + ResourceLoaderImage $image, + $script, + $variant = null + ) { + $imageDataUri = $image->getDataUri( $context, $variant, 'original' ); + $primaryUrl = $imageDataUri ?: $image->getUrl( $context, $script, $variant, 'original' ); + $declarations = $this->getCssDeclarations( + $primaryUrl, + $image->getUrl( $context, $script, $variant, 'rasterized' ) + ); + return implode( "\n\t", $declarations ); + } + + /** + * SVG support using a transparent gradient to guarantee cross-browser + * compatibility (browsers able to understand gradient syntax support also SVG). + * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique + * + * Keep synchronized with the .background-image-svg LESS mixin in + * /resources/src/mediawiki.less/mediawiki.mixins.less. + * + * @param string $primary Primary URI + * @param string $fallback Fallback URI + * @return string[] CSS declarations to use given URIs as background-image + */ + protected function getCssDeclarations( $primary, $fallback ) { + $primaryUrl = CSSMin::buildUrlValue( $primary ); + $fallbackUrl = CSSMin::buildUrlValue( $fallback ); + return [ + "background-image: $fallbackUrl;", + "background-image: linear-gradient(transparent, transparent), $primaryUrl;", + ]; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } + + /** + * Get the definition summary for this module. + * + * @param ResourceLoaderContext $context + * @return array + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $summary = parent::getDefinitionSummary( $context ); + + $options = []; + foreach ( [ + 'localBasePath', + 'images', + 'variants', + 'prefix', + 'selectorWithoutVariant', + 'selectorWithVariant', + ] as $member ) { + $options[$member] = $this->{$member}; + }; + + $summary[] = [ + 'options' => $options, + 'fileHashes' => $this->getFileHashes( $context ), + ]; + return $summary; + } + + /** + * Helper method for getDefinitionSummary. + * @param ResourceLoaderContext $context + * @return array + */ + protected function getFileHashes( ResourceLoaderContext $context ) { + $this->loadFromDefinition(); + $files = []; + foreach ( $this->getImages( $context ) as $name => $image ) { + $files[] = $image->getPath( $context ); + } + $files = array_values( array_unique( $files ) ); + return array_map( [ __CLASS__, 'safeFileHash' ], $files ); + } + + /** + * Extract a local base path from module definition information. + * + * @param array $options Module definition + * @param string $localBasePath Path to use if not provided in module definition. Defaults + * to $IP + * @return string Local base path + */ + public static function extractLocalBasePath( $options, $localBasePath = null ) { + global $IP; + + if ( $localBasePath === null ) { + $localBasePath = $IP; + } + + if ( array_key_exists( 'localBasePath', $options ) ) { + $localBasePath = (string)$options['localBasePath']; + } + + return $localBasePath; + } + + /** + * @return string + */ + public function getType() { + return self::LOAD_STYLES; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php b/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php new file mode 100644 index 00000000..bef34f99 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderJqueryMsgModule.php @@ -0,0 +1,82 @@ +<?php +/** + * ResourceLoader module for mediawiki.jqueryMsg that provides generated data. + * + * 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 + */ + +/** + * ResourceLoader module for mediawiki.jqueryMsg and its generated data + */ +class ResourceLoaderJqueryMsgModule extends ResourceLoaderFileModule { + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + $fileScript = parent::getScript( $context ); + + $tagData = Sanitizer::getRecognizedTagData(); + $parserDefaults = []; + $parserDefaults['allowedHtmlElements'] = array_merge( + array_keys( $tagData['htmlpairs'] ), + array_diff( + array_keys( $tagData['htmlsingle'] ), + array_keys( $tagData['htmlsingleonly'] ) + ) + ); + + $mainDataScript = Xml::encodeJsCall( 'mw.jqueryMsg.setParserDefaults', [ $parserDefaults ] ); + + // Associative array mapping magic words (e.g. SITENAME) + // to their values. + $magicWords = [ + 'SITENAME' => $this->getConfig()->get( 'Sitename' ), + ]; + + Hooks::run( 'ResourceLoaderJqueryMsgModuleMagicWords', [ $context, &$magicWords ] ); + + $magicWordExtendData = [ + 'magic' => $magicWords, + ]; + + $magicWordDataScript = Xml::encodeJsCall( 'mw.jqueryMsg.setParserDefaults', [ + $magicWordExtendData, + /* deep= */ true + ] ); + + return $fileScript . $mainDataScript . $magicWordDataScript; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getScriptURLsForDebug( ResourceLoaderContext $context ) { + // Bypass file module urls + return ResourceLoaderModule::getScriptURLsForDebug( $context ); + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php b/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php new file mode 100644 index 00000000..e78484a2 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderLanguageDataModule.php @@ -0,0 +1,81 @@ +<?php +/** + * ResourceLoader module for populating language specific data. + * + * 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 Santhosh Thottingal + * @author Timo Tijhof + */ + +/** + * ResourceLoader module for populating language specific data. + */ +class ResourceLoaderLanguageDataModule extends ResourceLoaderModule { + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Get all the dynamic data for the content language to an array. + * + * @param ResourceLoaderContext $context + * @return array + */ + protected function getData( ResourceLoaderContext $context ) { + $language = Language::factory( $context->getLanguage() ); + return [ + 'digitTransformTable' => $language->digitTransformTable(), + 'separatorTransformTable' => $language->separatorTransformTable(), + 'minimumGroupingDigits' => $language->minimumGroupingDigits(), + 'grammarForms' => $language->getGrammarForms(), + 'grammarTransformations' => $language->getGrammarTransformations(), + 'pluralRules' => $language->getPluralRules(), + 'digitGroupingPattern' => $language->digitGroupingPattern(), + 'fallbackLanguages' => $language->getFallbackLanguages(), + ]; + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + return Xml::encodeJsCall( + 'mw.language.setData', + [ + $context->getLanguage(), + $this->getData( $context ) + ], + ResourceLoader::inDebugMode() + ); + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'mediawiki.language.init' ]; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php new file mode 100644 index 00000000..57260ba5 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderLanguageNamesModule.php @@ -0,0 +1,77 @@ +<?php +/** + * ResourceLoader module for providing language names. + * + * By default these names will be autonyms however other extensions may + * provided language names in the context language (e.g. cldr extension) + * + * 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 Ed Sanders + * @author Trevor Parscal + */ + +/** + * ResourceLoader module for populating language specific data. + */ +class ResourceLoaderLanguageNamesModule extends ResourceLoaderModule { + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array + */ + protected function getData( ResourceLoaderContext $context ) { + return Language::fetchLanguageNames( + $context->getLanguage(), + 'all' + ); + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + return Xml::encodeJsCall( + 'mw.language.setData', + [ + $context->getLanguage(), + 'languageNames', + $this->getData( $context ) + ], + ResourceLoader::inDebugMode() + ); + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'mediawiki.language.init' ]; + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php b/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php new file mode 100644 index 00000000..d16a4ff7 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php @@ -0,0 +1,53 @@ +<?php +/** + * ResourceLoader mediawiki.util module + * + * 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 + */ + +/** + * ResourceLoader module for mediawiki.util + * + * @since 1.30 + */ +class ResourceLoaderMediaWikiUtilModule extends ResourceLoaderFileModule { + /** + * @inheritDoc + */ + public function getScript( ResourceLoaderContext $context ) { + return ResourceLoader::makeConfigSetScript( + [ 'wgFragmentMode' => $this->getConfig()->get( 'FragmentMode' ) ] + ) + . "\n" + . parent::getScript( $context ); + } + + /** + * @inheritDoc + */ + public function supportsURLLoading() { + return false; + } + + /** + * @inheritDoc + */ + public function enableModuleContentVersion() { + return true; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderModule.php b/www/wiki/includes/resourceloader/ResourceLoaderModule.php new file mode 100644 index 00000000..2abc17c2 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderModule.php @@ -0,0 +1,1044 @@ +<?php +/** + * Abstraction for ResourceLoader modules. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Wikimedia\RelPath; +use Wikimedia\ScopedCallback; + +/** + * Abstraction for ResourceLoader modules, with name registration and maxage functionality. + */ +abstract class ResourceLoaderModule implements LoggerAwareInterface { + # Type of resource + const TYPE_SCRIPTS = 'scripts'; + const TYPE_STYLES = 'styles'; + const TYPE_COMBINED = 'combined'; + + # Desired load type + // Module only has styles (loaded via <style> or <link rel=stylesheet>) + const LOAD_STYLES = 'styles'; + // Module may have other resources (loaded via mw.loader from a script) + const LOAD_GENERAL = 'general'; + + # sitewide core module like a skin file or jQuery component + const ORIGIN_CORE_SITEWIDE = 1; + + # per-user module generated by the software + const ORIGIN_CORE_INDIVIDUAL = 2; + + # sitewide module generated from user-editable files, like MediaWiki:Common.js, or + # modules accessible to multiple users, such as those generated by the Gadgets extension. + const ORIGIN_USER_SITEWIDE = 3; + + # per-user module generated from user-editable files, like User:Me/vector.js + const ORIGIN_USER_INDIVIDUAL = 4; + + # an access constant; make sure this is kept as the largest number in this group + const ORIGIN_ALL = 10; + + # script and style modules form a hierarchy of trustworthiness, with core modules like + # skins and jQuery as most trustworthy, and user scripts as least trustworthy. We can + # limit the types of scripts and styles we allow to load on, say, sensitive special + # pages like Special:UserLogin and Special:Preferences + protected $origin = self::ORIGIN_CORE_SITEWIDE; + + protected $name = null; + protected $targets = [ 'desktop' ]; + + // In-object cache for file dependencies + protected $fileDeps = []; + // In-object cache for message blob (keyed by language) + protected $msgBlobs = []; + // In-object cache for version hash + protected $versionHash = []; + // In-object cache for module content + protected $contents = []; + + /** + * @var Config + */ + protected $config; + + /** + * @var array|bool + */ + protected $deprecated = false; + + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * Get this module's name. This is set when the module is registered + * with ResourceLoader::register() + * + * @return string|null Name (string) or null if no name was set + */ + public function getName() { + return $this->name; + } + + /** + * Set this module's name. This is called by ResourceLoader::register() + * when registering the module. Other code should not call this. + * + * @param string $name + */ + public function setName( $name ) { + $this->name = $name; + } + + /** + * Get this module's origin. This is set when the module is registered + * with ResourceLoader::register() + * + * @return int ResourceLoaderModule class constant, the subclass default + * if not set manually + */ + public function getOrigin() { + return $this->origin; + } + + /** + * @param ResourceLoaderContext $context + * @return bool + */ + public function getFlip( $context ) { + global $wgContLang; + + return $wgContLang->getDir() !== $context->getDirection(); + } + + /** + * Get JS representing deprecation information for the current module if available + * + * @return string JavaScript code + */ + protected function getDeprecationInformation() { + $deprecationInfo = $this->deprecated; + if ( $deprecationInfo ) { + $name = $this->getName(); + $warning = 'This page is using the deprecated ResourceLoader module "' . $name . '".'; + if ( is_string( $deprecationInfo ) ) { + $warning .= "\n" . $deprecationInfo; + } + return Xml::encodeJsCall( + 'mw.log.warn', + [ $warning ] + ); + } else { + return ''; + } + } + + /** + * Get all JS for this module for a given language and skin. + * Includes all relevant JS except loader scripts. + * + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + // Stub, override expected + return ''; + } + + /** + * Takes named templates by the module and returns an array mapping. + * + * @return array of templates mapping template alias to content + */ + public function getTemplates() { + // Stub, override expected. + return []; + } + + /** + * @return Config + * @since 1.24 + */ + public function getConfig() { + if ( $this->config === null ) { + // Ugh, fall back to default + $this->config = MediaWikiServices::getInstance()->getMainConfig(); + } + + return $this->config; + } + + /** + * @param Config $config + * @since 1.24 + */ + public function setConfig( Config $config ) { + $this->config = $config; + } + + /** + * @since 1.27 + * @param LoggerInterface $logger + * @return null + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @since 1.27 + * @return LoggerInterface + */ + protected function getLogger() { + if ( !$this->logger ) { + $this->logger = new NullLogger(); + } + return $this->logger; + } + + /** + * Get the URL or URLs to load for this module's JS in debug mode. + * The default behavior is to return a load.php?only=scripts URL for + * the module, but file-based modules will want to override this to + * load the files directly. + * + * This function is called only when 1) we're in debug mode, 2) there + * is no only= parameter and 3) supportsURLLoading() returns true. + * #2 is important to prevent an infinite loop, therefore this function + * MUST return either an only= URL or a non-load.php URL. + * + * @param ResourceLoaderContext $context + * @return array Array of URLs + */ + public function getScriptURLsForDebug( ResourceLoaderContext $context ) { + $resourceLoader = $context->getResourceLoader(); + $derivative = new DerivativeResourceLoaderContext( $context ); + $derivative->setModules( [ $this->getName() ] ); + $derivative->setOnly( 'scripts' ); + $derivative->setDebug( true ); + + $url = $resourceLoader->createLoaderURL( + $this->getSource(), + $derivative + ); + + return [ $url ]; + } + + /** + * Whether this module supports URL loading. If this function returns false, + * getScript() will be used even in cases (debug mode, no only param) where + * getScriptURLsForDebug() would normally be used instead. + * @return bool + */ + public function supportsURLLoading() { + return true; + } + + /** + * Get all CSS for this module for a given skin. + * + * @param ResourceLoaderContext $context + * @return array List of CSS strings or array of CSS strings keyed by media type. + * like [ 'screen' => '.foo { width: 0 }' ]; + * or [ 'screen' => [ '.foo { width: 0 }' ] ]; + */ + public function getStyles( ResourceLoaderContext $context ) { + // Stub, override expected + return []; + } + + /** + * Get the URL or URLs to load for this module's CSS in debug mode. + * The default behavior is to return a load.php?only=styles URL for + * the module, but file-based modules will want to override this to + * load the files directly. See also getScriptURLsForDebug() + * + * @param ResourceLoaderContext $context + * @return array [ mediaType => [ URL1, URL2, ... ], ... ] + */ + public function getStyleURLsForDebug( ResourceLoaderContext $context ) { + $resourceLoader = $context->getResourceLoader(); + $derivative = new DerivativeResourceLoaderContext( $context ); + $derivative->setModules( [ $this->getName() ] ); + $derivative->setOnly( 'styles' ); + $derivative->setDebug( true ); + + $url = $resourceLoader->createLoaderURL( + $this->getSource(), + $derivative + ); + + return [ 'all' => [ $url ] ]; + } + + /** + * Get the messages needed for this module. + * + * To get a JSON blob with messages, use MessageBlobStore::get() + * + * @return array List of message keys. Keys may occur more than once + */ + public function getMessages() { + // Stub, override expected + return []; + } + + /** + * Get the group this module is in. + * + * @return string Group name + */ + public function getGroup() { + // Stub, override expected + return null; + } + + /** + * Get the origin of this module. Should only be overridden for foreign modules. + * + * @return string Origin name, 'local' for local modules + */ + public function getSource() { + // Stub, override expected + return 'local'; + } + + /** + * Whether this module's JS expects to work without the client-side ResourceLoader module. + * Returning true from this function will prevent mw.loader.state() call from being + * appended to the bottom of the script. + * + * @return bool + */ + public function isRaw() { + return false; + } + + /** + * Get a list of modules this module depends on. + * + * Dependency information is taken into account when loading a module + * on the client side. + * + * Note: It is expected that $context will be made non-optional in the near + * future. + * + * @param ResourceLoaderContext $context + * @return array List of module names as strings + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + // Stub, override expected + return []; + } + + /** + * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile'] + * + * @return array Array of strings + */ + public function getTargets() { + return $this->targets; + } + + /** + * Get the module's load type. + * + * @since 1.28 + * @return string ResourceLoaderModule LOAD_* constant + */ + public function getType() { + return self::LOAD_GENERAL; + } + + /** + * Get the skip function. + * + * Modules that provide fallback functionality can provide a "skip function". This + * function, if provided, will be passed along to the module registry on the client. + * When this module is loaded (either directly or as a dependency of another module), + * then this function is executed first. If the function returns true, the module will + * instantly be considered "ready" without requesting the associated module resources. + * + * The value returned here must be valid javascript for execution in a private function. + * It must not contain the "function () {" and "}" wrapper though. + * + * @return string|null A JavaScript function body returning a boolean value, or null + */ + public function getSkipFunction() { + return null; + } + + /** + * Get the files this module depends on indirectly for a given skin. + * + * These are only image files referenced by the module's stylesheet. + * + * @param ResourceLoaderContext $context + * @return array List of files + */ + protected function getFileDependencies( ResourceLoaderContext $context ) { + $vary = $context->getSkin() . '|' . $context->getLanguage(); + + // Try in-object cache first + if ( !isset( $this->fileDeps[$vary] ) ) { + $dbr = wfGetDB( DB_REPLICA ); + $deps = $dbr->selectField( 'module_deps', + 'md_deps', + [ + 'md_module' => $this->getName(), + 'md_skin' => $vary, + ], + __METHOD__ + ); + + if ( !is_null( $deps ) ) { + $this->fileDeps[$vary] = self::expandRelativePaths( + (array)FormatJson::decode( $deps, true ) + ); + } else { + $this->fileDeps[$vary] = []; + } + } + return $this->fileDeps[$vary]; + } + + /** + * Set in-object cache for file dependencies. + * + * This is used to retrieve data in batches. See ResourceLoader::preloadModuleInfo(). + * To save the data, use saveFileDependencies(). + * + * @param ResourceLoaderContext $context + * @param string[] $files Array of file names + */ + public function setFileDependencies( ResourceLoaderContext $context, $files ) { + $vary = $context->getSkin() . '|' . $context->getLanguage(); + $this->fileDeps[$vary] = $files; + } + + /** + * Set the files this module depends on indirectly for a given skin. + * + * @since 1.27 + * @param ResourceLoaderContext $context + * @param array $localFileRefs List of files + */ + protected function saveFileDependencies( ResourceLoaderContext $context, $localFileRefs ) { + try { + // Related bugs and performance considerations: + // 1. Don't needlessly change the database value with the same list in a + // different order or with duplicates. + // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481) + // 3. Don't needlessly replace the database with the same value + // just because $IP changed (e.g. when upgrading a wiki). + // 4. Don't create an endless replace loop on every request for this + // module when '../' is used anywhere. Even though both are expanded + // (one expanded by getFileDependencies from the DB, the other is + // still raw as originally read by RL), the latter has not + // been normalized yet. + + // Normalise + $localFileRefs = array_values( array_unique( $localFileRefs ) ); + sort( $localFileRefs ); + $localPaths = self::getRelativePaths( $localFileRefs ); + + $storedPaths = self::getRelativePaths( $this->getFileDependencies( $context ) ); + // If the list has been modified since last time we cached it, update the cache + if ( $localPaths !== $storedPaths ) { + $vary = $context->getSkin() . '|' . $context->getLanguage(); + $cache = ObjectCache::getLocalClusterInstance(); + $key = $cache->makeKey( __METHOD__, $this->getName(), $vary ); + $scopeLock = $cache->getScopedLock( $key, 0 ); + if ( !$scopeLock ) { + return; // T124649; avoid write slams + } + + $deps = FormatJson::encode( $localPaths ); + $dbw = wfGetDB( DB_MASTER ); + $dbw->upsert( 'module_deps', + [ + 'md_module' => $this->getName(), + 'md_skin' => $vary, + 'md_deps' => $deps, + ], + [ [ 'md_module', 'md_skin' ] ], + [ + 'md_deps' => $deps, + ] + ); + + if ( $dbw->trxLevel() ) { + $dbw->onTransactionResolution( + function () use ( &$scopeLock ) { + ScopedCallback::consume( $scopeLock ); // release after commit + }, + __METHOD__ + ); + } + } + } catch ( Exception $e ) { + wfDebugLog( 'resourceloader', __METHOD__ . ": failed to update DB: $e" ); + } + } + + /** + * Make file paths relative to MediaWiki directory. + * + * This is used to make file paths safe for storing in a database without the paths + * becoming stale or incorrect when MediaWiki is moved or upgraded (T111481). + * + * @since 1.27 + * @param array $filePaths + * @return array + */ + public static function getRelativePaths( array $filePaths ) { + global $IP; + return array_map( function ( $path ) use ( $IP ) { + return RelPath::getRelativePath( $path, $IP ); + }, $filePaths ); + } + + /** + * Expand directories relative to $IP. + * + * @since 1.27 + * @param array $filePaths + * @return array + */ + public static function expandRelativePaths( array $filePaths ) { + global $IP; + return array_map( function ( $path ) use ( $IP ) { + return RelPath::joinPath( $IP, $path ); + }, $filePaths ); + } + + /** + * Get the hash of the message blob. + * + * @since 1.27 + * @param ResourceLoaderContext $context + * @return string|null JSON blob or null if module has no messages + */ + protected function getMessageBlob( ResourceLoaderContext $context ) { + if ( !$this->getMessages() ) { + // Don't bother consulting MessageBlobStore + return null; + } + // Message blobs may only vary language, not by context keys + $lang = $context->getLanguage(); + if ( !isset( $this->msgBlobs[$lang] ) ) { + $this->getLogger()->warning( 'Message blob for {module} should have been preloaded', [ + 'module' => $this->getName(), + ] ); + $store = $context->getResourceLoader()->getMessageBlobStore(); + $this->msgBlobs[$lang] = $store->getBlob( $this, $lang ); + } + return $this->msgBlobs[$lang]; + } + + /** + * Set in-object cache for message blobs. + * + * Used to allow fetching of message blobs in batches. See ResourceLoader::preloadModuleInfo(). + * + * @since 1.27 + * @param string|null $blob JSON blob or null + * @param string $lang Language code + */ + public function setMessageBlob( $blob, $lang ) { + $this->msgBlobs[$lang] = $blob; + } + + /** + * Get headers to send as part of a module web response. + * + * It is not supported to send headers through this method that are + * required to be unique or otherwise sent once in an HTTP response + * because clients may make batch requests for multiple modules (as + * is the default behaviour for ResourceLoader clients). + * + * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders(). + * + * @since 1.30 + * @param ResourceLoaderContext $context + * @return string[] Array of HTTP response headers + */ + final public function getHeaders( ResourceLoaderContext $context ) { + $headers = []; + + $formattedLinks = []; + foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) { + $link = "<{$url}>;rel=preload"; + foreach ( $attribs as $key => $val ) { + $link .= ";{$key}={$val}"; + } + $formattedLinks[] = $link; + } + if ( $formattedLinks ) { + $headers[] = 'Link: ' . implode( ',', $formattedLinks ); + } + + return $headers; + } + + /** + * Get a list of resources that web browsers may preload. + * + * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>. + * + * Use case for ResourceLoader originally part of T164299. + * + * @par Example + * @code + * protected function getPreloadLinks() { + * return [ + * 'https://example.org/script.js' => [ 'as' => 'script' ], + * 'https://example.org/image.png' => [ 'as' => 'image' ], + * ]; + * } + * @endcode + * + * @par Example using HiDPI image variants + * @code + * protected function getPreloadLinks() { + * return [ + * 'https://example.org/logo.png' => [ + * 'as' => 'image', + * 'media' => 'not all and (min-resolution: 2dppx)', + * ], + * 'https://example.org/logo@2x.png' => [ + * 'as' => 'image', + * 'media' => '(min-resolution: 2dppx)', + * ], + * ]; + * } + * @endcode + * + * @see ResourceLoaderModule::getHeaders + * @since 1.30 + * @param ResourceLoaderContext $context + * @return array Keyed by url, values must be an array containing + * at least an 'as' key. Optionally a 'media' key as well. + */ + protected function getPreloadLinks( ResourceLoaderContext $context ) { + return []; + } + + /** + * Get module-specific LESS variables, if any. + * + * @since 1.27 + * @param ResourceLoaderContext $context + * @return array Module-specific LESS variables. + */ + protected function getLessVars( ResourceLoaderContext $context ) { + return []; + } + + /** + * Get an array of this module's resources. Ready for serving to the web. + * + * @since 1.26 + * @param ResourceLoaderContext $context + * @return array + */ + public function getModuleContent( ResourceLoaderContext $context ) { + $contextHash = $context->getHash(); + // Cache this expensive operation. This calls builds the scripts, styles, and messages + // content which typically involves filesystem and/or database access. + if ( !array_key_exists( $contextHash, $this->contents ) ) { + $this->contents[$contextHash] = $this->buildContent( $context ); + } + return $this->contents[$contextHash]; + } + + /** + * Bundle all resources attached to this module into an array. + * + * @since 1.26 + * @param ResourceLoaderContext $context + * @return array + */ + final protected function buildContent( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $statStart = microtime( true ); + + // Only include properties that are relevant to this context (e.g. only=scripts) + // and that are non-empty (e.g. don't include "templates" for modules without + // templates). This helps prevent invalidating cache for all modules when new + // optional properties are introduced. + $content = []; + + // Scripts + if ( $context->shouldIncludeScripts() ) { + // If we are in debug mode, we'll want to return an array of URLs if possible + // However, we can't do this if the module doesn't support it + // We also can't do this if there is an only= parameter, because we have to give + // the module a way to return a load.php URL without causing an infinite loop + if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) { + $scripts = $this->getScriptURLsForDebug( $context ); + } else { + $scripts = $this->getScript( $context ); + // Make the script safe to concatenate by making sure there is at least one + // trailing new line at the end of the content. Previously, this looked for + // a semi-colon instead, but that breaks concatenation if the semicolon + // is inside a comment like "// foo();". Instead, simply use a + // line break as separator which matches JavaScript native logic for implicitly + // ending statements even if a semi-colon is missing. + // Bugs: T29054, T162719. + if ( is_string( $scripts ) + && strlen( $scripts ) + && substr( $scripts, -1 ) !== "\n" + ) { + $scripts .= "\n"; + } + } + $content['scripts'] = $scripts; + } + + // Styles + if ( $context->shouldIncludeStyles() ) { + $styles = []; + // Don't create empty stylesheets like [ '' => '' ] for modules + // that don't *have* any stylesheets (T40024). + $stylePairs = $this->getStyles( $context ); + if ( count( $stylePairs ) ) { + // If we are in debug mode without &only= set, we'll want to return an array of URLs + // See comment near shouldIncludeScripts() for more details + if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) { + $styles = [ + 'url' => $this->getStyleURLsForDebug( $context ) + ]; + } else { + // Minify CSS before embedding in mw.loader.implement call + // (unless in debug mode) + if ( !$context->getDebug() ) { + foreach ( $stylePairs as $media => $style ) { + // Can be either a string or an array of strings. + if ( is_array( $style ) ) { + $stylePairs[$media] = []; + foreach ( $style as $cssText ) { + if ( is_string( $cssText ) ) { + $stylePairs[$media][] = + ResourceLoader::filter( 'minify-css', $cssText ); + } + } + } elseif ( is_string( $style ) ) { + $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style ); + } + } + } + // Wrap styles into @media groups as needed and flatten into a numerical array + $styles = [ + 'css' => $rl->makeCombinedStyles( $stylePairs ) + ]; + } + } + $content['styles'] = $styles; + } + + // Messages + $blob = $this->getMessageBlob( $context ); + if ( $blob ) { + $content['messagesBlob'] = $blob; + } + + $templates = $this->getTemplates(); + if ( $templates ) { + $content['templates'] = $templates; + } + + $headers = $this->getHeaders( $context ); + if ( $headers ) { + $content['headers'] = $headers; + } + + $statTiming = microtime( true ) - $statStart; + $statName = strtr( $this->getName(), '.', '_' ); + $stats->timing( "resourceloader_build.all", 1000 * $statTiming ); + $stats->timing( "resourceloader_build.$statName", 1000 * $statTiming ); + + return $content; + } + + /** + * Get a string identifying the current version of this module in a given context. + * + * Whenever anything happens that changes the module's response (e.g. scripts, styles, and + * messages) this value must change. This value is used to store module responses in cache. + * (Both client-side and server-side.) + * + * It is not recommended to override this directly. Use getDefinitionSummary() instead. + * If overridden, one must call the parent getVersionHash(), append data and re-hash. + * + * This method should be quick because it is frequently run by ResourceLoaderStartUpModule to + * propagate changes to the client and effectively invalidate cache. + * + * For backward-compatibility, the following optional data providers are automatically included: + * + * - getModifiedTime() + * - getModifiedHash() + * + * @since 1.26 + * @param ResourceLoaderContext $context + * @return string Hash (should use ResourceLoader::makeHash) + */ + public function getVersionHash( ResourceLoaderContext $context ) { + // The startup module produces a manifest with versions representing the entire module. + // Typically, the request for the startup module itself has only=scripts. That must apply + // only to the startup module content, and not to the module version computed here. + $context = new DerivativeResourceLoaderContext( $context ); + $context->setModules( [] ); + // Version hash must cover all resources, regardless of startup request itself. + $context->setOnly( null ); + // Compute version hash based on content, not debug urls. + $context->setDebug( false ); + + // Cache this somewhat expensive operation. Especially because some classes + // (e.g. startup module) iterate more than once over all modules to get versions. + $contextHash = $context->getHash(); + if ( !array_key_exists( $contextHash, $this->versionHash ) ) { + if ( $this->enableModuleContentVersion() ) { + // Detect changes directly + $str = json_encode( $this->getModuleContent( $context ) ); + } else { + // Infer changes based on definition and other metrics + $summary = $this->getDefinitionSummary( $context ); + if ( !isset( $summary['_cacheEpoch'] ) ) { + throw new LogicException( 'getDefinitionSummary must call parent method' ); + } + $str = json_encode( $summary ); + + $mtime = $this->getModifiedTime( $context ); + if ( $mtime !== null ) { + // Support: MediaWiki 1.25 and earlier + $str .= strval( $mtime ); + } + + $mhash = $this->getModifiedHash( $context ); + if ( $mhash !== null ) { + // Support: MediaWiki 1.25 and earlier + $str .= strval( $mhash ); + } + } + + $this->versionHash[$contextHash] = ResourceLoader::makeHash( $str ); + } + return $this->versionHash[$contextHash]; + } + + /** + * Whether to generate version hash based on module content. + * + * If a module requires database or file system access to build the module + * content, consider disabling this in favour of manually tracking relevant + * aspects in getDefinitionSummary(). See getVersionHash() for how this is used. + * + * @return bool + */ + public function enableModuleContentVersion() { + return false; + } + + /** + * Get the definition summary for this module. + * + * This is the method subclasses are recommended to use to track values in their + * version hash. Call this in getVersionHash() and pass it to e.g. json_encode. + * + * Subclasses must call the parent getDefinitionSummary() and build on that. + * It is recommended that each subclass appends its own new array. This prevents + * clashes or accidental overwrites of existing keys and gives each subclass + * its own scope for simple array keys. + * + * @code + * $summary = parent::getDefinitionSummary( $context ); + * $summary[] = [ + * 'foo' => 123, + * 'bar' => 'quux', + * ]; + * return $summary; + * @endcode + * + * Return an array containing values from all significant properties of this + * module's definition. + * + * Be careful not to normalise too much. Especially preserve the order of things + * that carry significance in getScript and getStyles (T39812). + * + * Avoid including things that are insiginificant (e.g. order of message keys is + * insignificant and should be sorted to avoid unnecessary cache invalidation). + * + * This data structure must exclusively contain arrays and scalars as values (avoid + * object instances) to allow simple serialisation using json_encode. + * + * If modules have a hash or timestamp from another source, that may be incuded as-is. + * + * A number of utility methods are available to help you gather data. These are not + * called by default and must be included by the subclass' getDefinitionSummary(). + * + * - getMessageBlob() + * + * @since 1.23 + * @param ResourceLoaderContext $context + * @return array|null + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + return [ + '_class' => static::class, + '_cacheEpoch' => $this->getConfig()->get( 'CacheEpoch' ), + ]; + } + + /** + * Get this module's last modification timestamp for a given context. + * + * @deprecated since 1.26 Use getDefinitionSummary() instead + * @param ResourceLoaderContext $context + * @return int|null UNIX timestamp + */ + public function getModifiedTime( ResourceLoaderContext $context ) { + return null; + } + + /** + * Helper method for providing a version hash to getVersionHash(). + * + * @deprecated since 1.26 Use getDefinitionSummary() instead + * @param ResourceLoaderContext $context + * @return string|null Hash + */ + public function getModifiedHash( ResourceLoaderContext $context ) { + return null; + } + + /** + * Check whether this module is known to be empty. If a child class + * has an easy and cheap way to determine that this module is + * definitely going to be empty, it should override this method to + * return true in that case. Callers may optimize the request for this + * module away if this function returns true. + * @param ResourceLoaderContext $context + * @return bool + */ + public function isKnownEmpty( ResourceLoaderContext $context ) { + return false; + } + + /** + * Check whether this module should be embeded rather than linked + * + * Modules returning true here will be embedded rather than loaded by + * ResourceLoaderClientHtml. + * + * @since 1.30 + * @param ResourceLoaderContext $context + * @return bool + */ + public function shouldEmbedModule( ResourceLoaderContext $context ) { + return $this->getGroup() === 'private'; + } + + /** @var JSParser Lazy-initialized; use self::javaScriptParser() */ + private static $jsParser; + private static $parseCacheVersion = 1; + + /** + * Validate a given script file; if valid returns the original source. + * If invalid, returns replacement JS source that throws an exception. + * + * @param string $fileName + * @param string $contents + * @return string JS with the original, or a replacement error + */ + protected function validateScriptFile( $fileName, $contents ) { + if ( !$this->getConfig()->get( 'ResourceLoaderValidateJS' ) ) { + return $contents; + } + $cache = ObjectCache::getMainWANInstance(); + return $cache->getWithSetCallback( + $cache->makeGlobalKey( + 'resourceloader', + 'jsparse', + self::$parseCacheVersion, + md5( $contents ), + $fileName + ), + $cache::TTL_WEEK, + function () use ( $contents, $fileName ) { + $parser = self::javaScriptParser(); + try { + $parser->parse( $contents, $fileName, 1 ); + $result = $contents; + } catch ( Exception $e ) { + // We'll save this to cache to avoid having to re-validate broken JS + $err = $e->getMessage(); + $result = "mw.log.error(" . + Xml::encodeJsVar( "JavaScript parse error: $err" ) . ");"; + } + return $result; + } + ); + } + + /** + * @return JSParser + */ + protected static function javaScriptParser() { + if ( !self::$jsParser ) { + self::$jsParser = new JSParser(); + } + return self::$jsParser; + } + + /** + * Safe version of filemtime(), which doesn't throw a PHP warning if the file doesn't exist. + * Defaults to 1. + * + * @param string $filePath File path + * @return int UNIX timestamp + */ + protected static function safeFilemtime( $filePath ) { + Wikimedia\suppressWarnings(); + $mtime = filemtime( $filePath ) ?: 1; + Wikimedia\restoreWarnings(); + return $mtime; + } + + /** + * Compute a non-cryptographic string hash of a file's contents. + * If the file does not exist or cannot be read, returns an empty string. + * + * @since 1.26 Uses MD4 instead of SHA1. + * @param string $filePath File path + * @return string Hash + */ + protected static function safeFileHash( $filePath ) { + return FileContentsHasher::getFileContentsHash( $filePath ); + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php new file mode 100644 index 00000000..e97e0742 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIFileModule.php @@ -0,0 +1,98 @@ +<?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 + */ + +/** + * ResourceLoaderFileModule which magically loads the right skinScripts and skinStyles for every + * skin, using the specified OOUI theme for each. + * + * @since 1.30 + */ +class ResourceLoaderOOUIFileModule extends ResourceLoaderFileModule { + use ResourceLoaderOOUIModule; + + public function __construct( $options = [] ) { + if ( isset( $options[ 'themeScripts' ] ) ) { + $skinScripts = $this->getSkinSpecific( $options[ 'themeScripts' ], 'scripts' ); + if ( !isset( $options['skinScripts'] ) ) { + $options['skinScripts'] = []; + } + $this->extendSkinSpecific( $options['skinScripts'], $skinScripts ); + } + if ( isset( $options[ 'themeStyles' ] ) ) { + $skinStyles = $this->getSkinSpecific( $options[ 'themeStyles' ], 'styles' ); + if ( !isset( $options['skinStyles'] ) ) { + $options['skinStyles'] = []; + } + $this->extendSkinSpecific( $options['skinStyles'], $skinStyles ); + } + + parent::__construct( $options ); + } + + /** + * Helper function to generate values for 'skinStyles' and 'skinScripts'. + * + * @param string $module Module to generate skinStyles/skinScripts for: + * 'core', 'widgets', 'toolbars', 'windows' + * @param string $which 'scripts' or 'styles' + * @return array + */ + private function getSkinSpecific( $module, $which ) { + $themes = self::getSkinThemeMap(); + + return array_combine( + array_keys( $themes ), + array_map( function ( $theme ) use ( $module, $which ) { + if ( $which === 'scripts' ) { + return $this->getThemeScriptsPath( $theme, $module ); + } else { + return $this->getThemeStylesPath( $theme, $module ); + } + }, array_values( $themes ) ) + ); + } + + /** + * Prepend the $extraSkinSpecific assoc. array to the $skinSpecific assoc. array. + * Both of them represent a 'skinScripts' or 'skinStyles' definition. + * + * @param array &$skinSpecific + * @param array $extraSkinSpecific + */ + private function extendSkinSpecific( &$skinSpecific, $extraSkinSpecific ) { + // For each skin where skinStyles/skinScripts are defined, add our ones at the beginning + foreach ( $skinSpecific as $skin => $files ) { + if ( !is_array( $files ) ) { + $files = [ $files ]; + } + if ( isset( $extraSkinSpecific[$skin] ) ) { + $skinSpecific[$skin] = array_merge( [ $extraSkinSpecific[$skin] ], $files ); + } elseif ( isset( $extraSkinSpecific['default'] ) ) { + $skinSpecific[$skin] = array_merge( [ $extraSkinSpecific['default'] ], $files ); + } + } + // Add our remaining skinStyles/skinScripts for skins that did not have them defined + foreach ( $extraSkinSpecific as $skin => $file ) { + if ( !isset( $skinSpecific[$skin] ) ) { + $skinSpecific[$skin] = $file; + } + } + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php new file mode 100644 index 00000000..5c9e1d94 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIImageModule.php @@ -0,0 +1,110 @@ +<?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 + */ + +/** + * Secret special sauce. + * + * @since 1.26 + */ +class ResourceLoaderOOUIImageModule extends ResourceLoaderImageModule { + use ResourceLoaderOOUIModule; + + protected function loadFromDefinition() { + if ( $this->definition === null ) { + // Do nothing if definition was already processed + return; + } + + $themes = self::getSkinThemeMap(); + + // For backwards-compatibility, allow missing 'themeImages' + $module = isset( $this->definition['themeImages'] ) ? $this->definition['themeImages'] : ''; + + $definition = []; + foreach ( $themes as $skin => $theme ) { + // Find the path to the JSON file which contains the actual image definitions for this theme + if ( $module ) { + $dataPath = $this->getThemeImagesPath( $theme, $module ); + } else { + // Backwards-compatibility for things that probably shouldn't have used this class... + $dataPath = + $this->definition['rootPath'] . '/' . + strtolower( $theme ) . '/' . + $this->definition['name'] . '.json'; + } + $localDataPath = $this->localBasePath . '/' . $dataPath; + + // If there's no file for this module of this theme, that's okay, it will just use the defaults + if ( !file_exists( $localDataPath ) ) { + continue; + } + $data = json_decode( file_get_contents( $localDataPath ), true ); + + // Expand the paths to images (since they are relative to the JSON file that defines them, not + // our base directory) + $fixPath = function ( &$path ) use ( $dataPath ) { + $path = dirname( $dataPath ) . '/' . $path; + }; + array_walk( $data['images'], function ( &$value ) use ( $fixPath ) { + if ( is_string( $value['file'] ) ) { + $fixPath( $value['file'] ); + } elseif ( is_array( $value['file'] ) ) { + array_walk_recursive( $value['file'], $fixPath ); + } + } ); + + // Convert into a definition compatible with the parent vanilla ResourceLoaderImageModule + foreach ( $data as $key => $value ) { + switch ( $key ) { + // Images and color variants are defined per-theme, here converted to per-skin + case 'images': + case 'variants': + $definition[$key][$skin] = $data[$key]; + break; + + // Other options must be identical for each theme (or only defined in the default one) + default: + if ( !isset( $definition[$key] ) ) { + $definition[$key] = $data[$key]; + } elseif ( $definition[$key] !== $data[$key] ) { + throw new Exception( + "Mismatched OOUI theme images definition: " . + "key '$key' of theme '$theme' for module '$module' " . + "does not match other themes" + ); + } + break; + } + } + } + + // Extra selectors to allow using the same icons for old-style MediaWiki UI code + if ( substr( $module, 0, 5 ) === 'icons' ) { + $definition['selectorWithoutVariant'] = '.oo-ui-icon-{name}, .mw-ui-icon-{name}:before'; + $definition['selectorWithVariant'] = '.oo-ui-image-{variant}.oo-ui-icon-{name}, ' . + '.mw-ui-icon-{name}-{variant}:before'; + } + + // Fields from module definition silently override keys from JSON files + $this->definition += $definition; + + parent::loadFromDefinition(); + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php b/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php new file mode 100644 index 00000000..4228a45f --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderOOUIModule.php @@ -0,0 +1,146 @@ +<?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 + */ + +/** + * Convenience methods for dealing with OOUI themes and their relations to MW skins. + * + * @since 1.30 + */ +trait ResourceLoaderOOUIModule { + protected static $knownScriptsModules = [ 'core' ]; + protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ]; + protected static $knownImagesModules = [ + 'indicators', 'textures', + // Extra icons + 'icons-accessibility', + 'icons-alerts', + 'icons-content', + 'icons-editing-advanced', + 'icons-editing-core', + 'icons-editing-list', + 'icons-editing-styling', + 'icons-interactions', + 'icons-layout', + 'icons-location', + 'icons-media', + 'icons-moderation', + 'icons-movement', + 'icons-user', + 'icons-wikimedia', + ]; + + // Note that keys must be lowercase, values TitleCase. + protected static $builtinSkinThemeMap = [ + 'default' => 'WikimediaUI', + ]; + + // Note that keys must be TitleCase. + protected static $builtinThemePaths = [ + 'WikimediaUI' => [ + 'scripts' => 'resources/lib/oojs-ui/oojs-ui-wikimediaui.js', + 'styles' => 'resources/lib/oojs-ui/oojs-ui-{module}-wikimediaui.css', + 'images' => 'resources/lib/oojs-ui/themes/wikimediaui/{module}.json', + ], + 'Apex' => [ + 'scripts' => 'resources/lib/oojs-ui/oojs-ui-apex.js', + 'styles' => 'resources/lib/oojs-ui/oojs-ui-{module}-apex.css', + 'images' => 'resources/lib/oojs-ui/themes/apex/{module}.json', + ], + ]; + + /** + * Return a map of skin names (in lowercase) to OOUI theme names, defining which theme a given + * skin should use. + * + * @return array + */ + public static function getSkinThemeMap() { + $themeMap = self::$builtinSkinThemeMap; + $themeMap += ExtensionRegistry::getInstance()->getAttribute( 'SkinOOUIThemes' ); + return $themeMap; + } + + /** + * Return a map of theme names to lists of paths from which a given theme should be loaded. + * + * Keys are theme names, values are associative arrays. Keys of the inner array are 'scripts', + * 'styles', or 'images', and values are string paths. + * + * Additionally, the string '{module}' in paths represents the name of the module to load. + * + * @return array + */ + protected static function getThemePaths() { + $themePaths = self::$builtinThemePaths; + return $themePaths; + } + + /** + * Return a path to load given module of given theme from. + * + * @param string $theme OOUI theme name, for example 'WikimediaUI' or 'Apex' + * @param string $kind Kind of the module: 'scripts', 'styles', or 'images' + * @param string $module Module name, for valid values see $knownScriptsModules, + * $knownStylesModules, $knownImagesModules + * @return string + */ + protected function getThemePath( $theme, $kind, $module ) { + $paths = self::getThemePaths(); + $path = $paths[ $theme ][ $kind ]; + $path = str_replace( '{module}', $module, $path ); + return $path; + } + + /** + * @param string $theme See getThemePath() + * @param string $module See getThemePath() + * @return string + */ + protected function getThemeScriptsPath( $theme, $module ) { + if ( !in_array( $module, self::$knownScriptsModules ) ) { + throw new InvalidArgumentException( "Invalid OOUI scripts module '$module'" ); + } + return $this->getThemePath( $theme, 'scripts', $module ); + } + + /** + * @param string $theme See getThemePath() + * @param string $module See getThemePath() + * @return string + */ + protected function getThemeStylesPath( $theme, $module ) { + if ( !in_array( $module, self::$knownStylesModules ) ) { + throw new InvalidArgumentException( "Invalid OOUI styles module '$module'" ); + } + return $this->getThemePath( $theme, 'styles', $module ); + } + + /** + * @param string $theme See getThemePath() + * @param string $module See getThemePath() + * @return string + */ + protected function getThemeImagesPath( $theme, $module ) { + if ( !in_array( $module, self::$knownImagesModules ) ) { + throw new InvalidArgumentException( "Invalid OOUI images module '$module'" ); + } + return $this->getThemePath( $theme, 'images', $module ); + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php b/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php new file mode 100644 index 00000000..beab53eb --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderRawFileModule.php @@ -0,0 +1,52 @@ +<?php +/** + * Module containing files that are loaded without ResourceLoader. + * + * Primary usecase being "base" modules loaded by the startup module, + * such as jquery and the mw.loader client itself. These make use of + * ResourceLoaderModule and load.php for convenience but aren't actually + * registered in the startup module (as it would have to load itself). + * + * 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 Timo Tijhof + */ + +class ResourceLoaderRawFileModule extends ResourceLoaderFileModule { + + /** + * Enable raw mode to omit mw.loader.state() call as mw.loader + * does not yet exist when these modules execute. + * @var bool + */ + protected $raw = true; + + /** + * Get all JavaScript code. + * + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + $script = parent::getScript( $context ); + // Add closure explicitly because raw modules can't be wrapped mw.loader.implement. + // Unlike with mw.loader.implement, this closure is immediately invoked. + // @see ResourceLoader::makeModuleResponse + // @see ResourceLoader::makeLoaderImplementScript + return "(function () {\n{$script}\n}());"; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php new file mode 100644 index 00000000..236112ea --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderSiteModule.php @@ -0,0 +1,52 @@ +<?php +/** + * ResourceLoader module for site customizations. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for site customizations + */ +class ResourceLoaderSiteModule extends ResourceLoaderWikiModule { + + /** + * Get list of pages used by this module + * + * @param ResourceLoaderContext $context + * @return array List of pages + */ + protected function getPages( ResourceLoaderContext $context ) { + $pages = []; + if ( $this->getConfig()->get( 'UseSiteJs' ) ) { + $pages['MediaWiki:Common.js'] = [ 'type' => 'script' ]; + $pages['MediaWiki:' . ucfirst( $context->getSkin() ) . '.js'] = [ 'type' => 'script' ]; + } + return $pages; + } + + /** + * @param ResourceLoaderContext|null $context + * @return array + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'site.styles' ]; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php new file mode 100644 index 00000000..79922bfe --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderSiteStylesModule.php @@ -0,0 +1,60 @@ +<?php +/** + * ResourceLoader module for site style customizations. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for site style customizations + */ +class ResourceLoaderSiteStylesModule extends ResourceLoaderWikiModule { + + /** + * Get list of pages used by this module + * + * @param ResourceLoaderContext $context + * @return array List of pages + */ + protected function getPages( ResourceLoaderContext $context ) { + $pages = []; + if ( $this->getConfig()->get( 'UseSiteCss' ) ) { + $pages['MediaWiki:Common.css'] = [ 'type' => 'style' ]; + $pages['MediaWiki:' . ucfirst( $context->getSkin() ) . '.css'] = [ 'type' => 'style' ]; + $pages['MediaWiki:Print.css'] = [ 'type' => 'style', 'media' => 'print' ]; + + } + return $pages; + } + + /** + * @return string + */ + public function getType() { + return self::LOAD_STYLES; + } + + /** + * @return string + */ + public function getGroup() { + return 'site'; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php new file mode 100644 index 00000000..fbd0a24a --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderSkinModule.php @@ -0,0 +1,167 @@ +<?php +/** + * ResourceLoader module for skin stylesheets. + * + * 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 Timo Tijhof + */ + +class ResourceLoaderSkinModule extends ResourceLoaderFileModule { + /** + * All skins are assumed to be compatible with mobile + */ + public $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getStyles( ResourceLoaderContext $context ) { + $logo = $this->getLogoData( $this->getConfig() ); + $styles = parent::getStyles( $context ); + $this->normalizeStyles( $styles ); + + $default = !is_array( $logo ) ? $logo : $logo['1x']; + $styles['all'][] = '.mw-wiki-logo { background-image: ' . + CSSMin::buildUrlValue( $default ) . + '; }'; + + if ( is_array( $logo ) ) { + if ( isset( $logo['svg'] ) ) { + $styles['all'][] = '.mw-wiki-logo { ' . + 'background-image: -webkit-linear-gradient(transparent, transparent), ' . + CSSMin::buildUrlValue( $logo['svg'] ) . '; ' . + 'background-image: linear-gradient(transparent, transparent), ' . + CSSMin::buildUrlValue( $logo['svg'] ) . ';' . + 'background-size: 135px auto; }'; + } else { + if ( isset( $logo['1.5x'] ) ) { + $styles[ + '(-webkit-min-device-pixel-ratio: 1.5), ' . + '(min--moz-device-pixel-ratio: 1.5), ' . + '(min-resolution: 1.5dppx), ' . + '(min-resolution: 144dpi)' + ][] = '.mw-wiki-logo { background-image: ' . + CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' . + 'background-size: 135px auto; }'; + } + if ( isset( $logo['2x'] ) ) { + $styles[ + '(-webkit-min-device-pixel-ratio: 2), ' . + '(min--moz-device-pixel-ratio: 2), ' . + '(min-resolution: 2dppx), ' . + '(min-resolution: 192dpi)' + ][] = '.mw-wiki-logo { background-image: ' . + CSSMin::buildUrlValue( $logo['2x'] ) . ';' . + 'background-size: 135px auto; }'; + } + } + } + + return $styles; + } + + /** + * Ensure all media keys use array values. + * + * Normalises arrays returned by the ResourceLoaderFileModule::getStyles() method. + * + * @param array &$styles Associative array, keys are strings (media queries), + * values are strings or arrays + */ + private function normalizeStyles( &$styles ) { + foreach ( $styles as $key => $val ) { + if ( !is_array( $val ) ) { + $styles[$key] = [ $val ]; + } + } + } + + /** + * @since 1.31 + * @param Config $conf + * @return string|array + */ + protected function getLogoData( Config $conf ) { + return static::getLogo( $conf ); + } + + /** + * @param Config $conf + * @return string|array Single url if no variants are defined, + * or an array of logo urls keyed by dppx in form "<float>x". + * Key "1x" is always defined. Key "svg" may also be defined, + * in which case variants other than "1x" are omitted. + */ + public static function getLogo( Config $conf ) { + $logo = $conf->get( 'Logo' ); + $logoHD = $conf->get( 'LogoHD' ); + + $logo1Url = OutputPage::transformResourcePath( $conf, $logo ); + + if ( !$logoHD ) { + return $logo1Url; + } + + $logoUrls = [ + '1x' => $logo1Url, + ]; + + if ( isset( $logoHD['svg'] ) ) { + $logoUrls['svg'] = OutputPage::transformResourcePath( + $conf, + $logoHD['svg'] + ); + } else { + // Only 1.5x and 2x are supported + if ( isset( $logoHD['1.5x'] ) ) { + $logoUrls['1.5x'] = OutputPage::transformResourcePath( + $conf, + $logoHD['1.5x'] + ); + } + if ( isset( $logoHD['2x'] ) ) { + $logoUrls['2x'] = OutputPage::transformResourcePath( + $conf, + $logoHD['2x'] + ); + } + } + + return $logoUrls; + } + + /** + * @param ResourceLoaderContext $context + * @return bool + */ + public function isKnownEmpty( ResourceLoaderContext $context ) { + // Regardless of whether the files are specified, we always + // provide mw-wiki-logo styles. + return false; + } + + public function getDefinitionSummary( ResourceLoaderContext $context ) { + $summary = parent::getDefinitionSummary( $context ); + $summary[] = [ + 'logo' => $this->getConfig()->get( 'Logo' ), + 'logoHD' => $this->getConfig()->get( 'LogoHD' ), + ]; + return $summary; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php b/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php new file mode 100644 index 00000000..a0061e35 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderSpecialCharacterDataModule.php @@ -0,0 +1,102 @@ +<?php +/** + * ResourceLoader module for populating special characters data for some + * editing extensions to use. + * + * 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 + */ + +/** + * ResourceLoader module for populating special characters data for some + * editing extensions to use. + */ +class ResourceLoaderSpecialCharacterDataModule extends ResourceLoaderModule { + private $path = "resources/src/mediawiki.language/specialcharacters.json"; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Get all the dynamic data. + * + * @return array + */ + protected function getData() { + global $IP; + return json_decode( file_get_contents( "$IP/{$this->path}" ) ); + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + return Xml::encodeJsCall( + 'mw.language.setSpecialCharacters', + [ + $this->getData() + ], + ResourceLoader::inDebugMode() + ); + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'mediawiki.language' ]; + } + + /** + * @return array + */ + public function getMessages() { + return [ + 'special-characters-group-latin', + 'special-characters-group-latinextended', + 'special-characters-group-ipa', + 'special-characters-group-symbols', + 'special-characters-group-greek', + 'special-characters-group-greekextended', + 'special-characters-group-cyrillic', + 'special-characters-group-arabic', + 'special-characters-group-arabicextended', + 'special-characters-group-persian', + 'special-characters-group-hebrew', + 'special-characters-group-bangla', + 'special-characters-group-tamil', + 'special-characters-group-telugu', + 'special-characters-group-sinhala', + 'special-characters-group-devanagari', + 'special-characters-group-gujarati', + 'special-characters-group-thai', + 'special-characters-group-lao', + 'special-characters-group-khmer', + 'special-characters-group-canadianaboriginal', + 'special-characters-title-endash', + 'special-characters-title-emdash', + 'special-characters-title-minus' + ]; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php b/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php new file mode 100644 index 00000000..56d88358 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -0,0 +1,458 @@ +<?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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for ResourceLoader initialization. + * + * See also <https://www.mediawiki.org/wiki/ResourceLoader/Features#Startup_Module> + * + * The startup module, as being called only from ResourceLoaderClientHtml, has + * the ability to vary based extra query parameters, in addition to those + * from ResourceLoaderContext: + * + * - target: Only register modules in the client allowed within this target. + * Default: "desktop". + * See also: OutputPage::setTarget(), ResourceLoaderModule::getTargets(). + */ +class ResourceLoaderStartUpModule extends ResourceLoaderModule { + + // Cache for getConfigSettings() as it's called by multiple methods + protected $configVars = []; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array + */ + protected function getConfigSettings( $context ) { + $hash = $context->getHash(); + if ( isset( $this->configVars[$hash] ) ) { + return $this->configVars[$hash]; + } + + global $wgContLang; + $conf = $this->getConfig(); + + // We can't use Title::newMainPage() if 'mainpage' is in + // $wgForceUIMsgAsContentMsg because that will try to use the session + // user's language and we have no session user. This does the + // equivalent but falling back to our ResourceLoaderContext language + // instead. + $mainPage = Title::newFromText( $context->msg( 'mainpage' )->inContentLanguage()->text() ); + if ( !$mainPage ) { + $mainPage = Title::newFromText( 'Main Page' ); + } + + /** + * Namespace related preparation + * - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces. + * - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive. + */ + $namespaceIds = $wgContLang->getNamespaceIds(); + $caseSensitiveNamespaces = []; + foreach ( MWNamespace::getCanonicalNamespaces() as $index => $name ) { + $namespaceIds[$wgContLang->lc( $name )] = $index; + if ( !MWNamespace::isCapitalized( $index ) ) { + $caseSensitiveNamespaces[] = $index; + } + } + + $illegalFileChars = $conf->get( 'IllegalFileChars' ); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + + // Build list of variables + $vars = [ + 'wgLoadScript' => wfScript( 'load' ), + 'debug' => $context->getDebug(), + 'skin' => $context->getSkin(), + 'stylepath' => $conf->get( 'StylePath' ), + 'wgUrlProtocols' => wfUrlProtocols(), + 'wgArticlePath' => $conf->get( 'ArticlePath' ), + 'wgScriptPath' => $conf->get( 'ScriptPath' ), + 'wgScript' => wfScript(), + 'wgSearchType' => $conf->get( 'SearchType' ), + 'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ), + // Force object to avoid "empty" associative array from + // becoming [] instead of {} in JS (T36604) + 'wgActionPaths' => (object)$conf->get( 'ActionPaths' ), + 'wgServer' => $conf->get( 'Server' ), + 'wgServerName' => $conf->get( 'ServerName' ), + 'wgUserLanguage' => $context->getLanguage(), + 'wgContentLanguage' => $wgContLang->getCode(), + 'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ), + 'wgVersion' => $conf->get( 'Version' ), + 'wgEnableAPI' => $conf->get( 'EnableAPI' ), + 'wgEnableWriteAPI' => $conf->get( 'EnableWriteAPI' ), + 'wgMainPageTitle' => $mainPage->getPrefixedText(), + 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(), + 'wgNamespaceIds' => $namespaceIds, + 'wgContentNamespaces' => MWNamespace::getContentNamespaces(), + 'wgSiteName' => $conf->get( 'Sitename' ), + 'wgDBname' => $conf->get( 'DBname' ), + 'wgExtraSignatureNamespaces' => $conf->get( 'ExtraSignatureNamespaces' ), + 'wgAvailableSkins' => Skin::getSkinNames(), + 'wgExtensionAssetsPath' => $conf->get( 'ExtensionAssetsPath' ), + // MediaWiki sets cookies to have this prefix by default + 'wgCookiePrefix' => $conf->get( 'CookiePrefix' ), + 'wgCookieDomain' => $conf->get( 'CookieDomain' ), + 'wgCookiePath' => $conf->get( 'CookiePath' ), + 'wgCookieExpiration' => $conf->get( 'CookieExpiration' ), + 'wgResourceLoaderMaxQueryLength' => $conf->get( 'ResourceLoaderMaxQueryLength' ), + 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces, + 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), + 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ), + 'wgResourceLoaderStorageVersion' => $conf->get( 'ResourceLoaderStorageVersion' ), + 'wgResourceLoaderStorageEnabled' => $conf->get( 'ResourceLoaderStorageEnabled' ), + 'wgForeignUploadTargets' => $conf->get( 'ForeignUploadTargets' ), + 'wgEnableUploads' => $conf->get( 'EnableUploads' ), + 'wgCommentByteLimit' => $oldCommentSchema ? 255 : null, + 'wgCommentCodePointLimit' => $oldCommentSchema ? null : CommentStore::COMMENT_CHARACTER_LIMIT, + ]; + + Hooks::run( 'ResourceLoaderGetConfigVars', [ &$vars ] ); + + $this->configVars[$hash] = $vars; + return $this->configVars[$hash]; + } + + /** + * Recursively get all explicit and implicit dependencies for to the given module. + * + * @param array $registryData + * @param string $moduleName + * @return array + */ + protected static function getImplicitDependencies( array $registryData, $moduleName ) { + static $dependencyCache = []; + + // The list of implicit dependencies won't be altered, so we can + // cache them without having to worry. + if ( !isset( $dependencyCache[$moduleName] ) ) { + if ( !isset( $registryData[$moduleName] ) ) { + // Dependencies may not exist + $dependencyCache[$moduleName] = []; + } else { + $data = $registryData[$moduleName]; + $dependencyCache[$moduleName] = $data['dependencies']; + + foreach ( $data['dependencies'] as $dependency ) { + // Recursively get the dependencies of the dependencies + $dependencyCache[$moduleName] = array_merge( + $dependencyCache[$moduleName], + self::getImplicitDependencies( $registryData, $dependency ) + ); + } + } + } + + return $dependencyCache[$moduleName]; + } + + /** + * Optimize the dependency tree in $this->modules. + * + * The optimization basically works like this: + * Given we have module A with the dependencies B and C + * and module B with the dependency C. + * Now we don't have to tell the client to explicitly fetch module + * C as that's already included in module B. + * + * This way we can reasonably reduce the amount of module registration + * data send to the client. + * + * @param array &$registryData Modules keyed by name with properties: + * - string 'version' + * - array 'dependencies' + * - string|null 'group' + * - string 'source' + */ + public static function compileUnresolvedDependencies( array &$registryData ) { + foreach ( $registryData as $name => &$data ) { + $dependencies = $data['dependencies']; + foreach ( $data['dependencies'] as $dependency ) { + $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); + $dependencies = array_diff( $dependencies, $implicitDependencies ); + } + // Rebuild keys + $data['dependencies'] = array_values( $dependencies ); + } + } + + /** + * Get registration code for all modules. + * + * @param ResourceLoaderContext $context + * @return string JavaScript code for registering all modules with the client loader + */ + public function getModuleRegistrations( ResourceLoaderContext $context ) { + $resourceLoader = $context->getResourceLoader(); + // Future developers: Use WebRequest::getRawVal() instead getVal(). + // The getVal() method performs slow Language+UTF logic. (f303bb9360) + $target = $context->getRequest()->getRawVal( 'target', 'desktop' ); + // Bypass target filter if this request is Special:JavaScriptTest. + // To prevent misuse in production, this is only allowed if testing is enabled server-side. + $byPassTargetFilter = $this->getConfig()->get( 'EnableJavaScriptTest' ) && $target === 'test'; + + $out = ''; + $states = []; + $registryData = []; + + // Get registry data + foreach ( $resourceLoader->getModuleNames() as $name ) { + $module = $resourceLoader->getModule( $name ); + $moduleTargets = $module->getTargets(); + if ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) ) { + continue; + } + + if ( $module->isRaw() ) { + // Don't register "raw" modules (like 'jquery' and 'mediawiki') client-side because + // depending on them is illegal anyway and would only lead to them being reloaded + // causing any state to be lost (like jQuery plugins, mw.config etc.) + continue; + } + + try { + $versionHash = $module->getVersionHash( $context ); + } catch ( Exception $e ) { + // See also T152266 and ResourceLoader::getCombinedVersion() + MWExceptionHandler::logException( $e ); + $context->getLogger()->warning( + 'Calculating version for "{module}" failed: {exception}', + [ + 'module' => $name, + 'exception' => $e, + ] + ); + $versionHash = ''; + $states[$name] = 'error'; + } + + if ( $versionHash !== '' && strlen( $versionHash ) !== 7 ) { + $context->getLogger()->warning( + "Module '{module}' produced an invalid version hash: '{version}'.", + [ + 'module' => $name, + 'version' => $versionHash, + ] + ); + // Module implementation either broken or deviated from ResourceLoader::makeHash + // Asserted by tests/phpunit/structure/ResourcesTest. + $versionHash = ResourceLoader::makeHash( $versionHash ); + } + + $skipFunction = $module->getSkipFunction(); + if ( $skipFunction !== null && !ResourceLoader::inDebugMode() ) { + $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction ); + } + + $registryData[$name] = [ + 'version' => $versionHash, + 'dependencies' => $module->getDependencies( $context ), + 'group' => $module->getGroup(), + 'source' => $module->getSource(), + 'skip' => $skipFunction, + ]; + } + + self::compileUnresolvedDependencies( $registryData ); + + // Register sources + $out .= ResourceLoader::makeLoaderSourcesScript( $resourceLoader->getSources() ); + + // Figure out the different call signatures for mw.loader.register + $registrations = []; + foreach ( $registryData as $name => $data ) { + // Call mw.loader.register(name, version, dependencies, group, source, skip) + $registrations[] = [ + $name, + $data['version'], + $data['dependencies'], + $data['group'], + // Swap default (local) for null + $data['source'] === 'local' ? null : $data['source'], + $data['skip'] + ]; + } + + // Register modules + $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $registrations ); + + if ( $states ) { + $out .= "\n" . ResourceLoader::makeLoaderStateScript( $states ); + } + + return $out; + } + + /** + * @return bool + */ + public function isRaw() { + return true; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getPreloadLinks( ResourceLoaderContext $context ) { + $url = self::getStartupModulesUrl( $context ); + return [ + $url => [ 'as' => 'script' ] + ]; + } + + /** + * Base modules required for the base environment of ResourceLoader + * + * @return array + */ + public static function getStartupModules() { + return [ 'jquery', 'mediawiki' ]; + } + + public static function getLegacyModules() { + global $wgIncludeLegacyJavaScript; + + $legacyModules = []; + if ( $wgIncludeLegacyJavaScript ) { + $legacyModules[] = 'mediawiki.legacy.wikibits'; + } + + return $legacyModules; + } + + /** + * Get the load URL of the startup modules. + * + * This is a helper for getScript(), but can also be called standalone, such + * as when generating an AppCache manifest. + * + * @param ResourceLoaderContext $context + * @return string + */ + public static function getStartupModulesUrl( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); + $derivative = new DerivativeResourceLoaderContext( $context ); + $derivative->setModules( array_merge( + self::getStartupModules(), + self::getLegacyModules() + ) ); + $derivative->setOnly( 'scripts' ); + // Must setModules() before makeVersionQuery() + $derivative->setVersion( $rl->makeVersionQuery( $derivative ) ); + + return $rl->createLoaderURL( 'local', $derivative ); + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + global $IP; + if ( $context->getOnly() !== 'scripts' ) { + return '/* Requires only=script */'; + } + + $out = file_get_contents( "$IP/resources/src/startup.js" ); + + $pairs = array_map( function ( $value ) { + $value = FormatJson::encode( $value, ResourceLoader::inDebugMode(), FormatJson::ALL_OK ); + // Fix indentation + $value = str_replace( "\n", "\n\t", $value ); + return $value; + }, [ + '$VARS.wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ), + '$VARS.configuration' => $this->getConfigSettings( $context ), + // This url may be preloaded. See getPreloadLinks(). + '$VARS.baseModulesUri' => self::getStartupModulesUrl( $context ), + ] ); + $pairs['$CODE.registrations()'] = str_replace( + "\n", + "\n\t", + trim( $this->getModuleRegistrations( $context ) ) + ); + + return strtr( $out, $pairs ); + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } + + /** + * Get the definition summary for this module. + * + * @param ResourceLoaderContext $context + * @return array + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + global $IP; + $summary = parent::getDefinitionSummary( $context ); + $summary[] = [ + // Detect changes to variables exposed in mw.config (T30899). + 'vars' => $this->getConfigSettings( $context ), + // Changes how getScript() creates mw.Map for mw.config + 'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ), + // Detect changes to the module registrations + 'moduleHashes' => $this->getAllModuleHashes( $context ), + + 'fileMtimes' => [ + filemtime( "$IP/resources/src/startup.js" ), + ], + ]; + return $summary; + } + + /** + * Helper method for getDefinitionSummary(). + * + * @param ResourceLoaderContext $context + * @return string SHA-1 + */ + protected function getAllModuleHashes( ResourceLoaderContext $context ) { + $rl = $context->getResourceLoader(); + // Preload for getCombinedVersion() + $rl->preloadModuleInfo( $rl->getModuleNames(), $context ); + + // ATTENTION: Because of the line below, this is not going to cause infinite recursion. + // Think carefully before making changes to this code! + // Pre-populate versionHash with something because the loop over all modules below includes + // the startup module (this module). + // See ResourceLoaderModule::getVersionHash() for usage of this cache. + $this->versionHash[$context->getHash()] = null; + + return $rl->getCombinedVersion( $context, $rl->getModuleNames() ); + } + + /** + * @return string + */ + public function getGroup() { + return 'startup'; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php new file mode 100644 index 00000000..1a390cf1 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUploadDialogModule.php @@ -0,0 +1,49 @@ +<?php +/** + * ResourceLoader module for the upload dialog configuration data. + * + * 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 + */ + +/** + * ResourceLoader module for the upload dialog configuration data. + * + * @since 1.27 + */ +class ResourceLoaderUploadDialogModule extends ResourceLoaderModule { + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + $config = $context->getResourceLoader()->getConfig(); + return ResourceLoader::makeConfigSetScript( [ + 'wgUploadDialog' => $config->get( 'UploadDialog' ), + ] ); + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php new file mode 100644 index 00000000..b9dc0982 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUserDefaultsModule.php @@ -0,0 +1,49 @@ +<?php +/** + * ResourceLoader module for default user preferences. + * + * 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 Ori Livneh + */ + +/** + * Module for default user preferences. + */ +class ResourceLoaderUserDefaultsModule extends ResourceLoaderModule { + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + return Xml::encodeJsCall( + 'mw.user.options.set', + [ User::getDefaultOptions() ], + ResourceLoader::inDebugMode() + ); + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php new file mode 100644 index 00000000..8e213819 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUserModule.php @@ -0,0 +1,87 @@ +<?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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for user customizations scripts + */ +class ResourceLoaderUserModule extends ResourceLoaderWikiModule { + + protected $origin = self::ORIGIN_USER_INDIVIDUAL; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array List of pages + */ + protected function getPages( ResourceLoaderContext $context ) { + $config = $this->getConfig(); + $user = $context->getUserObj(); + if ( $user->isAnon() ) { + return []; + } + + // Use localised/normalised variant to ensure $excludepage matches + $userPage = $user->getUserPage()->getPrefixedDBkey(); + $pages = []; + + if ( $config->get( 'AllowUserJs' ) ) { + $pages["$userPage/common.js"] = [ 'type' => 'script' ]; + $pages["$userPage/" . $context->getSkin() . '.js'] = [ 'type' => 'script' ]; + } + + // User group pages are maintained site-wide and enabled with site JS/CSS. + if ( $config->get( 'UseSiteJs' ) ) { + foreach ( $user->getEffectiveGroups() as $group ) { + if ( $group == '*' ) { + continue; + } + $pages["MediaWiki:Group-$group.js"] = [ 'type' => 'script' ]; + } + } + + // Hack for T28283: Allow excluding pages for preview on a CSS/JS page. + // The excludepage parameter is set by OutputPage. + $excludepage = $context->getRequest()->getVal( 'excludepage' ); + if ( isset( $pages[$excludepage] ) ) { + unset( $pages[$excludepage] ); + } + + return $pages; + } + + /** + * Get group name + * + * @return string + */ + public function getGroup() { + return 'user'; + } + + /** + * @param ResourceLoaderContext|null $context + * @return array + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'user.styles' ]; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php new file mode 100644 index 00000000..ffa55c08 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUserOptionsModule.php @@ -0,0 +1,83 @@ +<?php +/** + * ResourceLoader module for user preference customizations. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for user preference customizations + */ +class ResourceLoaderUserOptionsModule extends ResourceLoaderModule { + + protected $origin = self::ORIGIN_CORE_INDIVIDUAL; + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array List of module names as strings + */ + public function getDependencies( ResourceLoaderContext $context = null ) { + return [ 'user.defaults' ]; + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + // Use FILTER_NOMIN annotation to prevent needless minification and caching (T84960). + return ResourceLoader::FILTER_NOMIN . Xml::encodeJsCall( + 'mw.user.options.set', + [ $context->getUserObj()->getOptions( User::GETOPTIONS_EXCLUDE_DEFAULTS ) ], + ResourceLoader::inDebugMode() + ); + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } + + /** + * @param ResourceLoaderContext $context + * @return bool + */ + public function isKnownEmpty( ResourceLoaderContext $context ) { + return !$context->getUserObj()->getOptions( User::GETOPTIONS_EXCLUDE_DEFAULTS ); + } + + /** + * @return string + */ + public function getGroup() { + return 'private'; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php new file mode 100644 index 00000000..8d8e0085 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUserStylesModule.php @@ -0,0 +1,86 @@ +<?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 Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for user customizations styles + */ +class ResourceLoaderUserStylesModule extends ResourceLoaderWikiModule { + + protected $origin = self::ORIGIN_USER_INDIVIDUAL; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param ResourceLoaderContext $context + * @return array List of pages + */ + protected function getPages( ResourceLoaderContext $context ) { + $config = $this->getConfig(); + $user = $context->getUserObj(); + if ( $user->isAnon() ) { + return []; + } + + // Use localised/normalised variant to ensure $excludepage matches + $userPage = $user->getUserPage()->getPrefixedDBkey(); + $pages = []; + + if ( $config->get( 'AllowUserCss' ) ) { + $pages["$userPage/common.css"] = [ 'type' => 'style' ]; + $pages["$userPage/" . $context->getSkin() . '.css'] = [ 'type' => 'style' ]; + } + + // User group pages are maintained site-wide and enabled with site JS/CSS. + if ( $config->get( 'UseSiteCss' ) ) { + foreach ( $user->getEffectiveGroups() as $group ) { + if ( $group == '*' ) { + continue; + } + $pages["MediaWiki:Group-$group.css"] = [ 'type' => 'style' ]; + } + } + + // Hack for T28283: Allow excluding pages for preview on a CSS/JS page. + // The excludepage parameter is set by OutputPage. + $excludepage = $context->getRequest()->getVal( 'excludepage' ); + if ( isset( $pages[$excludepage] ) ) { + unset( $pages[$excludepage] ); + } + + return $pages; + } + + /** + * @return string + */ + public function getType() { + return self::LOAD_STYLES; + } + + /** + * Get group name + * + * @return string + */ + public function getGroup() { + return 'user'; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php b/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php new file mode 100644 index 00000000..ae4fb67b --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderUserTokensModule.php @@ -0,0 +1,76 @@ +<?php +/** + * ResourceLoader module for user tokens. + * + * 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 Krinkle + */ + +/** + * Module for user tokens + */ +class ResourceLoaderUserTokensModule extends ResourceLoaderModule { + + protected $origin = self::ORIGIN_CORE_INDIVIDUAL; + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Fetch the tokens for the current user. + * + * @param ResourceLoaderContext $context + * @return array List of tokens keyed by token type + */ + protected function contextUserTokens( ResourceLoaderContext $context ) { + $user = $context->getUserObj(); + + return [ + 'editToken' => $user->getEditToken(), + 'patrolToken' => $user->getEditToken( 'patrol' ), + 'watchToken' => $user->getEditToken( 'watch' ), + 'csrfToken' => $user->getEditToken(), + ]; + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + // Use FILTER_NOMIN annotation to prevent needless minification and caching (T84960). + return ResourceLoader::FILTER_NOMIN . Xml::encodeJsCall( + 'mw.user.tokens.set', + [ $this->contextUserTokens( $context ) ], + ResourceLoader::inDebugMode() + ); + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } + + /** + * @return string + */ + public function getGroup() { + return 'private'; + } +} diff --git a/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php b/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php new file mode 100644 index 00000000..5b512af7 --- /dev/null +++ b/www/wiki/includes/resourceloader/ResourceLoaderWikiModule.php @@ -0,0 +1,473 @@ +<?php +/** + * Abstraction for ResourceLoader modules that pull from wiki pages. + * + * 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 Trevor Parscal + * @author Roan Kattouw + */ + +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; + +/** + * Abstraction for ResourceLoader modules which pull from wiki pages + * + * This can only be used for wiki pages in the MediaWiki and User namespaces, + * because of its dependence on the functionality of Title::isUserConfigPage() + * and Title::isSiteConfigPage(). + * + * This module supports being used as a placeholder for a module on a remote wiki. + * To do so, getDB() must be overloaded to return a foreign database object that + * allows local wikis to query page metadata. + * + * Safe for calls on local wikis are: + * - Option getters: + * - getGroup() + * - getPages() + * - Basic methods that strictly involve the foreign database + * - getDB() + * - isKnownEmpty() + * - getTitleInfo() + */ +class ResourceLoaderWikiModule extends ResourceLoaderModule { + + // Origin defaults to users with sitewide authority + protected $origin = self::ORIGIN_USER_SITEWIDE; + + // In-process cache for title info + protected $titleInfo = []; + + // List of page names that contain CSS + protected $styles = []; + + // List of page names that contain JavaScript + protected $scripts = []; + + // Group of module + protected $group; + + /** + * @param array $options For back-compat, this can be omitted in favour of overwriting getPages. + */ + public function __construct( array $options = null ) { + if ( is_null( $options ) ) { + return; + } + + foreach ( $options as $member => $option ) { + switch ( $member ) { + case 'styles': + case 'scripts': + case 'group': + case 'targets': + $this->{$member} = $option; + break; + } + } + } + + /** + * Subclasses should return an associative array of resources in the module. + * Keys should be the title of a page in the MediaWiki or User namespace. + * + * Values should be a nested array of options. The supported keys are 'type' and + * (CSS only) 'media'. + * + * For scripts, 'type' should be 'script'. + * + * For stylesheets, 'type' should be 'style'. + * There is an optional media key, the value of which can be the + * medium ('screen', 'print', etc.) of the stylesheet. + * + * @param ResourceLoaderContext $context + * @return array + */ + protected function getPages( ResourceLoaderContext $context ) { + $config = $this->getConfig(); + $pages = []; + + // Filter out pages from origins not allowed by the current wiki configuration. + if ( $config->get( 'UseSiteJs' ) ) { + foreach ( $this->scripts as $script ) { + $pages[$script] = [ 'type' => 'script' ]; + } + } + + if ( $config->get( 'UseSiteCss' ) ) { + foreach ( $this->styles as $style ) { + $pages[$style] = [ 'type' => 'style' ]; + } + } + + return $pages; + } + + /** + * Get group name + * + * @return string + */ + public function getGroup() { + return $this->group; + } + + /** + * Get the Database object used in getTitleInfo(). + * + * Defaults to the local replica DB. Subclasses may want to override this to return a foreign + * database object, or null if getTitleInfo() shouldn't access the database. + * + * NOTE: This ONLY works for getTitleInfo() and isKnownEmpty(), NOT FOR ANYTHING ELSE. + * In particular, it doesn't work for getContent() or getScript() etc. + * + * @return IDatabase|null + */ + protected function getDB() { + return wfGetDB( DB_REPLICA ); + } + + /** + * @param string $titleText + * @return null|string + */ + protected function getContent( $titleText ) { + $title = Title::newFromText( $titleText ); + if ( !$title ) { + return null; // Bad title + } + + // If the page is a redirect, follow the redirect. + if ( $title->isRedirect() ) { + $content = $this->getContentObj( $title ); + $title = $content ? $content->getUltimateRedirectTarget() : null; + if ( !$title ) { + return null; // Dead redirect + } + } + + $handler = ContentHandler::getForTitle( $title ); + if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) { + $format = CONTENT_FORMAT_CSS; + } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) { + $format = CONTENT_FORMAT_JAVASCRIPT; + } else { + return null; // Bad content model + } + + $content = $this->getContentObj( $title ); + if ( !$content ) { + return null; // No content found + } + + return $content->serialize( $format ); + } + + /** + * @param Title $title + * @return Content|null + */ + protected function getContentObj( Title $title ) { + $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); + if ( !$revision ) { + return null; + } + $content = $revision->getContent( Revision::RAW ); + if ( !$content ) { + wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' ); + return null; + } + return $content; + } + + /** + * @param ResourceLoaderContext $context + * @return string JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + $scripts = ''; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'script' ) { + continue; + } + $script = $this->getContent( $titleText ); + if ( strval( $script ) !== '' ) { + $script = $this->validateScriptFile( $titleText, $script ); + $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n"; + } + } + return $scripts; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getStyles( ResourceLoaderContext $context ) { + $styles = []; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'style' ) { + continue; + } + $media = isset( $options['media'] ) ? $options['media'] : 'all'; + $style = $this->getContent( $titleText ); + if ( strval( $style ) === '' ) { + continue; + } + if ( $this->getFlip( $context ) ) { + $style = CSSJanus::transform( $style, true, false ); + } + $style = MemoizedCallable::call( 'CSSMin::remap', + [ $style, false, $this->getConfig()->get( 'ScriptPath' ), true ] ); + if ( !isset( $styles[$media] ) ) { + $styles[$media] = []; + } + $style = ResourceLoader::makeComment( $titleText ) . $style; + $styles[$media][] = $style; + } + return $styles; + } + + /** + * Disable module content versioning. + * + * This class does not support generating content outside of a module + * request due to foreign database support. + * + * See getDefinitionSummary() for meta-data versioning. + * + * @return bool + */ + public function enableModuleContentVersion() { + return false; + } + + /** + * @param ResourceLoaderContext $context + * @return array + */ + public function getDefinitionSummary( ResourceLoaderContext $context ) { + $summary = parent::getDefinitionSummary( $context ); + $summary[] = [ + 'pages' => $this->getPages( $context ), + // Includes meta data of current revisions + 'titleInfo' => $this->getTitleInfo( $context ), + ]; + return $summary; + } + + /** + * @param ResourceLoaderContext $context + * @return bool + */ + public function isKnownEmpty( ResourceLoaderContext $context ) { + $revisions = $this->getTitleInfo( $context ); + + // For user modules, don't needlessly load if there are no non-empty pages + if ( $this->getGroup() === 'user' ) { + foreach ( $revisions as $revision ) { + if ( $revision['page_len'] > 0 ) { + // At least one non-empty page, module should be loaded + return false; + } + } + return true; + } + + // T70488: For other modules (i.e. ones that are called in cached html output) only check + // page existance. This ensures that, if some pages in a module are temporarily blanked, + // we don't end omit the module's script or link tag on some pages. + return count( $revisions ) === 0; + } + + private function setTitleInfo( $key, array $titleInfo ) { + $this->titleInfo[$key] = $titleInfo; + } + + /** + * Get the information about the wiki pages for a given context. + * @param ResourceLoaderContext $context + * @return array Keyed by page name + */ + protected function getTitleInfo( ResourceLoaderContext $context ) { + $dbr = $this->getDB(); + if ( !$dbr ) { + // We're dealing with a subclass that doesn't have a DB + return []; + } + + $pageNames = array_keys( $this->getPages( $context ) ); + sort( $pageNames ); + $key = implode( '|', $pageNames ); + if ( !isset( $this->titleInfo[$key] ) ) { + $this->titleInfo[$key] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ ); + } + return $this->titleInfo[$key]; + } + + protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) { + $titleInfo = []; + $batch = new LinkBatch; + foreach ( $pages as $titleText ) { + $title = Title::newFromText( $titleText ); + if ( $title ) { + // Page name may be invalid if user-provided (e.g. gadgets) + $batch->addObj( $title ); + } + } + if ( !$batch->isEmpty() ) { + $res = $db->select( 'page', + // Include page_touched to allow purging if cache is poisoned (T117587, T113916) + [ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ], + $batch->constructSet( 'page', $db ), + $fname + ); + foreach ( $res as $row ) { + // Avoid including ids or timestamps of revision/page tables so + // that versions are not wasted + $title = Title::makeTitle( $row->page_namespace, $row->page_title ); + $titleInfo[$title->getPrefixedText()] = [ + 'page_len' => $row->page_len, + 'page_latest' => $row->page_latest, + 'page_touched' => $row->page_touched, + ]; + } + } + return $titleInfo; + } + + /** + * @since 1.28 + * @param ResourceLoaderContext $context + * @param IDatabase $db + * @param string[] $moduleNames + */ + public static function preloadTitleInfo( + ResourceLoaderContext $context, IDatabase $db, array $moduleNames + ) { + $rl = $context->getResourceLoader(); + // getDB() can be overridden to point to a foreign database. + // For now, only preload local. In the future, we could preload by wikiID. + $allPages = []; + /** @var ResourceLoaderWikiModule[] $wikiModules */ + $wikiModules = []; + foreach ( $moduleNames as $name ) { + $module = $rl->getModule( $name ); + if ( $module instanceof self ) { + $mDB = $module->getDB(); + // Subclasses may disable getDB and implement getTitleInfo differently + if ( $mDB && $mDB->getDomainID() === $db->getDomainID() ) { + $wikiModules[] = $module; + $allPages += $module->getPages( $context ); + } + } + } + + if ( !$wikiModules ) { + // Nothing to preload + return; + } + + $pageNames = array_keys( $allPages ); + sort( $pageNames ); + $hash = sha1( implode( '|', $pageNames ) ); + + // Avoid Zend bug where "static::" does not apply LSB in the closure + $func = [ static::class, 'fetchTitleInfo' ]; + $fname = __METHOD__; + + $cache = ObjectCache::getMainWANInstance(); + $allInfo = $cache->getWithSetCallback( + $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID(), $hash ), + $cache::TTL_HOUR, + function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) { + $setOpts += Database::getCacheSetOptions( $db ); + + return call_user_func( $func, $db, $pageNames, $fname ); + }, + [ + 'checkKeys' => [ + $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID() ) ] + ] + ); + + foreach ( $wikiModules as $wikiModule ) { + $pages = $wikiModule->getPages( $context ); + // Before we intersect, map the names to canonical form (T145673). + $intersect = []; + foreach ( $pages as $page => $unused ) { + $title = Title::newFromText( $page ); + if ( $title ) { + $intersect[ $title->getPrefixedText() ] = 1; + } else { + // Page name may be invalid if user-provided (e.g. gadgets) + $rl->getLogger()->info( + 'Invalid wiki page title "{title}" in ' . __METHOD__, + [ 'title' => $page ] + ); + } + } + $info = array_intersect_key( $allInfo, $intersect ); + $pageNames = array_keys( $pages ); + sort( $pageNames ); + $key = implode( '|', $pageNames ); + $wikiModule->setTitleInfo( $key, $info ); + } + } + + /** + * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on + * page change if it was a JS or CSS page + * + * @param Title $title + * @param Revision|null $old Prior page revision + * @param Revision|null $new New page revision + * @param string $wikiId + * @since 1.28 + */ + public static function invalidateModuleCache( + Title $title, Revision $old = null, Revision $new = null, $wikiId + ) { + static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ]; + + if ( $old && in_array( $old->getContentFormat(), $formats ) ) { + $purge = true; + } elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) { + $purge = true; + } else { + $purge = ( $title->isSiteConfigPage() || $title->isUserConfigPage() ); + } + + if ( $purge ) { + $cache = ObjectCache::getMainWANInstance(); + $key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $wikiId ); + $cache->touchCheckKey( $key ); + } + } + + /** + * @since 1.28 + * @return string + */ + public function getType() { + // Check both because subclasses don't always pass pages via the constructor, + // they may also override getPages() instead, in which case we should keep + // defaulting to LOAD_GENERAL and allow them to override getType() separately. + return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL; + } +} |