diff options
author | Tim Starling <tstarling@wikimedia.org> | 2022-05-06 19:09:56 +1000 |
---|---|---|
committer | Krinkle <krinkle@fastmail.com> | 2022-05-24 15:41:46 +0000 |
commit | 3e2653f83bc096889d8b69d1e01a52d7de42b247 (patch) | |
tree | 2b87e1d578790776fa139b89f561695666bf1591 /includes/ResourceLoader | |
parent | 62c75f78f4e5b19a727571a6633b7091b84fc651 (diff) | |
download | mediawikicore-3e2653f83bc096889d8b69d1e01a52d7de42b247.tar.gz mediawikicore-3e2653f83bc096889d8b69d1e01a52d7de42b247.zip |
ResourceLoader namespace (attempt 2)
Move ResourceLoader classes to their own namespace. Strip the
"ResourceLoader" prefix from all except ResourceLoader itself.
Move the tests by analogy.
I used a namespace alias "RL" in some callers since RL\Module is less
ambiguous at the call site than just "Module".
I did not address DependencyStore which continues to have a non-standard
location and namespace.
Revert of a241d83e0a6dabedf.
Bug: T308718
Change-Id: Id08a220e1d6085e2b33f3f6c9d0e3935a4204659
Diffstat (limited to 'includes/ResourceLoader')
42 files changed, 11741 insertions, 0 deletions
diff --git a/includes/ResourceLoader/CircularDependencyError.php b/includes/ResourceLoader/CircularDependencyError.php new file mode 100644 index 000000000000..99835ebcac10 --- /dev/null +++ b/includes/ResourceLoader/CircularDependencyError.php @@ -0,0 +1,33 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Exception; + +/** + * @ingroup ResourceLoader + * @internal For use by ResourceLoaderStartUpModule only + */ +class CircularDependencyError extends Exception { +} + +/** @deprecated since 1.39 */ +class_alias( CircularDependencyError::class, 'ResourceLoaderCircularDependencyError' ); diff --git a/includes/ResourceLoader/ClientHtml.php b/includes/ResourceLoader/ClientHtml.php new file mode 100644 index 000000000000..630d10f632c5 --- /dev/null +++ b/includes/ResourceLoader/ClientHtml.php @@ -0,0 +1,503 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Html; +use Wikimedia\WrappedString; +use Wikimedia\WrappedStringList; + +/** + * Load and configure a ResourceLoader client on an HTML page. + * + * @ingroup ResourceLoader + * @since 1.28 + */ +class ClientHtml { + /** @var Context */ + private $context; + + /** @var ResourceLoader */ + private $resourceLoader; + + /** @var array<string,string|null|false> */ + private $options; + + /** @var array<string,mixed> */ + private $config = []; + + /** @var string[] */ + private $modules = []; + + /** @var string[] */ + private $moduleStyles = []; + + /** @var array<string,string> */ + private $exemptStates = []; + + /** @var array */ + private $data; + + /** + * @param Context $context + * @param array $options [optional] Array of options + * - 'target': Parameter for modules=startup request, see StartUpModule. + * - 'safemode': Parameter for modules=startup request, see StartUpModule. + * - 'nonce': From OutputPage->getCSP->getNonce(). + */ + public function __construct( Context $context, array $options = [] ) { + $this->context = $context; + $this->resourceLoader = $context->getResourceLoader(); + $this->options = $options + [ + 'target' => null, + 'safemode' => null, + 'nonce' => null, + ]; + } + + /** + * Set mw.config variables. + * + * @param array $vars Array of key/value pairs + */ + public function setConfig( array $vars ): void { + foreach ( $vars as $key => $value ) { + $this->config[$key] = $value; + } + } + + /** + * Ensure one or more modules are loaded. + * + * @param string[] $modules Array of module names + */ + public function setModules( array $modules ): void { + $this->modules = $modules; + } + + /** + * Ensure the styles of one or more modules are loaded. + * + * @param string[] $modules Array of module names + */ + public function setModuleStyles( array $modules ): void { + $this->moduleStyles = $modules; + } + + /** + * Set state of special modules that are handled by the caller manually. + * + * See OutputPage::buildExemptModules() for use cases. + * + * @param array<string,string> $states Module state keyed by module name + */ + public function setExemptStates( array $states ): void { + $this->exemptStates = $states; + } + + private function getData(): array { + if ( $this->data ) { + // @codeCoverageIgnoreStart + return $this->data; + // @codeCoverageIgnoreEnd + } + + $rl = $this->resourceLoader; + $data = [ + 'states' => [ + // moduleName => state + ], + 'general' => [], + 'styles' => [], + // Embedding for private modules + 'embed' => [ + 'styles' => [], + 'general' => [], + ], + // Deprecations for style-only modules + 'styleDeprecations' => [], + ]; + + foreach ( $this->modules as $name ) { + $module = $rl->getModule( $name ); + if ( !$module ) { + continue; + } + + $group = $module->getGroup(); + $context = $this->getContext( $group, Module::TYPE_COMBINED ); + $shouldEmbed = $module->shouldEmbedModule( $this->context ); + + if ( ( $group === Module::GROUP_USER || $shouldEmbed ) && + $module->isKnownEmpty( $context ) ) { + // This is a user-specific or embedded module, which means its output + // can be specific to the current page or user. As such, we can optimise + // the way we load it based on the current version of the module. + // Avoid needless embed for empty module, preset ready state. + $data['states'][$name] = 'ready'; + } elseif ( $group === Module::GROUP_USER || $shouldEmbed ) { + // - For group=user: We need to provide a pre-generated load.php + // url to the client that has the 'user' and 'version' parameters + // filled in. Without this, the client would wrongly use the static + // version hash, per T64602. + // - For shouldEmbed=true: 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() !== Module::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. + // + // Optimization: Exclude state for "noscript" modules. Since these are also excluded + // from the startup registry, no need to send their states (T291735). + $group = $module->getGroup(); + if ( $group !== Module::GROUP_NOSCRIPT ) { + $data['states'][$name] = 'ready'; + } + + $context = $this->getContext( $group, Module::TYPE_STYLES ); + if ( $module->shouldEmbedModule( $this->context ) ) { + // Avoid needless embed for private embeds we know are empty. + // (Set "ready" state directly instead, which we do a few lines above.) + if ( !$module->isKnownEmpty( $context ) ) { + // Embed via <style> element + $data['embed']['styles'][] = $name; + } + // For other style modules, always request them, regardless of whether they are + // currently known to be empty. Because: + // 1. Those modules are requested in batch, so there is no extra request overhead + // or extra HTML element to be avoided. + // 2. Checking isKnownEmpty for those can be expensive and slow down page view + // generation (T230260). + // 3. We don't want cached HTML to vary on the current state of a module. + // If the module becomes non-empty a few minutes later, it should start working + // on cached HTML without requiring a purge. + // + // But, user-specific modules: + // * ... are used on page views not publicly cached. + // * ... are in their own group and thus a require a request we can avoid + // * ... have known-empty status preloaded by ResourceLoader. + } elseif ( $group !== Module::GROUP_USER || !$module->isKnownEmpty( $context ) ) { + // Load from load.php?only=styles via <link rel=stylesheet> + $data['styles'][] = $name; + } + $deprecation = $module->getDeprecationInformation( $context ); + if ( $deprecation ) { + $data['styleDeprecations'][] = $deprecation; + } + } + + return $data; + } + + /** + * @return array<string,string> Attributes 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 preceding stylesheets. + * - Async scripts are not blocked on stylesheets. + * - Inline scripts can't be asynchronous. + * - For styles, earlier is better. + * + * @param string|null $nojsClass Class name that caller uses on HTML document element + * @return string|WrappedStringList HTML + */ + public function getHeadHtml( $nojsClass = null ) { + $nonce = $this->options['nonce']; + $data = $this->getData(); + $chunks = []; + + // Change "client-nojs" class to client-js. This allows easy toggling of UI components. + // This must happen synchronously on every page view to avoid flashes of wrong content. + // See also startup/startup.js. + $nojsClass = $nojsClass ?? $this->getDocumentAttributes()['class']; + $jsClass = preg_replace( '/(^|\s)client-nojs(\s|$)/', '$1client-js$2', $nojsClass ); + $jsClassJson = $this->context->encodeJson( $jsClass ); + $script = " +document.documentElement.className = {$jsClassJson}; +"; + + // Inline script: Declare mw.config variables for this page. + if ( $this->config ) { + $confJson = $this->context->encodeJson( $this->config ); + $script .= " +RLCONF = {$confJson}; +"; + } + + // Inline script: Declare initial module states for this page. + $states = array_merge( $this->exemptStates, $data['states'] ); + if ( $states ) { + $stateJson = $this->context->encodeJson( $states ); + $script .= " +RLSTATE = {$stateJson}; +"; + } + + // Inline script: Declare general modules to load on this page. + if ( $data['general'] ) { + $pageModulesJson = $this->context->encodeJson( $data['general'] ); + $script .= " +RLPAGEMODULES = {$pageModulesJson}; +"; + } + + if ( !$this->context->getDebug() ) { + $script = ResourceLoader::filter( 'minify-js', $script, [ 'cache' => false ] ); + } + + $chunks[] = Html::inlineScript( $script, $nonce ); + + // Inline RLQ: Embedded modules + if ( $data['embed']['general'] ) { + $chunks[] = $this->getLoad( + $data['embed']['general'], + Module::TYPE_COMBINED, + $nonce + ); + } + + // External stylesheets (only=styles) + if ( $data['styles'] ) { + $chunks[] = $this->getLoad( + $data['styles'], + Module::TYPE_STYLES, + $nonce + ); + } + + // Inline stylesheets (embedded only=styles) + if ( $data['embed']['styles'] ) { + $chunks[] = $this->getLoad( + $data['embed']['styles'], + Module::TYPE_STYLES, + $nonce + ); + } + + // Async scripts. Once the startup is loaded, inline RLQ scripts will run. + // Pass-through a custom 'target' from OutputPage (T143066). + $startupQuery = [ 'raw' => '1' ]; + foreach ( [ 'target', 'safemode' ] as $param ) { + if ( $this->options[$param] !== null ) { + $startupQuery[$param] = (string)$this->options[$param]; + } + } + $chunks[] = $this->getLoad( + 'startup', + Module::TYPE_SCRIPTS, + $nonce, + $startupQuery + ); + + return WrappedString::join( "\n", $chunks ); + } + + /** + * @return string|WrappedStringList HTML + */ + public function getBodyHtml() { + $data = $this->getData(); + $chunks = []; + + // Deprecations for only=styles modules + if ( $data['styleDeprecations'] ) { + $chunks[] = ResourceLoader::makeInlineScript( + implode( '', $data['styleDeprecations'] ), + $this->options['nonce'] + ); + } + + return WrappedString::join( "\n", $chunks ); + } + + private function getContext( $group, $type ): Context { + return self::makeContext( $this->context, $group, $type ); + } + + private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) { + return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce ); + } + + private static function makeContext( Context $mainContext, $group, $type, + array $extraQuery = [] + ): DerivativeContext { + // Allow caller to setVersion() and setModules() + $ret = new DerivativeContext( $mainContext ); + // Set 'only' if not combined + $ret->setOnly( $type === Module::TYPE_COMBINED ? null : $type ); + // Remove user parameter in most cases + if ( $group !== Module::GROUP_USER && $group !== Module::GROUP_PRIVATE ) { + $ret->setUser( null ); + } + if ( isset( $extraQuery['raw'] ) ) { + $ret->setRaw( true ); + } + return $ret; + } + + /** + * Explicitly load or embed modules on a page. + * + * @param Context $mainContext + * @param string[] $modules One or more module names + * @param string $only Module TYPE_ class constant + * @param array $extraQuery [optional] Array with extra query parameters for the request + * @param string|null $nonce [optional] Content-Security-Policy nonce + * (from OutputPage->getCSP->getNonce()) + * @return string|WrappedStringList HTML + */ + public static function makeLoad( Context $mainContext, array $modules, $only, + array $extraQuery = [], $nonce = null + ) { + $rl = $mainContext->getResourceLoader(); + $chunks = []; + + // Sort module names so requests are more uniform + sort( $modules ); + + if ( $mainContext->getDebug() && count( $modules ) > 1 ) { + // Recursively call us for every item + foreach ( $modules as $name ) { + $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce ); + } + 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 ) ) { + $moduleSetNames = array_keys( $moduleSet ); + $context->setModules( $moduleSetNames ); + if ( $embed ) { + // Decide whether to use style or script element + if ( $only == Module::TYPE_STYLES ) { + $chunks[] = Html::inlineStyle( + $rl->makeModuleResponse( $context, $moduleSet ) + ); + } else { + $chunks[] = ResourceLoader::makeInlineScript( + $rl->makeModuleResponse( $context, $moduleSet ), + $nonce + ); + } + } else { + // 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 === Module::GROUP_USER ) { + $context->setVersion( $rl->makeVersionQuery( $context, $moduleSetNames ) ); + } + + // Must setModules() before createLoaderURL() + $url = $rl->createLoaderURL( $source, $context, $extraQuery ); + + // Decide whether to use 'style' or 'script' element + if ( $only === Module::TYPE_STYLES ) { + $chunk = Html::linkedStyle( $url ); + } elseif ( $context->getRaw() ) { + // This request is asking for the module to be delivered standalone, + // (aka "raw") without communicating to any mw.loader client. + // For: + // - startup (naturally because this is what will define mw.loader) + $chunk = Html::element( 'script', [ + 'async' => true, + 'src' => $url, + ] ); + } else { + $chunk = ResourceLoader::makeInlineScript( + 'mw.loader.load(' . $mainContext->encodeJson( $url ) . ');', + $nonce + ); + } + + if ( $group == Module::GROUP_NOSCRIPT ) { + $chunks[] = Html::rawElement( 'noscript', [], $chunk ); + } else { + $chunks[] = $chunk; + } + } + } + } + } + + return new WrappedStringList( "\n", $chunks ); + } +} + +/** @deprecated since 1.39 */ +class_alias( ClientHtml::class, 'ResourceLoaderClientHtml' ); diff --git a/includes/ResourceLoader/CodexModule.php b/includes/ResourceLoader/CodexModule.php new file mode 100644 index 000000000000..9cc0b208b9e2 --- /dev/null +++ b/includes/ResourceLoader/CodexModule.php @@ -0,0 +1,88 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Config; + +/** + * Module for codex that has direction-specific style files and a static helper function for + * embedding icons in package modules. + * + * @ingroup ResourceLoader + * @internal + */ +class CodexModule extends FileModule { + + protected $dirSpecificStyles = []; + + public function __construct( array $options = [], $localBasePath = null, $remoteBasePath = null ) { + if ( isset( $options['dirSpecificStyles'] ) ) { + $this->dirSpecificStyles = $options['dirSpecificStyles']; + } + + parent::__construct( $options, $localBasePath, $remoteBasePath ); + } + + public function getStyleFiles( Context $context ) { + // Add direction-specific styles + $dir = $context->getDirection(); + if ( isset( $this->dirSpecificStyles[ $dir ] ) ) { + $this->styles = array_merge( $this->styles, (array)$this->dirSpecificStyles[ $dir ] ); + // Empty dirSpecificStyles so we don't add them twice if getStyleFiles() is called twice + $this->dirSpecificStyles = []; + } + + return parent::getStyleFiles( $context ); + } + + /** + * Retrieve the specified icon definitions from codex-icons.json. Intended as a convenience + * function to be used in packageFiles definitions. + * + * Example: + * "packageFiles": [ + * { + * "name": "icons.json", + * "callback": "ResourceLoaderCodexModule::getIcons", + * "callbackParam": [ + * "cdxIconClear", + * "cdxIconTrash" + * ] + * } + * ] + * + * @param Context $context + * @param Config $config + * @param string[] $iconNames Names of icons to fetch + * @return array + */ + public static function getIcons( Context $context, Config $config, array $iconNames = [] ) { + global $IP; + static $allIcons = null; + if ( $allIcons === null ) { + $allIcons = json_decode( file_get_contents( "$IP/resources/lib/codex-icons/codex-icons.json" ), true ); + } + return array_intersect_key( $allIcons, array_flip( $iconNames ) ); + } +} + +/** @deprecated since 1.39 */ +class_alias( CodexModule::class, 'ResourceLoaderCodexModule' ); diff --git a/includes/ResourceLoader/Context.php b/includes/ResourceLoader/Context.php new file mode 100644 index 000000000000..bd8e5791f53d --- /dev/null +++ b/includes/ResourceLoader/Context.php @@ -0,0 +1,515 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Config; +use FauxRequest; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use MediaWiki\Page\PageReferenceValue; +use MediaWiki\User\UserIdentity; +use MediaWiki\User\UserRigorOptions; +use Message; +use MessageLocalizer; +use MessageSpecifier; +use Psr\Log\LoggerInterface; +use User; +use WebRequest; + +/** + * Context object that contains information about the state of a specific + * ResourceLoader web request. Passed around to Module methods. + * + * @ingroup ResourceLoader + * @since 1.17 + */ +class Context implements MessageLocalizer { + public const DEFAULT_LANG = 'qqx'; + public const DEFAULT_SKIN = 'fallback'; + + /** @internal For use in ResourceLoader classes. */ + public const DEBUG_OFF = 0; + /** @internal For use in ResourceLoader classes. */ + public const DEBUG_LEGACY = 1; + private const DEBUG_MAIN = 2; + + /** @var ResourceLoader */ + protected $resourceLoader; + /** @var WebRequest */ + protected $request; + /** @var LoggerInterface */ + protected $logger; + + // Module content vary + /** @var string */ + protected $skin; + /** @var string */ + protected $language; + /** @var int */ + protected $debug; + /** @var string|null */ + protected $user; + + // Request vary (in addition to cache vary) + /** @var string[] */ + protected $modules; + /** @var string|null */ + protected $only; + /** @var string|null */ + protected $version; + /** @var bool */ + protected $raw; + /** @var string|null */ + protected $image; + /** @var string|null */ + protected $variant; + /** @var string|null */ + protected $format; + + /** @var string|null */ + protected $direction; + /** @var string|null */ + protected $hash; + /** @var User|null */ + protected $userObj; + /** @var UserIdentity|null|false */ + protected $userIdentity = false; + /** @var Image|false */ + 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(); + + // Optimisation: Use WebRequest::getRawVal() instead of getVal(). We don't + // need the slow Language+UTF logic meant for user input here. (f303bb9360) + + // List of modules + $modules = $request->getRawVal( 'modules' ); + $this->modules = $modules ? ResourceLoader::expandModuleNames( $modules ) : []; + + // Various parameters + $this->user = $request->getRawVal( 'user' ); + $this->debug = self::debugFromString( $request->getRawVal( 'debug' ) ); + $this->only = $request->getRawVal( 'only' ); + $this->version = $request->getRawVal( 'version' ); + $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' ); + $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); + $skinnames = $skinFactory->getInstalledSkins(); + + if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) { + // The 'skin' parameter is required. (Not yet enforced.) + // For requests without a known skin specified, + // use MediaWiki's 'fallback' skin for skin-specific decisions. + $this->skin = self::DEFAULT_SKIN; + } + } + + /** + * @internal For use in ResourceLoader::inDebugMode + * @param string|null $debug + * @return int + */ + public static function debugFromString( ?string $debug ): int { + // The canonical way to enable debug mode is via debug=true + // This continues to map to v1 until v2 is ready (T85805). + if ( $debug === 'true' || $debug === '1' ) { + $ret = self::DEBUG_LEGACY; + } elseif ( $debug === '2' ) { + $ret = self::DEBUG_MAIN; + } else { + $ret = self::DEBUG_OFF; + } + + return $ret; + } + + /** + * Return a dummy Context object suitable for passing into + * things that don't "really" need a context. + * + * Use cases: + * - Unit tests (deprecated, create empty instance directly or use RLTestCase). + * + * @return Context + */ + public static function newDummyContext(): Context { + // This currently creates a non-empty instance of ResourceLoader (all modules registered), + // but that's probably not needed. So once that moves into ServiceWiring, this'll + // become more like the EmptyResourceLoader class we have in PHPUnit tests, which + // is what this should've had originally. If this turns out to be untrue, change to: + // `MediaWikiServices::getInstance()->getResourceLoader()` instead. + return new self( new ResourceLoader( + MediaWikiServices::getInstance()->getMainConfig(), + LoggerFactory::getInstance( 'resourceloader' ) + ), new FauxRequest( [] ) ); + } + + public function getResourceLoader(): ResourceLoader { + return $this->resourceLoader; + } + + /** + * @deprecated since 1.34 Use Module::getConfig instead inside module + * methods. Use ResourceLoader::getConfig elsewhere. + * @return Config + * @codeCoverageIgnore + */ + public function getConfig() { + wfDeprecated( __METHOD__, '1.34' ); + return $this->getResourceLoader()->getConfig(); + } + + public function getRequest(): WebRequest { + return $this->request; + } + + /** + * @deprecated since 1.34 Use Module::getLogger instead + * inside module methods. Use ResourceLoader::getLogger elsewhere. + * @since 1.27 + * @return LoggerInterface + */ + public function getLogger() { + return $this->logger; + } + + public function getModules(): array { + return $this->modules; + } + + public function getLanguage(): string { + 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', '' ); + '@phan-var string $lang'; // getRawVal does not return null here + // Stricter version of RequestContext::sanitizeLangCode() + $validBuiltinCode = MediaWikiServices::getInstance()->getLanguageNameUtils() + ->isValidBuiltInCode( $lang ); + if ( !$validBuiltinCode ) { + // The 'lang' parameter is required. (Not yet enforced.) + // If omitted, localise with the dummy language code. + $lang = self::DEFAULT_LANG; + } + $this->language = $lang; + } + return $this->language; + } + + public function getDirection(): string { + if ( $this->direction === null ) { + $direction = $this->getRequest()->getRawVal( 'dir' ); + if ( $direction === 'ltr' || $direction === 'rtl' ) { + $this->direction = $direction; + } else { + // Determine directionality based on user language (T8100) + $this->direction = MediaWikiServices::getInstance()->getLanguageFactory() + ->getLanguage( $this->getLanguage() )->getDir(); + } + } + return $this->direction; + } + + public function getSkin(): string { + return $this->skin; + } + + /** + * @return string|null + */ + public function getUser(): ?string { + 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 ...$params + * @return Message + */ + public function msg( $key, ...$params ): Message { + return wfMessage( $key, ...$params ) + // Do not use MediaWiki user language from session. Use the provided one instead. + ->inLanguage( $this->getLanguage() ) + // inLanguage() clears the interface flag, so we need re-enable it. (T291601) + ->setInterfaceMessageFlag( true ) + // Use a dummy title because there is no real title for this endpoint, and the cache won't + // vary on it anyways. + ->page( PageReferenceValue::localReference( NS_SPECIAL, 'Badtitle/ResourceLoaderContext' ) ); + } + + /** + * Get the possibly-cached UserIdentity object for the specified username + * + * This will be null on most requests, + * except for load.php requests that have a 'user' parameter set. + * + * @since 1.38 + * @return UserIdentity|null + */ + public function getUserIdentity(): ?UserIdentity { + if ( $this->userIdentity === false ) { + $username = $this->getUser(); + if ( $username === null ) { + // Anonymous user + $this->userIdentity = null; + } else { + // Use provided username if valid + $this->userIdentity = MediaWikiServices::getInstance() + ->getUserFactory() + ->newFromName( $username, UserRigorOptions::RIGOR_VALID ); + } + } + return $this->userIdentity; + } + + /** + * Get the possibly-cached User object for the specified username + * + * @since 1.25 + * @return User + */ + public function getUserObj(): User { + 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; + } + + public function getDebug(): int { + return $this->debug; + } + + /** + * @return string|null + */ + public function getOnly(): ?string { + return $this->only; + } + + /** + * @see Module::getVersionHash + * @see ClientHtml::makeLoad + * @return string|null + */ + public function getVersion(): ?string { + return $this->version; + } + + public function getRaw(): bool { + return $this->raw; + } + + /** + * @return string|null + */ + public function getImage(): ?string { + return $this->image; + } + + /** + * @return string|null + */ + public function getVariant(): ?string { + return $this->variant; + } + + /** + * @return string|null + */ + public function getFormat(): ?string { + return $this->format; + } + + /** + * If this is a request for an image, get the Image object. + * + * @since 1.25 + * @return Image|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 ImageModule ) { + return $this->imageObj; + } + + $image = $module->getImage( $this->image, $this ); + if ( !$image ) { + return $this->imageObj; + } + + $this->imageObj = $image; + } + + return $this->imageObj; + } + + /** + * Return the replaced-content mapping callback + * + * When editing a page that's used to generate the scripts or styles of a + * WikiModule, a preview should use the to-be-saved version of + * the page rather than the current version in the database. A context + * supporting such previews should return a callback to return these + * mappings here. + * + * @since 1.32 + * @return callable|null Signature is `Content|null func( Title $t )` + */ + public function getContentOverrideCallback() { + return null; + } + + public function shouldIncludeScripts(): bool { + return $this->getOnly() === null || $this->getOnly() === 'scripts'; + } + + public function shouldIncludeStyles(): bool { + return $this->getOnly() === null || $this->getOnly() === 'styles'; + } + + public function shouldIncludeMessages(): bool { + 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 response output. + * + * @return string + */ + public function getHash(): string { + if ( $this->hash === null ) { + $this->hash = implode( '|', [ + // Module content vary + $this->getLanguage(), + $this->getSkin(), + (string)$this->getDebug(), + $this->getUser() ?? '', + // Request vary + $this->getOnly() ?? '', + $this->getVersion() ?? '', + (string)$this->getRaw(), + $this->getImage() ?? '', + $this->getVariant() ?? '', + $this->getFormat() ?? '', + ] ); + } + return $this->hash; + } + + /** + * Get the request base parameters, omitting any defaults. + * + * @internal For use by StartUpModule only + * @return string[] + */ + public function getReqBase(): array { + $reqBase = []; + $lang = $this->getLanguage(); + if ( $lang !== self::DEFAULT_LANG ) { + $reqBase['lang'] = $lang; + } + $skin = $this->getSkin(); + if ( $skin !== self::DEFAULT_SKIN ) { + $reqBase['skin'] = $skin; + } + $debug = $this->getDebug(); + if ( $debug !== self::DEBUG_OFF ) { + $reqBase['debug'] = strval( $debug ); + } + return $reqBase; + } + + /** + * Wrapper around json_encode that avoids needless escapes, + * and pretty-prints in debug mode. + * + * @since 1.34 + * @param mixed $data + * @return string|false JSON string, false on error + */ + public function encodeJson( $data ) { + // Keep output as small as possible by disabling needless escape modes + // that PHP uses by default. + // However, while most module scripts are only served on HTTP responses + // for JavaScript, some modules can also be embedded in the HTML as inline + // scripts. This, and the fact that we sometimes need to export strings + // containing user-generated content and labels that may genuinely contain + // a sequences like "</script>", we need to encode either '/' or '<'. + // By default PHP escapes '/'. Let's escape '<' instead which is less common + // and allows URLs to mostly remain readable. + $jsonFlags = JSON_UNESCAPED_SLASHES | + JSON_UNESCAPED_UNICODE | + JSON_HEX_TAG | + JSON_HEX_AMP; + if ( $this->getDebug() ) { + $jsonFlags |= JSON_PRETTY_PRINT; + } + return json_encode( $data, $jsonFlags ); + } +} + +/** @deprecated since 1.39 */ +class_alias( Context::class, 'ResourceLoaderContext' ); diff --git a/includes/ResourceLoader/DerivativeContext.php b/includes/ResourceLoader/DerivativeContext.php new file mode 100644 index 000000000000..0fa0f8515ecc --- /dev/null +++ b/includes/ResourceLoader/DerivativeContext.php @@ -0,0 +1,266 @@ +<?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 Kunal Mehta + */ + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MediaWikiServices; +use MediaWiki\User\UserIdentity; +use MediaWiki\User\UserRigorOptions; +use User; +use WebRequest; + +/** + * A mutable version of Context. + * + * Allows changing specific properties of a context object, + * without changing the main one. Inspired by MediaWiki's DerivativeContext. + * + * @ingroup ResourceLoader + * @since 1.24 + */ +class DerivativeContext extends Context { + private const INHERIT_VALUE = -1; + + /** + * @var Context + */ + private $context; + + /** @var int|string[] */ + protected $modules = self::INHERIT_VALUE; + /** @var int|string */ + protected $language = self::INHERIT_VALUE; + /** @var int|string|null */ + protected $direction = self::INHERIT_VALUE; + /** @var int|string */ + protected $skin = self::INHERIT_VALUE; + /** @var int|string|null */ + protected $user = self::INHERIT_VALUE; + /** @var int|UserIdentity|null|false */ + protected $userIdentity = self::INHERIT_VALUE; + /** @var int|User|null */ + protected $userObj = self::INHERIT_VALUE; + /** @var int */ + protected $debug = self::INHERIT_VALUE; + /** @var int|string|null */ + protected $only = self::INHERIT_VALUE; + /** @var int|string|null */ + protected $version = self::INHERIT_VALUE; + /** @var int|bool */ + protected $raw = self::INHERIT_VALUE; + /** @var int|callable|null */ + protected $contentOverrideCallback = self::INHERIT_VALUE; + + public function __construct( Context $context ) { + $this->context = $context; + } + + public function getModules(): array { + 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(): string { + if ( $this->language === self::INHERIT_VALUE ) { + return $this->context->getLanguage(); + } + return $this->language; + } + + public function setLanguage( string $language ) { + $this->language = $language; + // Invalidate direction since it is based on language + $this->direction = null; + $this->hash = null; + } + + public function getDirection(): string { + if ( $this->direction === self::INHERIT_VALUE ) { + return $this->context->getDirection(); + } + if ( $this->direction === null ) { + $this->direction = MediaWikiServices::getInstance()->getLanguageFactory() + ->getLanguage( $this->getLanguage() )->getDir(); + } + return $this->direction; + } + + public function setDirection( string $direction ) { + $this->direction = $direction; + $this->hash = null; + } + + public function getSkin(): string { + if ( $this->skin === self::INHERIT_VALUE ) { + return $this->context->getSkin(); + } + return $this->skin; + } + + public function setSkin( string $skin ) { + $this->skin = $skin; + $this->hash = null; + } + + public function getUser(): ?string { + if ( $this->user === self::INHERIT_VALUE ) { + return $this->context->getUser(); + } + return $this->user; + } + + public function getUserIdentity(): ?UserIdentity { + if ( $this->userIdentity === self::INHERIT_VALUE ) { + return $this->context->getUserIdentity(); + } + if ( $this->userIdentity === false ) { + $username = $this->getUser(); + if ( $username === null ) { + // Anonymous user + $this->userIdentity = null; + } else { + // Use provided username if valid + $this->userIdentity = MediaWikiServices::getInstance() + ->getUserFactory() + ->newFromName( $username, UserRigorOptions::RIGOR_VALID ); + } + } + return $this->userIdentity; + } + + public function getUserObj(): User { + if ( $this->userObj === self::INHERIT_VALUE ) { + return $this->context->getUserObj(); + } + if ( $this->userObj === null ) { + $username = $this->getUser(); + if ( $username ) { + $this->userObj = User::newFromName( $username ) ?: new User; + } else { + $this->userObj = new User; + } + } + return $this->userObj; + } + + /** + * @param string|null $user + */ + public function setUser( ?string $user ) { + $this->user = $user; + $this->hash = null; + // Clear getUserObj cache + $this->userObj = null; + $this->userIdentity = false; + } + + public function getDebug(): int { + if ( $this->debug === self::INHERIT_VALUE ) { + return $this->context->getDebug(); + } + return $this->debug; + } + + public function setDebug( int $debug ) { + $this->debug = $debug; + $this->hash = null; + } + + public function getOnly(): ?string { + if ( $this->only === self::INHERIT_VALUE ) { + return $this->context->getOnly(); + } + return $this->only; + } + + /** + * @param string|null $only + */ + public function setOnly( ?string $only ) { + $this->only = $only; + $this->hash = null; + } + + public function getVersion(): ?string { + if ( $this->version === self::INHERIT_VALUE ) { + return $this->context->getVersion(); + } + return $this->version; + } + + /** + * @param string|null $version + */ + public function setVersion( ?string $version ) { + $this->version = $version; + $this->hash = null; + } + + public function getRaw(): bool { + if ( $this->raw === self::INHERIT_VALUE ) { + return $this->context->getRaw(); + } + return $this->raw; + } + + public function setRaw( bool $raw ) { + $this->raw = $raw; + } + + public function getRequest(): WebRequest { + return $this->context->getRequest(); + } + + public function getResourceLoader(): ResourceLoader { + return $this->context->getResourceLoader(); + } + + public function getContentOverrideCallback() { + if ( $this->contentOverrideCallback === self::INHERIT_VALUE ) { + return $this->context->getContentOverrideCallback(); + } + return $this->contentOverrideCallback; + } + + /** + * @see self::getContentOverrideCallback + * @since 1.32 + * @param callable|null|int $callback As per self::getContentOverrideCallback, + * or self::INHERIT_VALUE + */ + public function setContentOverrideCallback( $callback ) { + $this->contentOverrideCallback = $callback; + } + +} + +/** @deprecated since 1.39 */ +class_alias( DerivativeContext::class, 'DerivativeResourceLoaderContext' ); diff --git a/includes/ResourceLoader/FileModule.php b/includes/ResourceLoader/FileModule.php new file mode 100644 index 000000000000..63fa2d501603 --- /dev/null +++ b/includes/ResourceLoader/FileModule.php @@ -0,0 +1,1489 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use CSSJanus; +use Exception; +use ExtensionRegistry; +use FileContentsHasher; +use InvalidArgumentException; +use LogicException; +use MediaWiki\Languages\LanguageFallback; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use ObjectCache; +use OutputPage; +use RuntimeException; +use Wikimedia\Minify\CSSMin; +use Wikimedia\RequestTimeout\TimeoutException; + +/** + * Module based on local JavaScript/CSS files. + * + * The following public methods can query the database: + * + * - getDefinitionSummary / … / Module::getFileDependencies. + * - getVersionHash / getDefinitionSummary / … / Module::getFileDependencies. + * - getStyles / Module::saveFileDependencies. + * + * @ingroup ResourceLoader + * @see $wgResourceModules + * @since 1.17 + */ +class FileModule extends Module { + /** @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 packaged files to make available through require() + * @par Usage: + * @code + * [ [file-path-or-object], [file-path-or-object], ... ] + * @endcode + */ + protected $packageFiles = null; + + /** + * @var array Expanded versions of $packageFiles, lazy-computed by expandPackageFiles(); + * keyed by context hash + */ + private $expandedPackageFiles = []; + + /** + * @var array Further expanded versions of $expandedPackageFiles, lazy-computed by + * getPackageFiles(); keyed by context hash + */ + private $fullyExpandedPackageFiles = []; + + /** + * @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 string[] */ + protected $targets = [ 'desktop' ]; + + /** @var bool Whether CSSJanus flipping should be skipped for this module */ + protected $noflip = false; + + /** @var bool Whether this module requires the client to support ES6 */ + protected $es6 = 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 = []; + + /** + * @var VueComponentParser|null Lazy-created by getVueComponentParser() + */ + protected $vueComponentParser = null; + + /** + * Constructs a new module from an options array. + * + * @param array $options See $wgResourceModules for the available options. + * @param string|null $localBasePath Base path to prepend to all local paths in $options. + * Defaults to $IP + * @param string|null $remoteBasePath Base path to prepend to all remote paths in $options. + * Defaults to $wgResourceBasePath + * @throws InvalidArgumentException + * @see $wgResourceModules + */ + public function __construct( + array $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': + case 'packageFiles': + $this->{$member} = is_array( $option ) ? $option : [ $option ]; + break; + case 'templates': + $hasTemplates = true; + $this->{$member} = is_array( $option ) ? $option : [ $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] = is_array( $value ) ? $value : [ $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 'noflip': + case 'es6': + $this->{$member} = (bool)$option; + break; + } + } + if ( isset( $options['scripts'] ) && isset( $options['packageFiles'] ) ) { + throw new InvalidArgumentException( "A module may not set both 'scripts' and 'packageFiles'" ); + } + if ( isset( $options['packageFiles'] ) && isset( $options['skinScripts'] ) ) { + throw new InvalidArgumentException( "Options 'skinScripts' and 'packageFiles' cannot be used together." ); + } + if ( $hasTemplates ) { + $this->dependencies[] = 'mediawiki.template'; + // Ensure relevant template compiler module gets loaded + foreach ( $this->templates as $alias => $templatePath ) { + if ( is_int( $alias ) ) { + $alias = $this->getPath( $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|null $localBasePath Path to use if not provided in module definition. Defaults + * to $IP + * @param string|null $remoteBasePath Path to use if not provided in module definition. Defaults + * to $wgResourceBasePath + * @return string[] [ localBasePath, remoteBasePath ] + */ + public static function extractBasePaths( + array $options = [], + $localBasePath = null, + $remoteBasePath = null + ) { + global $IP; + // 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 = MediaWikiServices::getInstance()->getMainConfig() + ->get( MainConfigNames::ResourceBasePath ); + } + + if ( isset( $options['remoteExtPath'] ) ) { + $extensionAssetsPath = MediaWikiServices::getInstance()->getMainConfig() + ->get( MainConfigNames::ExtensionAssetsPath ); + $remoteBasePath = $extensionAssetsPath . '/' . $options['remoteExtPath']; + } + + if ( isset( $options['remoteSkinPath'] ) ) { + $stylePath = MediaWikiServices::getInstance()->getMainConfig() + ->get( MainConfigNames::StylePath ); + $remoteBasePath = $stylePath . '/' . $options['remoteSkinPath']; + } + + if ( array_key_exists( 'localBasePath', $options ) ) { + $localBasePath = (string)$options['localBasePath']; + } + + if ( array_key_exists( 'remoteBasePath', $options ) ) { + $remoteBasePath = (string)$options['remoteBasePath']; + } + + if ( $remoteBasePath === '' ) { + // If MediaWiki is installed at the document root (not recommended), + // then wgScriptPath is set to the empty string by the installer to + // ensure safe concatenating of file paths (avoid "/" + "/foo" being "//foo"). + // However, this also means the path itself can be an invalid URI path, + // as those must start with a slash. Within ResourceLoader, we will not + // do such primitive/unsafe slash concatenation and use URI resolution + // instead, so beyond this point, to avoid fatal errors in CSSMin::resolveUrl(), + // do a best-effort support for docroot installs by casting this to a slash. + $remoteBasePath = '/'; + } + + return [ $localBasePath, $remoteBasePath ]; + } + + /** + * Gets all scripts for a given context concatenated together. + * + * @param Context $context Context in which to generate script + * @return string|array JavaScript code for $context, or package files data structure + */ + public function getScript( Context $context ) { + $deprecationScript = $this->getDeprecationInformation( $context ); + $packageFiles = $this->getPackageFiles( $context ); + if ( $packageFiles !== null ) { + foreach ( $packageFiles['files'] as &$file ) { + if ( $file['type'] === 'script+style' ) { + $file['content'] = $file['content']['script']; + $file['type'] = 'script'; + } + } + if ( $deprecationScript ) { + $mainFile =& $packageFiles['files'][$packageFiles['main']]; + $mainFile['content'] = $deprecationScript . $mainFile['content']; + } + return $packageFiles; + } + + $files = $this->getScriptFiles( $context ); + return $deprecationScript . $this->readScriptFiles( $files ); + } + + /** + * @param Context $context + * @return string[] + */ + public function getScriptURLsForDebug( Context $context ) { + $rl = $context->getResourceLoader(); + $config = $this->getConfig(); + $server = $config->get( MainConfigNames::Server ); + + $urls = []; + foreach ( $this->getScriptFiles( $context ) as $file ) { + $url = OutputPage::transformResourcePath( $config, $this->getRemotePath( $file ) ); + // Expand debug URL in case we are another wiki's module source (T255367) + $url = $rl->expandUrl( $server, $url ); + $urls[] = $url; + } + return $urls; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + // If package files are involved, don't support URL loading, because that breaks + // scoped require() functions + return $this->debugRaw && !$this->packageFiles; + } + + /** + * Get all styles for a given context. + * + * @param Context $context + * @return string[] CSS code for $context as an associative array mapping media type to CSS text. + */ + public function getStyles( Context $context ) { + $styles = $this->readStyleFiles( + $this->getStyleFiles( $context ), + $context + ); + + $packageFiles = $this->getPackageFiles( $context ); + if ( $packageFiles !== null ) { + foreach ( $packageFiles['files'] as $fileName => $file ) { + if ( $file['type'] === 'script+style' ) { + $style = $this->processStyle( + $file['content']['style'], + $file['content']['styleLang'], + $fileName, + $context + ); + $styles['all'] = ( $styles['all'] ?? '' ) . "\n" . $style; + } + } + } + + // Track indirect file dependencies so that StartUpModule can check for + // on-disk file changes to any of this files without having to recompute the file list + $this->saveFileDependencies( $context, $this->localFileRefs ); + + return $styles; + } + + /** + * @param Context $context + * @return string[][] + */ + public function getStyleURLsForDebug( Context $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 string[] 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 Context|null $context + * @return string[] List of module names + */ + public function getDependencies( Context $context = null ) { + return $this->dependencies; + } + + /** + * Helper method for getting a file. + * + * @param string $localPath The path to the resource to load + * @param string $type The type of resource being loaded (for error reporting only) + * @throws RuntimeException If the supplied path is not found, or not a path + * @return string + */ + private function getFileContents( $localPath, $type ) { + if ( !is_file( $localPath ) ) { + throw new RuntimeException( + __METHOD__ . ": $type file not found, or is not a file: \"$localPath\"" + ); + } + return $this->stripBom( file_get_contents( $localPath ) ); + } + + /** + * @return null|string + * @throws RuntimeException If the file doesn't exist + */ + public function getSkipFunction() { + if ( !$this->skipFunction ) { + return null; + } + $localPath = $this->getLocalPath( $this->skipFunction ); + return $this->getFileContents( $localPath, 'skip function' ); + } + + public function requiresES6() { + return $this->es6; + } + + /** + * 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 for getDefinitionSummary. + * + * @param Context $context + * @return string + */ + private function getFileHashes( Context $context ) { + $files = []; + + $styleFiles = $this->getStyleFiles( $context ); + foreach ( $styleFiles as $paths ) { + $files = array_merge( $files, $paths ); + } + + // Extract file paths for package files + // Optimisation: Use foreach() and isset() instead of array_map/array_filter. + // This is a hot code path, called by StartupModule for thousands of modules. + $expandedPackageFiles = $this->expandPackageFiles( $context ); + $packageFiles = []; + if ( $expandedPackageFiles ) { + foreach ( $expandedPackageFiles['files'] as $fileInfo ) { + if ( isset( $fileInfo['filePath'] ) ) { + $packageFiles[] = $fileInfo['filePath']; + } + } + } + + // Merge all the file paths we were able discover directly from the module definition. + // This is the master list of direct-dependent files for this module. + $files = array_merge( + $files, + $packageFiles, + $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; + } + + // Expand these local paths into absolute file paths + $files = array_map( [ $this, 'getLocalPath' ], $files ); + + // Add any lazily discovered file dependencies from previous module builds. + // These are added last because they are already absolute file paths. + $files = array_merge( $files, $this->getFileDependencies( $context ) ); + + // Filter out any duplicates. Typically introduced by getFileDependencies() which + // may lazily re-discover a master file. + $files = array_unique( $files ); + + // Don't return array keys or any other form of file path here, only the hashes. + // Including file paths would needlessly cause global cache invalidation when files + // move on disk or if e.g. the MediaWiki directory name changes. + // Anything where order is significant is already detected by the definition summary. + return FileContentsHasher::getFileContentsHash( $files ); + } + + /** + * Get the definition summary for this module. + * + * @param Context $context + * @return array + */ + public function getDefinitionSummary( Context $context ) { + $summary = parent::getDefinitionSummary( $context ); + + $options = []; + foreach ( [ + // The following properties are omitted because they don't affect the module response: + // - 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', + ] as $member ) { + $options[$member] = $this->{$member}; + } + + $packageFiles = $this->expandPackageFiles( $context ); + if ( $packageFiles ) { + // Extract the minimum needed: + // - The 'main' pointer (included as-is). + // - The 'files' array, simplified to only which files exist (the keys of + // this array), and something that represents their non-file content. + // For packaged files that reflect files directly from disk, the + // 'getFileHashes' method tracks their content already. + // It is important that the keys of the $packageFiles['files'] array + // are preserved, as they do affect the module output. + $packageFiles['files'] = array_map( static function ( $fileInfo ) { + return $fileInfo['definitionSummary'] ?? ( $fileInfo['content'] ?? null ); + }, $packageFiles['files'] ); + } + + $summary[] = [ + 'options' => $options, + 'packageFiles' => $packageFiles, + 'fileHashes' => $this->getFileHashes( $context ), + 'messageBlob' => $this->getMessageBlob( $context ), + ]; + + $lessVars = $this->getLessVars( $context ); + if ( $lessVars ) { + $summary[] = [ 'lessVars' => $lessVars ]; + } + + return $summary; + } + + /** + * @return VueComponentParser + */ + protected function getVueComponentParser() { + if ( $this->vueComponentParser === null ) { + $this->vueComponentParser = new VueComponentParser; + } + return $this->vueComponentParser; + } + + /** + * @param string|FilePath $path + * @return string + */ + protected function getPath( $path ) { + if ( $path instanceof FilePath ) { + return $path->getPath(); + } + + return $path; + } + + /** + * @param string|FilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof FilePath ) { + return $path->getLocalPath(); + } + + return "{$this->localBasePath}/$path"; + } + + /** + * @param string|FilePath $path + * @return string + */ + protected function getRemotePath( $path ) { + if ( $path instanceof FilePath ) { + return $path->getRemotePath(); + } + + if ( $this->remoteBasePath === '/' ) { + return "/$path"; + } else { + 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'; + } + + /** + * Infer the file type from a package file path. + * @param string $path + * @return string 'script', 'script-vue', or 'data' + */ + public static function getPackageFileType( $path ) { + if ( preg_match( '/\.json$/i', $path ) ) { + return 'data'; + } + if ( preg_match( '/\.vue$/i', $path ) ) { + return 'script-vue'; + } + return 'script'; + } + + /** + * 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 string[][] List of file paths, collated by $option + */ + protected static function collateFilePathListByOption( array $list, $option, $default ) { + $collatedFiles = []; + foreach ( $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 = $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 $list + * @param string|null $fallback Key to look for in $list if $key doesn't exist + * @return array List of elements from $list 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 script file paths for this module, in order of proper execution. + * + * @param Context $context + * @return string[] List of file paths + */ + private function getScriptFiles( Context $context ) { + // Execution order, as documented at $wgResourceModules: + // scripts, languageScripts, skinScripts, debugScripts. + $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 string[] + */ + private function getLanguageScripts( string $lang ): array { + $scripts = self::tryForKey( $this->languageScripts, $lang ); + if ( $scripts ) { + return $scripts; + } + + // Optimization: Avoid initialising and calling into language services + // for the majority of modules that don't use this option. + if ( $this->languageScripts ) { + $fallbacks = MediaWikiServices::getInstance() + ->getLanguageFallback() + ->getAll( $lang, LanguageFallback::MESSAGES ); + foreach ( $fallbacks as $lang ) { + $scripts = self::tryForKey( $this->languageScripts, $lang ); + if ( $scripts ) { + return $scripts; + } + } + } + + return []; + } + + public function setSkinStylesOverride( array $moduleSkinStyles ): void { + $moduleName = $this->getName(); + foreach ( $moduleSkinStyles as $skinName => $overrides ) { + // If a module provides overrides for a skin, and that skin also provides overrides + // for the same module, then the module has precedence. + if ( isset( $this->skinStyles[$skinName] ) ) { + continue; + } + + // If $moduleName in ResourceModuleSkinStyles is preceded with a '+', the defined style + // files will be added to 'default' skinStyles, otherwise 'default' will be ignored. + if ( isset( $overrides[$moduleName] ) ) { + $paths = (array)$overrides[$moduleName]; + $styleFiles = []; + } elseif ( isset( $overrides['+' . $moduleName] ) ) { + $paths = (array)$overrides['+' . $moduleName]; + $styleFiles = isset( $this->skinStyles['default'] ) ? + (array)$this->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 ) = self::extractBasePaths( $overrides ); + + foreach ( $paths as $path ) { + $styleFiles[] = new FilePath( $path, $localBasePath, $remoteBasePath ); + } + + $this->skinStyles[$skinName] = $styleFiles; + } + } + + /** + * Get a list of file paths for all styles in this module, in order of proper inclusion. + * + * @internal Exposed only for use by structure phpunit tests. + * @param Context $context + * @return string[][] List of file paths + */ + public function getStyleFiles( Context $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() { + $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); + $styleFiles = []; + + $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() ); + $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; + } + + /** + * Get the contents of a list of JavaScript files. Helper for getScript(). + * + * @param string[] $scripts List of file paths to scripts to read, remap and concatenate + * @return string Concatenated JavaScript data from $scripts + * @throws RuntimeException + */ + private function readScriptFiles( array $scripts ) { + if ( empty( $scripts ) ) { + return ''; + } + $js = ''; + foreach ( array_unique( $scripts, SORT_REGULAR ) as $fileName ) { + $localPath = $this->getLocalPath( $fileName ); + $contents = $this->getFileContents( $localPath, 'script' ); + $js .= ResourceLoader::ensureNewline( $contents ); + } + return $js; + } + + /** + * Get the contents of a list of CSS files. + * + * @internal This is considered a private method. Exposed for internal use by WebInstallerOutput. + * @param array $styles Map of media type to file paths to read, remap, and concatenate + * @param Context $context + * @return string[] List of concatenated and remapped CSS data from $styles, + * keyed by media type + * @throws RuntimeException + */ + public function readStyleFiles( array $styles, Context $context ) { + if ( !$styles ) { + return []; + } + foreach ( $styles as $media => $files ) { + $uniqueFiles = array_unique( $files, SORT_REGULAR ); + $styleFiles = []; + foreach ( $uniqueFiles as $file ) { + $styleFiles[] = $this->readStyleFile( $file, $context ); + } + $styles[$media] = implode( "\n", $styleFiles ); + } + return $styles; + } + + /** + * Read and process a style file. Reads a file from disk and runs it through processStyle(). + * + * This method can be used as a callback for array_map() + * + * @internal + * @param string $path File path of style file to read + * @param Context $context + * @return string CSS data in script file + * @throws RuntimeException If the file doesn't exist + */ + protected function readStyleFile( $path, Context $context ) { + $localPath = $this->getLocalPath( $path ); + $style = $this->getFileContents( $localPath, 'style' ); + $styleLang = $this->getStyleSheetLang( $localPath ); + + return $this->processStyle( $style, $styleLang, $path, $context ); + } + + /** + * Process a CSS/LESS string. + * + * This method performs the following processing steps: + * - LESS compilation (if $styleLang = 'less') + * - RTL flipping with CSSJanus (if getFlip() returns true) + * - Registration of references to local files in $localFileRefs and $missingLocalFileRefs + * - URL remapping and data URI embedding + * + * @internal + * @param string $style CSS/LESS string + * @param string $styleLang Language of $style ('css' or 'less') + * @param string $path File path where the CSS/LESS lives, used for resolving relative file paths + * @param Context $context + * @return string Processed CSS + */ + protected function processStyle( $style, $styleLang, $path, Context $context ) { + $localPath = $this->getLocalPath( $path ); + $remotePath = $this->getRemotePath( $path ); + + if ( $styleLang === 'less' ) { + $style = $this->compileLessString( $style, $localPath, $context ); + $this->hasGeneratedStyles = true; + } + + if ( $this->getFlip( $context ) ) { + $style = CSSJanus::transform( + $style, + /* $swapLtrRtlInURL = */ true, + /* $swapLeftRightInURL = */ false + ); + } + + $localDir = dirname( $localPath ); + $remoteDir = dirname( $remotePath ); + // Get and register local file references + $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir ); + foreach ( $localFileRefs as $file ) { + if ( is_file( $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 Context $context + * @return bool + */ + public function getFlip( Context $context ) { + return $context->getDirection() === 'rtl' && !$this->noflip; + } + + /** + * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile'] + * + * @return string[] + */ + 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->packageFiles + ); + return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL; + } + + /** + * @deprecated since 1.35 Use compileLessString() instead + * @param string $fileName + * @param Context $context + * @return string + * @codeCoverageIgnore + */ + protected function compileLessFile( $fileName, Context $context ) { + wfDeprecated( __METHOD__, '1.35' ); + + $style = $this->getFileContents( $fileName, 'LESS' ); + return $this->compileLessString( $style, $fileName, $context ); + } + + /** + * Compile a LESS string into CSS. + * + * Keeps track of all used files and adds them to localFileRefs. + * + * @since 1.35 + * @throws Exception If less.php encounters a parse error + * @param string $style LESS source to compile + * @param string $stylePath File path of LESS source, used for resolving relative file paths + * @param Context $context Context in which to generate script + * @return string CSS source + */ + protected function compileLessString( $style, $stylePath, Context $context ) { + static $cache; + // @TODO: dependency injection + if ( !$cache ) { + $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); + } + + $skinName = $context->getSkin(); + $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute( 'SkinLessImportPaths' ); + $importDirs = []; + if ( isset( $skinImportPaths[ $skinName ] ) ) { + $importDirs[] = $skinImportPaths[ $skinName ]; + } + + $vars = $this->getLessVars( $context ); + // Construct a cache key from a hash of the LESS source, and a hash digest + // of the LESS variables used for compilation. + ksort( $vars ); + $compilerParams = [ + 'vars' => $vars, + 'importDirs' => $importDirs, + ]; + $key = $cache->makeGlobalKey( + 'resourceloader-less', + 'v1', + hash( 'md4', $style ), + hash( 'md4', serialize( $compilerParams ) ) + ); + + // 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. + $data = $cache->get( $key ); + if ( + !$data || + $data['hash'] !== FileContentsHasher::getFileContentsHash( $data['files'] ) + ) { + $compiler = $context->getResourceLoader()->getLessCompiler( $vars, $importDirs ); + + $css = $compiler->parse( $style, $stylePath )->getCss(); + // T253055: store the implicit dependency paths in a form relative to any install + // path so that multiple version of the application can share the cache for identical + // less stylesheets. This also avoids churn during application updates. + $files = $compiler->AllParsedFiles(); + $data = [ + 'css' => $css, + 'files' => Module::getRelativePaths( $files ), + 'hash' => FileContentsHasher::getFileContentsHash( $files ) + ]; + $cache->set( $key, $data, $cache::TTL_DAY ); + } + + foreach ( Module::expandRelativePaths( $data['files'] ) as $path ) { + $this->localFileRefs[] = $path; + } + + return $data['css']; + } + + /** + * Takes named templates by the module and returns an array mapping. + * @return array Templates mapping template alias to content + * @throws RuntimeException If a file doesn't exist + */ + public function getTemplates() { + $templates = []; + + foreach ( $this->templates as $alias => $templatePath ) { + // Alias is optional + if ( is_int( $alias ) ) { + $alias = $this->getPath( $templatePath ); + } + $localPath = $this->getLocalPath( $templatePath ); + $content = $this->getFileContents( $localPath, 'template' ); + + $templates[$alias] = $this->stripBom( $content ); + } + return $templates; + } + + /** + * Internal helper for use by getPackageFiles(), getFileHashes() and getDefinitionSummary(). + * + * This expands the 'packageFiles' definition into something that's (almost) the right format + * for getPackageFiles() to return. It expands shorthands, resolves config vars, and handles + * summarising any non-file data for getVersionHash(). For file-based data, getFileHashes() + * handles it instead, which also ends up in getDefinitionSummary(). + * + * What it does not do is reading the actual contents of any specified files, nor invoking + * the computation callbacks. Those things are done by getPackageFiles() instead to improve + * backend performance by only doing this work when the module response is needed, and not + * when merely computing the version hash for StartupModule, or when checking + * If-None-Match headers for a HTTP 304 response. + * + * @param Context $context + * @return array|null + * @phan-return array{main:?string,files:array[]}|null + * @throws LogicException If the 'packageFiles' definition is invalid. + */ + private function expandPackageFiles( Context $context ) { + $hash = $context->getHash(); + if ( isset( $this->expandedPackageFiles[$hash] ) ) { + return $this->expandedPackageFiles[$hash]; + } + if ( $this->packageFiles === null ) { + return null; + } + $expandedFiles = []; + $mainFile = null; + + foreach ( $this->packageFiles as $key => $fileInfo ) { + if ( is_string( $fileInfo ) ) { + $fileInfo = [ 'name' => $fileInfo, 'file' => $fileInfo ]; + } + if ( !isset( $fileInfo['name'] ) ) { + $msg = "Missing 'name' key in package file info for module '{$this->getName()}'," . + " offset '{$key}'."; + $this->getLogger()->error( $msg ); + throw new LogicException( $msg ); + } + $fileName = $fileInfo['name']; + + // Infer type from alias if needed + $type = $fileInfo['type'] ?? self::getPackageFileType( $fileName ); + $expanded = [ 'type' => $type ]; + if ( !empty( $fileInfo['main'] ) ) { + $mainFile = $fileName; + if ( $type !== 'script' && $type !== 'script-vue' ) { + $msg = "Main file in package must be of type 'script', module " . + "'{$this->getName()}', main file '{$mainFile}' is '{$type}'."; + $this->getLogger()->error( $msg ); + throw new LogicException( $msg ); + } + } + + // Perform expansions (except 'file' and 'callback'), creating one of these keys: + // - 'content': literal value. + // - 'filePath': content to be read from a file. + // - 'callback': content computed by a callable. + if ( isset( $fileInfo['content'] ) ) { + $expanded['content'] = $fileInfo['content']; + } elseif ( isset( $fileInfo['file'] ) ) { + $expanded['filePath'] = $fileInfo['file']; + } elseif ( isset( $fileInfo['callback'] ) ) { + // If no extra parameter for the callback is given, use null. + $expanded['callbackParam'] = $fileInfo['callbackParam'] ?? null; + + if ( !is_callable( $fileInfo['callback'] ) ) { + $msg = "Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'."; + $this->getLogger()->error( $msg ); + throw new LogicException( $msg ); + } + if ( isset( $fileInfo['versionCallback'] ) ) { + if ( !is_callable( $fileInfo['versionCallback'] ) ) { + throw new LogicException( "Invalid 'versionCallback' for " + . "module '{$this->getName()}', file '{$fileName}'." + ); + } + + // Execute the versionCallback with the same arguments that + // would be given to the callback + $callbackResult = ( $fileInfo['versionCallback'] )( + $context, + $this->getConfig(), + $expanded['callbackParam'] + ); + if ( $callbackResult instanceof FilePath ) { + $expanded['filePath'] = $callbackResult->getPath(); + } else { + $expanded['definitionSummary'] = $callbackResult; + } + // Don't invoke 'callback' here as it may be expensive (T223260). + $expanded['callback'] = $fileInfo['callback']; + } else { + // Else go ahead invoke callback with its arguments. + $callbackResult = ( $fileInfo['callback'] )( + $context, + $this->getConfig(), + $expanded['callbackParam'] + ); + if ( $callbackResult instanceof FilePath ) { + $expanded['filePath'] = $callbackResult->getPath(); + } else { + $expanded['content'] = $callbackResult; + } + } + } elseif ( isset( $fileInfo['config'] ) ) { + if ( $type !== 'data' ) { + $msg = "Key 'config' only valid for data files. " + . " Module '{$this->getName()}', file '{$fileName}' is '{$type}'."; + $this->getLogger()->error( $msg ); + throw new LogicException( $msg ); + } + $expandedConfig = []; + foreach ( $fileInfo['config'] as $configKey => $var ) { + $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var ); + } + $expanded['content'] = $expandedConfig; + } elseif ( !empty( $fileInfo['main'] ) ) { + // [ 'name' => 'foo.js', 'main' => true ] is shorthand + $expanded['filePath'] = $fileName; + } else { + $msg = "Incomplete definition for module '{$this->getName()}', file '{$fileName}'. " + . "One of 'file', 'content', 'callback', or 'config' must be set."; + $this->getLogger()->error( $msg ); + throw new LogicException( $msg ); + } + + $expandedFiles[$fileName] = $expanded; + } + + if ( $expandedFiles && $mainFile === null ) { + // The first package file that is a script is the main file + foreach ( $expandedFiles as $path => $file ) { + if ( $file['type'] === 'script' || $file['type'] === 'script-vue' ) { + $mainFile = $path; + break; + } + } + } + + $result = [ + 'main' => $mainFile, + 'files' => $expandedFiles + ]; + + $this->expandedPackageFiles[$hash] = $result; + return $result; + } + + /** + * Resolves the package files definition and generates the content of each package file. + * @param Context $context + * @return array|null Package files data structure, see ResourceLoaderModule::getScript() + * @throws RuntimeException If a file doesn't exist, or parsing a .vue file fails + */ + public function getPackageFiles( Context $context ) { + if ( $this->packageFiles === null ) { + return null; + } + $hash = $context->getHash(); + if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) { + return $this->fullyExpandedPackageFiles[ $hash ]; + } + $expandedPackageFiles = $this->expandPackageFiles( $context ); + + // Expand file contents + foreach ( $expandedPackageFiles['files'] as $fileName => &$fileInfo ) { + // Turn any 'filePath' or 'callback' key into actual 'content', + // and remove the key after that. The callback could return a + // ResourceLoaderFilePath object; if that happens, fall through + // to the 'filePath' handling. + if ( isset( $fileInfo['callback'] ) ) { + $callbackResult = ( $fileInfo['callback'] )( + $context, + $this->getConfig(), + $fileInfo['callbackParam'] + ); + if ( $callbackResult instanceof FilePath ) { + // Fall through to the filePath handling code below + $fileInfo['filePath'] = $callbackResult->getPath(); + } else { + $fileInfo['content'] = $callbackResult; + } + unset( $fileInfo['callback'] ); + } + // Only interpret 'filePath' if 'content' hasn't been set already. + // This can happen if 'versionCallback' provided 'filePath', + // while 'callback' provides 'content'. In that case both are set + // at this point. The 'filePath' from 'versionCallback' in that case is + // only to inform getDefinitionSummary(). + if ( !isset( $fileInfo['content'] ) && isset( $fileInfo['filePath'] ) ) { + $localPath = $this->getLocalPath( $fileInfo['filePath'] ); + $content = $this->getFileContents( $localPath, 'package' ); + if ( $fileInfo['type'] === 'data' ) { + $content = json_decode( $content ); + } + $fileInfo['content'] = $content; + unset( $fileInfo['filePath'] ); + } + if ( $fileInfo['type'] === 'script-vue' ) { + try { + $parsedComponent = $this->getVueComponentParser()->parse( + // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive + $fileInfo['content'], + [ 'minifyTemplate' => !$context->getDebug() ] + ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + $msg = "Error parsing file '$fileName' in module '{$this->getName()}': " . + $e->getMessage(); + $this->getLogger()->error( $msg ); + throw new RuntimeException( $msg ); + } + $encodedTemplate = json_encode( $parsedComponent['template'] ); + if ( $context->getDebug() ) { + // Replace \n (backslash-n) with space + backslash-newline in debug mode + // We only replace \n if not preceded by a backslash, to avoid breaking '\\n' + $encodedTemplate = preg_replace( '/(?<!\\\\)\\\\n/', " \\\n", $encodedTemplate ); + // Expand \t to real tabs in debug mode + $encodedTemplate = strtr( $encodedTemplate, [ "\\t" => "\t" ] ); + } + $fileInfo['content'] = [ + 'script' => $parsedComponent['script'] . + ";\nmodule.exports.template = $encodedTemplate;", + 'style' => $parsedComponent['style'] ?? '', + 'styleLang' => $parsedComponent['styleLang'] ?? 'css' + ]; + $fileInfo['type'] = 'script+style'; + } + + // Not needed for client response, exists for use by getDefinitionSummary(). + unset( $fileInfo['definitionSummary'] ); + // Not needed for client response, used by callbacks only. + unset( $fileInfo['callbackParam'] ); + } + + $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles; + return $expandedPackageFiles; + } + + /** + * 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 initial BOM char + */ + protected function stripBom( $input ) { + if ( str_starts_with( $input, "\xef\xbb\xbf" ) ) { + return substr( $input, 3 ); + } + return $input; + } +} + +/** @deprecated since 1.39 */ +class_alias( FileModule::class, 'ResourceLoaderFileModule' ); diff --git a/includes/ResourceLoader/FilePath.php b/includes/ResourceLoader/FilePath.php new file mode 100644 index 000000000000..e2f451f9c776 --- /dev/null +++ b/includes/ResourceLoader/FilePath.php @@ -0,0 +1,90 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +/** + * An object to represent a path to a JavaScript/CSS file, along with a remote + * and local base path, for use with ResourceLoaderFileModule. + * + * @ingroup ResourceLoader + * @since 1.17 + */ +class FilePath { + /** @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 Relative path to the file, no leading slash. + * @param string $localBasePath Base path to prepend when generating a local path. + * @param string $remoteBasePath Base path to prepend when generating a remote path. + * Should not have a trailing slash unless at web document root. + */ + 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 : + "{$this->localBasePath}/{$this->path}"; + } + + /** @return string */ + public function getRemotePath() { + if ( $this->remoteBasePath === '' ) { + // No base path configured + return $this->path; + } + if ( $this->remoteBasePath === '/' ) { + // In document root + // Don't insert another slash (T284391). + return $this->remoteBasePath . $this->path; + } + return "{$this->remoteBasePath}/{$this->path}"; + } + + /** @return string */ + public function getLocalBasePath() { + return $this->localBasePath; + } + + /** @return string */ + public function getRemoteBasePath() { + return $this->remoteBasePath; + } + + /** @return string */ + public function getPath() { + return $this->path; + } +} + +/** @deprecated since 1.39 */ +class_alias( FilePath::class, 'ResourceLoaderFilePath' ); diff --git a/includes/ResourceLoader/ForeignApiModule.php b/includes/ResourceLoader/ForeignApiModule.php new file mode 100644 index 000000000000..822fdd1fd769 --- /dev/null +++ b/includes/ResourceLoader/ForeignApiModule.php @@ -0,0 +1,39 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +/** + * Module for mediawiki.ForeignApi and mediawiki.ForeignRest that has dynamically + * generated dependencies, via a hook usable by extensions. + * + * @ingroup ResourceLoader + * @internal + */ +class ForeignApiModule extends FileModule { + public function getDependencies( Context $context = null ) { + $dependencies = $this->dependencies; + $this->getHookRunner()->onResourceLoaderForeignApiModules( $dependencies, $context ); + return $dependencies; + } +} + +/** @deprecated since 1.39 */ +class_alias( ForeignApiModule::class, 'ResourceLoaderForeignApiModule' ); diff --git a/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php new file mode 100644 index 000000000000..f73e65579d32 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderExcludeUserOptionsHook.php @@ -0,0 +1,37 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use MediaWiki\ResourceLoader\Context; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderExcludeUserOptions" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderExcludeUserOptionsHook { + + /** + * Exclude a user option from the preloaded data for client-side mw.user.options. + * + * This hook is called on every index.php pageview (via ResourceLoaderUserOptionsModule), + * and when building responses for the "mediawiki.base" module. Avoid database queries + * or other expensive operations as that would increase page load time. + * + * Use this hook to optimize pageview HTML size by omitting user preference + * values from the export JavaScript data for `mw.user.options`. For example, + * when an extension stores large values in a user preference, and rarely or never + * needs these client-side, you can exclude it via this hook. (T251994) + * + * This will exclude both the default value (via mediawiki.base module) and + * the current user's value (via pageview HTML). + * + * @since 1.38 + * @param array &$keysToExclude + * @param Context $context + * @return void + */ + public function onResourceLoaderExcludeUserOptions( array &$keysToExclude, Context $context ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php new file mode 100644 index 000000000000..e7b2179b2a98 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderForeignApiModulesHook.php @@ -0,0 +1,28 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use MediaWiki\ResourceLoader\Context; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderForeignApiModules" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderForeignApiModulesHook { + /** + * Add dependencies to the `mediawiki.ForeignApi` module when you wish + * to override its behavior. See the JS docs for more information. + * + * This hook is called from ResourceLoaderForeignApiModule. + * + * @since 1.35 + * @param string[] &$dependencies List of modules that mediawiki.ForeignApi should + * depend on + * @param Context|null $context + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderForeignApiModules( &$dependencies, $context ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php new file mode 100644 index 000000000000..410401e87469 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderGetConfigVarsHook.php @@ -0,0 +1,31 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use Config; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderGetConfigVars" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderGetConfigVarsHook { + /** + * Export static site-wide `mw.config` variables to JavaScript. + * + * Variables that depend on the current page or request state must be added + * through MediaWiki\Hook\MakeGlobalVariablesScriptHook instead. + * The skin name is made available to send skin-specific config only when needed. + * + * This hook is called from ResourceLoaderStartUpModule. + * + * @since 1.35 + * @param array &$vars `[ variable name => value ]` + * @param string $skin + * @param Config $config since 1.34 + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php b/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php new file mode 100644 index 000000000000..c01c1ac8efeb --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderJqueryMsgModuleMagicWordsHook.php @@ -0,0 +1,30 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use MediaWiki\ResourceLoader\Context; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderJqueryMsgModuleMagicWords" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderJqueryMsgModuleMagicWordsHook { + /** + * Add magic words to the `mediawiki.jqueryMsg` module. The values should be a string, + * and they may only vary by what's in the Context. + * + * This hook is called from ResourceLoaderJqueryMsgModule. + * + * @since 1.35 + * @param Context $context + * @param string[] &$magicWords Associative array mapping all-caps magic word to a string value + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderJqueryMsgModuleMagicWords( + Context $context, + array &$magicWords + ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php new file mode 100644 index 000000000000..6238d4fe7384 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderRegisterModulesHook.php @@ -0,0 +1,25 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use MediaWiki\ResourceLoader\ResourceLoader; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderRegisterModules" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderRegisterModulesHook { + /** + * This hook is called right before modules information is required, + * such as when responding to a resource + * loader request or generating HTML output. + * + * @since 1.35 + * @param ResourceLoader $rl + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderRegisterModules( ResourceLoader $rl ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php new file mode 100644 index 000000000000..245b678d2f49 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderSiteModulePagesHook.php @@ -0,0 +1,24 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderSiteModulePages" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderSiteModulePagesHook { + /** + * Change which wiki pages comprise the `site` module in given skin. + * + * This hook is called from ResourceLoaderSiteModule. + * + * @since 1.35 + * @param string $skin Current skin key + * @param array &$pages Array of pages and their types + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderSiteModulePages( $skin, array &$pages ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php new file mode 100644 index 000000000000..392044da2e83 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderSiteStylesModulePagesHook.php @@ -0,0 +1,24 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderSiteStylesModulePages" to register handlers implementing this interface. + * + * @stable to implement + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderSiteStylesModulePagesHook { + /** + * Change which wiki pages comprise the `site.styles` module in given skin. + * + * This hook is called from ResourceLoaderSiteStylesModule. + * + * @since 1.35 + * @param string $skin Current skin key + * @param array &$pages Array of pages and their types + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderSiteStylesModulePages( $skin, array &$pages ): void; +} diff --git a/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php b/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php new file mode 100644 index 000000000000..641400aad861 --- /dev/null +++ b/includes/ResourceLoader/Hook/ResourceLoaderTestModulesHook.php @@ -0,0 +1,34 @@ +<?php + +namespace MediaWiki\ResourceLoader\Hook; + +use MediaWiki\ResourceLoader\ResourceLoader; + +/** + * This is a hook handler interface, see docs/Hooks.md. + * Use the hook name "ResourceLoaderTestModules" to register handlers implementing this interface. + * + * @deprecated since 1.33; use the QUnitTestModule static extension registration attribute instead. + * @ingroup ResourceLoaderHooks + */ +interface ResourceLoaderTestModulesHook { + /** + * Use this hook to register ResourceLoader modules that are only available + * when $wgEnableJavaScriptTest is true. Use this for test suites and + * other test-only resources. + * + * @since 1.35 + * @param array &$testModules One array of modules per test framework. + * The modules array follows the same format as `$wgResourceModules`. + * For example: + * $testModules['qunit']['ext.Example.test'] = [ + * 'localBasePath' => __DIR__ . '/tests/qunit', + * 'remoteExtPath' => 'Example/tests/qunit', + * 'script' => [ 'tests/qunit/foo.js' ], + * 'dependencies' => [ 'ext.Example.foo' ] + * ]; + * @param ResourceLoader $rl + * @return void This hook must not abort, it must return no value + */ + public function onResourceLoaderTestModules( array &$testModules, ResourceLoader $rl ): void; +} diff --git a/includes/ResourceLoader/HookRunner.php b/includes/ResourceLoader/HookRunner.php new file mode 100644 index 000000000000..acd626b32255 --- /dev/null +++ b/includes/ResourceLoader/HookRunner.php @@ -0,0 +1,74 @@ +<?php + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\HookContainer\HookContainer; + +/** + * @internal + * @codeCoverageIgnore + * @ingroup ResourceLoader + */ +class HookRunner implements + \MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook, + \MediaWiki\ResourceLoader\Hook\ResourceLoaderForeignApiModulesHook, + \MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook, + \MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteModulePagesHook, + \MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteStylesModulePagesHook, + \MediaWiki\ResourceLoader\Hook\ResourceLoaderTestModulesHook +{ + /** @var HookContainer */ + private $container; + + public function __construct( HookContainer $container ) { + $this->container = $container; + } + + public function onResourceLoaderExcludeUserOptions( array &$keysToExclude, Context $context ): void { + $this->container->run( + 'ResourceLoaderExcludeUserOptions', + [ &$keysToExclude, $context ], + [ 'abortable' => false ] + ); + } + + public function onResourceLoaderForeignApiModules( &$dependencies, $context ): void { + $this->container->run( + 'ResourceLoaderForeignApiModules', + [ &$dependencies, $context ], + [ 'abortable' => false ] + ); + } + + public function onResourceLoaderRegisterModules( ResourceLoader $rl ): void { + $this->container->run( + 'ResourceLoaderRegisterModules', + [ $rl ], + [ 'abortable' => false ] + ); + } + + public function onResourceLoaderSiteModulePages( $skin, array &$pages ): void { + $this->container->run( + 'ResourceLoaderSiteModulePages', + [ $skin, &$pages ], + [ 'abortable' => false ] + ); + } + + public function onResourceLoaderSiteStylesModulePages( $skin, array &$pages ): void { + $this->container->run( + 'ResourceLoaderSiteStylesModulePages', + [ $skin, &$pages ], + [ 'abortable' => false ] + ); + } + + public function onResourceLoaderTestModules( array &$testModules, ResourceLoader $rl ): void { + $this->container->run( + 'ResourceLoaderTestModules', + [ &$testModules, $rl ], + [ 'abortable' => false ] + ); + } +} diff --git a/includes/ResourceLoader/Image.php b/includes/ResourceLoader/Image.php new file mode 100644 index 000000000000..ef474dd4bf9d --- /dev/null +++ b/includes/ResourceLoader/Image.php @@ -0,0 +1,502 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use DOMDocument; +use FileBackend; +use InvalidArgumentException; +use MediaWiki\Languages\LanguageFallback; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use MediaWiki\Shell\Shell; +use MWException; +use SvgHandler; +use SVGReader; +use Wikimedia\Minify\CSSMin; + +/** + * Class encapsulating an image used in an ImageModule. + * + * @ingroup ResourceLoader + * @since 1.25 + */ +class Image { + /** + * Map of allowed file extensions to their MIME types. + * @var array + */ + private const FILE_TYPES = [ + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'jpg' => 'image/jpg', + ]; + + /** @var string */ + private $name; + /** @var string */ + private $module; + /** @var string|array */ + private $descriptor; + /** @var string */ + private $basePath; + /** @var array */ + private $variants; + /** @var string|null */ + private $defaultColor; + /** @var string */ + private $extension; + + /** + * @param string $name Self-name of the image as known to ImageModule. + * @param string $module Self-name of the module containing this image. + * Used to find the image in the registry e.g. through a load.php url. + * @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 + * @param string|null $defaultColor of the base variant + * @throws InvalidArgumentException + */ + public function __construct( $name, $module, $descriptor, $basePath, array $variants, + $defaultColor = null + ) { + $this->name = $name; + $this->module = $module; + $this->descriptor = $descriptor; + $this->basePath = $basePath; + $this->variants = $variants; + $this->defaultColor = $defaultColor; + + // 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 = is_array( $this->descriptor ) ? $this->descriptor : [ $this->descriptor ]; + array_walk_recursive( $descriptor, function ( $path ) use ( &$extensions ) { + $extensions[] = pathinfo( $this->getLocalPath( $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::FILE_TYPES[$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(): array { + return array_keys( $this->variants ); + } + + /** + * @internal For unit testing override + * @param string $lang + * @return string[] + */ + protected function getLangFallbacks( string $lang ): array { + return MediaWikiServices::getInstance() + ->getLanguageFallback() + ->getAll( $lang, LanguageFallback::STRICT ); + } + + /** + * Get the path to image file for given context. + * + * @param Context $context Any context + * @return string + * @throws MWException If no matching path is found + */ + public function getPath( Context $context ) { + $desc = $this->descriptor; + if ( !is_array( $desc ) ) { + return $this->getLocalPath( $desc ); + } + if ( isset( $desc['lang'] ) ) { + $contextLang = $context->getLanguage(); + if ( isset( $desc['lang'][$contextLang] ) ) { + return $this->getLocalPath( $desc['lang'][$contextLang] ); + } + $fallbacks = $this->getLangFallbacks( $contextLang ); + foreach ( $fallbacks as $lang ) { + if ( isset( $desc['lang'][$lang] ) ) { + return $this->getLocalPath( $desc['lang'][$lang] ); + } + } + } + if ( isset( $desc[$context->getDirection()] ) ) { + return $this->getLocalPath( $desc[$context->getDirection()] ); + } + if ( isset( $desc['default'] ) ) { + return $this->getLocalPath( $desc['default'] ); + } else { + throw new MWException( 'No matching path found' ); + } + } + + /** + * @param string|FilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof FilePath ) { + return $path->getLocalPath(); + } + + return "{$this->basePath}/$path"; + } + + /** + * Get the extension of the image. + * + * @param string|null $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|null $format Format to get the MIME type for, 'original' or 'rasterized' + * @return string + */ + public function getMimeType( $format = 'original' ) { + $ext = $this->getExtension( $format ); + return self::FILE_TYPES[$ext]; + } + + /** + * Get the load.php URL that will produce this image. + * + * @param Context $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 URL + */ + public function getUrl( Context $context, $script, $variant, $format ) { + $query = [ + 'modules' => $this->getModule(), + 'image' => $this->getName(), + 'variant' => $variant, + 'format' => $format, + ]; + if ( $this->varyOnLanguage() ) { + $query['lang'] = $context->getLanguage(); + } + // The following parameters are at the end to keep the original order of the parameters. + $query['skin'] = $context->getSkin(); + $rl = $context->getResourceLoader(); + $query['version'] = $rl->makeVersionQuery( $context, [ $this->getModule() ] ); + + return wfAppendQuery( $script, $query ); + } + + /** + * Get the data: URI that will produce this image. + * + * @param Context $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( Context $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 Context $context Image context, or any context if $variant and $format + * given. + * @param string|null|false $variant Variant to get the data for. Optional; if given, overrides the data + * from $context. + * @param string|false $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( Context $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 { + $defaultColor = $this->defaultColor; + $data = $defaultColor ? + $this->variantize( [ 'color' => $defaultColor ], $context ) : + 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 Context $context Image context + */ + public function sendResponseHeaders( Context $context ): void { + $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 Context $context Image context + * @return string New SVG file data + */ + protected function variantize( array $variantConf, Context $context ) { + $dom = new DOMDocument; + $dom->loadXML( file_get_contents( $this->getPath( $context ) ) ); + $root = $dom->documentElement; + $titleNode = null; + $wrapper = $dom->createElementNS( 'http://www.w3.org/2000/svg', 'g' ); + // Reattach all direct children of the `<svg>` root node to the `<g>` wrapper + while ( $root->firstChild ) { + $node = $root->firstChild; + // @phan-suppress-next-line PhanUndeclaredProperty False positive + if ( !$titleNode && $node->nodeType === XML_ELEMENT_NODE && $node->tagName === 'title' ) { + // Remember the first encountered `<title>` node + $titleNode = $node; + } + $wrapper->appendChild( $node ); + } + if ( $titleNode ) { + // Reattach the `<title>` node to the `<svg>` root node rather than the `<g>` wrapper + $root->appendChild( $titleNode ); + } + $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 ) { + $svgConverter = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverter ); + $svgConverterPath = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SVGConverterPath ); + // 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 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. + + $svg = $this->massageSvgPathdata( $svg ); + + // Sometimes this might be 'rsvg-secure'. Long as it's rsvg. + if ( strpos( $svgConverter, 'rsvg' ) === 0 ) { + $command = 'rsvg-convert'; + if ( $svgConverterPath ) { + $command = Shell::escape( "{$svgConverterPath}/" ) . $command; + } + + $process = proc_open( + $command, + [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ] ], + $pipes + ); + + if ( $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 ); + + $svgReader = new SVGReader( $tempFilenameSvg ); + $metadata = $svgReader->getMetadata(); + 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; + } + } + + /** + * Check if the image depends on the language. + * + * @return bool + */ + private function varyOnLanguage() { + return is_array( $this->descriptor ) && ( + isset( $this->descriptor['ltr'] ) || + isset( $this->descriptor['rtl'] ) || + isset( $this->descriptor['lang'] ) ); + } +} + +/** @deprecated since 1.39 */ +class_alias( Image::class, 'ResourceLoaderImage' ); diff --git a/includes/ResourceLoader/ImageModule.php b/includes/ResourceLoader/ImageModule.php new file mode 100644 index 000000000000..c9b75894012f --- /dev/null +++ b/includes/ResourceLoader/ImageModule.php @@ -0,0 +1,504 @@ +<?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 + */ +namespace MediaWiki\ResourceLoader; + +use InvalidArgumentException; +use Wikimedia\Minify\CSSMin; + +/** + * Module for generated and embedded images. + * + * @ingroup ResourceLoader + * @since 1.25 + */ +class ImageModule extends Module { + /** @var array|null */ + protected $definition; + + /** + * Local base path, see __construct() + * @var string + */ + protected $localBasePath = ''; + + protected $origin = self::ORIGIN_CORE_SITEWIDE; + + /** @var Image[][]|null */ + protected $imageObjects = null; + /** @var array */ + protected $images = []; + /** @var string|null */ + protected $defaultColor = null; + protected $useDataURI = true; + /** @var array|null */ + protected $globalVariants = null; + /** @var array */ + protected $variants = []; + /** @var string|null */ + 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|null $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( array $options = [], $localBasePath = null ) { + $this->localBasePath = static::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->getLocalPath( $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( $data ) ) { + throw new InvalidArgumentException( + "Invalid list error. '$data' given, array expected." + ); + } + } + $this->{$member} = $option; + break; + + case 'useDataURI': + $this->{$member} = (bool)$option; + break; + case 'defaultColor': + 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 an Image object for given image. + * @param string $name Image name + * @param Context $context + * @return Image|null + */ + public function getImage( $name, Context $context ): ?Image { + $this->loadFromDefinition(); + $images = $this->getImages( $context ); + return $images[$name] ?? null; + } + + /** + * Get Image objects for all images. + * @param Context $context + * @return Image[] Array keyed by image name + */ + public function getImages( Context $context ): array { + $skin = $context->getSkin(); + if ( $this->imageObjects === null ) { + $this->loadFromDefinition(); + $this->imageObjects = []; + } + if ( !isset( $this->imageObjects[$skin] ) ) { + $this->imageObjects[$skin] = []; + if ( !isset( $this->images[$skin] ) ) { + $this->images[$skin] = $this->images['default'] ?? []; + } + foreach ( $this->images[$skin] as $name => $options ) { + $fileDescriptor = is_array( $options ) ? $options['file'] : $options; + + $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 Image( + $name, + $this->getName(), + $fileDescriptor, + $this->localBasePath, + $variantConfig, + $this->defaultColor + ); + $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 Context $context + * @return string[] + */ + public function getGlobalVariants( Context $context ): array { + $skin = $context->getSkin(); + if ( $this->globalVariants === null ) { + $this->loadFromDefinition(); + $this->globalVariants = []; + } + if ( !isset( $this->globalVariants[$skin] ) ) { + $this->globalVariants[$skin] = []; + if ( !isset( $this->variants[$skin] ) ) { + $this->variants[$skin] = $this->variants['default'] ?? []; + } + foreach ( $this->variants[$skin] as $name => $config ) { + if ( $config['global'] ?? false ) { + $this->globalVariants[$skin][] = $name; + } + } + } + + return $this->globalVariants[$skin]; + } + + /** + * @param Context $context + * @return array + */ + public function getStyles( Context $context ): array { + $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 ]; + } + + /** + * This method must not be used by getDefinitionSummary as doing so would cause + * an infinite loop (we use ResourceLoaderImage::getUrl below which calls + * Module:getVersionHash, which calls Module::getDefinitionSummary). + * + * @param Context $context + * @param Image $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( + Context $context, + Image $image, + $script, + $variant = null + ) { + $imageDataUri = $this->useDataURI ? $image->getDataUri( $context, $variant, 'original' ) : false; + $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 ): array { + $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 Context $context + * @return array + */ + public function getDefinitionSummary( Context $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 Context $context + * @return array + */ + private function getFileHashes( Context $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 ); + } + + /** + * @param string|FilePath $path + * @return string + */ + protected function getLocalPath( $path ) { + if ( $path instanceof FilePath ) { + return $path->getLocalPath(); + } + + return "{$this->localBasePath}/$path"; + } + + /** + * Extract a local base path from module definition information. + * + * @param array $options Module definition + * @param string|null $localBasePath Path to use if not provided in module definition. Defaults + * to $IP. + * @return string Local base path + */ + public static function extractLocalBasePath( array $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; + } +} + +/** @deprecated since 1.39 */ +class_alias( ImageModule::class, 'ResourceLoaderImageModule' ); diff --git a/includes/ResourceLoader/LanguageDataModule.php b/includes/ResourceLoader/LanguageDataModule.php new file mode 100644 index 000000000000..a1b420797033 --- /dev/null +++ b/includes/ResourceLoader/LanguageDataModule.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 Santhosh Thottingal + */ + +namespace MediaWiki\ResourceLoader; + +use LanguageCode; +use MediaWiki\MediaWikiServices; + +/** + * Module for populating language specific data, such as grammar forms. + * + * @ingroup ResourceLoader + * @internal + */ +class LanguageDataModule extends FileModule { + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Get all the dynamic data for the content language to an array. + * + * @internal Only public for use by GenerateJqueryMsgData (tests) + * @param string $langCode + * @return array + */ + public static function getData( $langCode ): array { + $language = MediaWikiServices::getInstance()->getLanguageFactory() + ->getLanguage( $langCode ); + 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(), + 'bcp47Map' => LanguageCode::getNonstandardLanguageCodeMapping(), + ]; + } + + /** + * @param Context $context + * @return string JavaScript code + */ + public function getScript( Context $context ) { + return parent::getScript( $context ) + . 'mw.language.setData(' + . $context->encodeJson( $context->getLanguage() ) . ',' + . $context->encodeJson( self::getData( $context->getLanguage() ) ) + . ');'; + } + + /** + * @return bool + */ + public function enableModuleContentVersion() { + return true; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } +} + +/** @deprecated since 1.39 */ +class_alias( LanguageDataModule::class, 'ResourceLoaderLanguageDataModule' ); diff --git a/includes/ResourceLoader/LessVarFileModule.php b/includes/ResourceLoader/LessVarFileModule.php new file mode 100644 index 000000000000..6674da1a7017 --- /dev/null +++ b/includes/ResourceLoader/LessVarFileModule.php @@ -0,0 +1,138 @@ +<?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 + */ +namespace MediaWiki\ResourceLoader; + +use Wikimedia\Minify\CSSMin; + +// Per https://phabricator.wikimedia.org/T241091 +// phpcs:disable MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation + +/** + * Module augmented with context-specific LESS variables. + * + * @ingroup ResourceLoader + * @since 1.32 + */ +class LessVarFileModule extends FileModule { + protected $lessVariables = []; + + /** + * @inheritDoc + */ + public function __construct( + array $options = [], + $localBasePath = null, + $remoteBasePath = null + ) { + if ( isset( $options['lessMessages'] ) ) { + $this->lessVariables = $options['lessMessages']; + } + parent::__construct( $options, $localBasePath, $remoteBasePath ); + } + + /** + * @inheritDoc + */ + public function getMessages() { + // Overload so MessageBlobStore can detect updates to messages and purge as needed. + return array_merge( $this->messages, $this->lessVariables ); + } + + /** + * Return a subset of messages from a JSON string representation. + * + * @param string|null $blob JSON, or null if module has no declared messages + * @param string[] $allowed + * @return array + */ + private function pluckFromMessageBlob( $blob, array $allowed ): array { + $data = $blob ? json_decode( $blob, true ) : []; + // Keep only the messages intended for LESS export + // (opposite of getMessages essentially). + return array_intersect_key( $data, array_fill_keys( $allowed, true ) ); + } + + /** + * @inheritDoc + */ + protected function getMessageBlob( Context $context ) { + $blob = parent::getMessageBlob( $context ); + if ( !$blob ) { + // If module has no blob, preserve null to avoid needless WAN cache allocation + // client output for modules without messages. + return $blob; + } + return json_encode( (object)$this->pluckFromMessageBlob( $blob, $this->messages ) ); + } + + // phpcs:disable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before + /** + * Escape and wrap a message value as literal string for LESS. + * + * This mostly lets CSSMin escape it and wrap it, but also escape single quotes + * for compatibility with LESS's feature of variable interpolation into other strings. + * This is relatively rare for most use of LESS, but for messages it is quite common. + * + * Example: + * + * @code + * @x: "foo's"; + * .eg { content: 'Value is @{x}'; } + * @endcode + * + * Produces output: `.eg { content: 'Value is foo's'; }`. + * (Tested in less.php 1.8.1, and Less.js 2.7) + * + * @param string $msg + * @return string wrapped LESS variable value + */ + private static function wrapAndEscapeMessage( $msg ) { + return str_replace( "'", "\'", CSSMin::serializeStringValue( $msg ) ); + } + + // phpcs:enable MediaWiki.Commenting.DocComment.SpacingDocTag, Squiz.WhiteSpace.FunctionSpacing.Before + + /** + * Get language-specific LESS variables for this module. + * + * @param Context $context + * @return array LESS variables + */ + protected function getLessVars( Context $context ) { + $vars = parent::getLessVars( $context ); + + $blob = parent::getMessageBlob( $context ); + $messages = $this->pluckFromMessageBlob( $blob, $this->lessVariables ); + + // It is important that we iterate the declared list from $this->lessVariables, + // and not $messages since in the case of undefined messages, the key is + // omitted entirely from the blob. This emits a log warning for developers, + // but we must still carry on and produce a valid LESS variable declaration, + // to avoid a LESS syntax error (T267785). + foreach ( $this->lessVariables as $msgKey ) { + $vars['msg-' . $msgKey] = self::wrapAndEscapeMessage( $messages[$msgKey] ?? "⧼${msgKey}⧽" ); + } + + return $vars; + } +} + +/** @deprecated since 1.39 */ +class_alias( LessVarFileModule::class, 'ResourceLoaderLessVarFileModule' ); diff --git a/includes/ResourceLoader/MessageBlobStore.php b/includes/ResourceLoader/MessageBlobStore.php new file mode 100644 index 000000000000..33be0abdf601 --- /dev/null +++ b/includes/ResourceLoader/MessageBlobStore.php @@ -0,0 +1,266 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @author Roan Kattouw + * @author Trevor Parscal + */ + +namespace MediaWiki\ResourceLoader; + +use FormatJson; +use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use WANObjectCache; +use Wikimedia\Rdbms\Database; + +/** + * PHP 7.2 hack to work around the issue described at https://phabricator.wikimedia.org/T166010#5962098 + * Load the ResourceLoader class when MessageBlobStore is loaded. + * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound + * phpcs:disable MediaWiki.Files.ClassMatchesFilename.NotMatch + */ +class ResourceLoader72Hack extends ResourceLoader { +} + +/** + * This class generates message blobs for use by ResourceLoader. + * + * A message blob is a JSON object containing the interface messages for a + * certain module in a certain language. + * + * @ingroup ResourceLoader + * @since 1.17 + */ +class MessageBlobStore implements LoggerAwareInterface { + /** @var ResourceLoader */ + private $resourceloader; + + /** @var LoggerInterface */ + protected $logger; + + /** @var WANObjectCache */ + protected $wanCache; + + /** + * @param ResourceLoader $rl + * @param LoggerInterface|null $logger + * @param WANObjectCache|null $wanObjectCache + */ + public function __construct( + ResourceLoader $rl, + ?LoggerInterface $logger, + ?WANObjectCache $wanObjectCache + ) { + $this->resourceloader = $rl; + $this->logger = $logger ?: new NullLogger(); + + // NOTE: when changing this assignment, make sure the code in the instantiator for + // LocalisationCache which calls MessageBlobStore::clearGlobalCacheEntry() uses the + // same cache object. + $this->wanCache = $wanObjectCache ?: MediaWikiServices::getInstance() + ->getMainWANObjectCache(); + } + + /** + * @since 1.27 + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Get the message blob for a module + * + * @since 1.27 + * @param Module $module + * @param string $lang Language code + * @return string JSON + */ + public function getBlob( Module $module, $lang ) { + $blobs = $this->getBlobs( [ $module->getName() => $module ], $lang ); + return $blobs[$module->getName()]; + } + + /** + * Get the message blobs for a set of modules + * + * @since 1.27 + * @param Module[] $modules Array of module objects keyed by name + * @param string $lang Language code + * @return string[] An array mapping module names to message blobs + */ + public function getBlobs( array $modules, $lang ) { + // Each cache key for a message blob by module name and language code also has a generic + // check key without language code. This is used to invalidate any and all language subkeys + // that exist for a module from the updateMessage() method. + $cache = $this->wanCache; + $checkKeys = [ + // Global check key, see clear() + $cache->makeGlobalKey( __CLASS__ ) + ]; + $cacheKeys = []; + foreach ( $modules as $name => $module ) { + $cacheKey = $this->makeCacheKey( $module, $lang ); + $cacheKeys[$name] = $cacheKey; + // Per-module check key, see updateMessage() + $checkKeys[$cacheKey][] = $cache->makeKey( __CLASS__, $name ); + } + $curTTLs = []; + $result = $cache->getMulti( array_values( $cacheKeys ), $curTTLs, $checkKeys ); + + $blobs = []; + foreach ( $modules as $name => $module ) { + $key = $cacheKeys[$name]; + if ( !isset( $result[$key] ) || $curTTLs[$key] === null || $curTTLs[$key] < 0 ) { + $blobs[$name] = $this->recacheMessageBlob( $key, $module, $lang ); + } else { + // Use unexpired cache + $blobs[$name] = $result[$key]; + } + } + return $blobs; + } + + /** + * @since 1.27 + * @param Module $module + * @param string $lang + * @return string Cache key + */ + private function makeCacheKey( Module $module, $lang ) { + $messages = array_values( array_unique( $module->getMessages() ) ); + sort( $messages ); + return $this->wanCache->makeKey( __CLASS__, $module->getName(), $lang, + md5( json_encode( $messages ) ) + ); + } + + /** + * @since 1.27 + * @param string $cacheKey + * @param Module $module + * @param string $lang + * @return string JSON blob + */ + protected function recacheMessageBlob( $cacheKey, Module $module, $lang ) { + $blob = $this->generateMessageBlob( $module, $lang ); + $cache = $this->wanCache; + $cache->set( $cacheKey, $blob, + // Add part of a day to TTL to avoid all modules expiring at once + $cache::TTL_WEEK + mt_rand( 0, $cache::TTL_DAY ), + Database::getCacheSetOptions( wfGetDB( DB_REPLICA ) ) + ); + return $blob; + } + + /** + * Invalidate cache keys for modules using this message key. + * Called by MessageCache when a message has changed. + * + * @param string $key Message key + */ + public function updateMessage( $key ): void { + $moduleNames = $this->resourceloader->getModulesByMessage( $key ); + foreach ( $moduleNames as $moduleName ) { + // Uses a holdoff to account for database replica DB lag (for MessageCache) + $this->wanCache->touchCheckKey( $this->wanCache->makeKey( __CLASS__, $moduleName ) ); + } + } + + /** + * Invalidate cache keys for all known modules. + */ + public function clear() { + self::clearGlobalCacheEntry( $this->wanCache ); + } + + /** + * Invalidate cache keys for all known modules. + * + * Called by LocalisationCache after cache is regenerated. + * + * @param WANObjectCache $cache + */ + public static function clearGlobalCacheEntry( WANObjectCache $cache ) { + // Disable hold-off because: + // - LocalisationCache is populated by messages on-disk and don't have DB lag, + // thus there is no need for hold off. We only clear it after new localisation + // updates are known to be deployed to all servers. + // - This global check key invalidates message blobs for all modules for all wikis + // in cache contexts (e.g. languages, skins). Setting a hold-off on this key could + // cause a cache stampede since no values would be stored for several seconds. + $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_TTL_NONE ); + } + + /** + * @since 1.27 + * @param string $key Message key + * @param string $lang Language code + * @return string|null + */ + protected function fetchMessage( $key, $lang ) { + $message = wfMessage( $key )->inLanguage( $lang ); + if ( !$message->exists() ) { + $this->logger->warning( 'Failed to find {messageKey} ({lang})', [ + 'messageKey' => $key, + 'lang' => $lang, + ] ); + $value = null; + } else { + $value = $message->plain(); + } + return $value; + } + + /** + * Generate the message blob for a given module in a given language. + * + * @param Module $module + * @param string $lang Language code + * @return string JSON blob + */ + private function generateMessageBlob( Module $module, $lang ) { + $messages = []; + foreach ( $module->getMessages() as $key ) { + $value = $this->fetchMessage( $key, $lang ); + // If the message does not exist, omit it from the blob so that + // client-side mw.message may do its own existence handling. + if ( $value !== null ) { + $messages[$key] = $value; + } + } + + $json = FormatJson::encode( (object)$messages, false, FormatJson::UTF8_OK ); + // @codeCoverageIgnoreStart + if ( $json === false ) { + $this->logger->warning( 'Failed to encode message blob for {module} ({lang})', [ + 'module' => $module->getName(), + 'lang' => $lang, + ] ); + $json = '{}'; + } + // codeCoverageIgnoreEnd + return $json; + } +} + +/** @deprecated since 1.39 */ +class_alias( MessageBlobStore::class, 'MessageBlobStore' ); diff --git a/includes/ResourceLoader/Module.php b/includes/ResourceLoader/Module.php new file mode 100644 index 000000000000..97d7b86d660b --- /dev/null +++ b/includes/ResourceLoader/Module.php @@ -0,0 +1,1128 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Config; +use Exception; +use FileContentsHasher; +use JSParser; +use LogicException; +use MediaWiki\HookContainer\HookContainer; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use RuntimeException; +use Wikimedia\RelPath; +use Wikimedia\RequestTimeout\TimeoutException; + +/** + * Abstraction for ResourceLoader modules, with name registration and maxage functionality. + * + * @see $wgResourceModules for the available options when registering a module. + * @stable to extend + * @ingroup ResourceLoader + * @since 1.17 + */ +abstract class Module implements LoggerAwareInterface { + /** @var Config */ + protected $config; + /** @var LoggerInterface */ + protected $logger; + + /** + * 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 + * @var int + */ + protected $origin = self::ORIGIN_CORE_SITEWIDE; + + /** @var string|null Module name */ + protected $name = null; + /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */ + protected $targets = [ 'desktop' ]; + /** @var string[]|null Skin names */ + protected $skins = null; + + /** @var array Map of (variant => indirect file dependencies) */ + protected $fileDeps = []; + /** @var array Map of (language => in-object cache for message blob) */ + protected $msgBlobs = []; + /** @var array Map of (context hash => cached module version hash) */ + protected $versionHash = []; + /** @var array Map of (context hash => cached module content) */ + protected $contents = []; + + /** @var HookRunner|null */ + private $hookRunner; + + /** @var callback Function of (module name, variant) to get indirect file dependencies */ + private $depLoadCallback; + /** @var callback Function of (module name, variant) to get indirect file dependencies */ + private $depSaveCallback; + + /** @var string|bool Deprecation string or true if deprecated; false otherwise */ + protected $deprecated = false; + + /** @var string Scripts only */ + public const TYPE_SCRIPTS = 'scripts'; + /** @var string Styles only */ + public const TYPE_STYLES = 'styles'; + /** @var string Scripts and styles */ + public const TYPE_COMBINED = 'combined'; + + /** @var string */ + public const GROUP_SITE = 'site'; + /** @var string */ + public const GROUP_USER = 'user'; + /** @var string */ + public const GROUP_PRIVATE = 'private'; + /** @var string */ + public const GROUP_NOSCRIPT = 'noscript'; + + /** @var string Module only has styles (loaded via <style> or <link rel=stylesheet>) */ + public const LOAD_STYLES = 'styles'; + /** @var string Module may have other resources (loaded via mw.loader from a script) */ + public const LOAD_GENERAL = 'general'; + + /** @var int Sitewide core module like a skin file or jQuery component */ + public const ORIGIN_CORE_SITEWIDE = 1; + /** @var int Per-user module generated by the software */ + public 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. + * @var int + */ + public const ORIGIN_USER_SITEWIDE = 3; + /** @var int Per-user module generated from user-editable files, like User:Me/vector.js */ + public const ORIGIN_USER_INDIVIDUAL = 4; + /** @var int An access constant; make sure this is kept as the largest number in this group */ + public const ORIGIN_ALL = 10; + + /** @var int Cache version for user-script JS validation errors from validateScriptFile(). */ + private const USERJSPARSE_CACHE_VERSION = 2; + + /** + * 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; + } + + /** + * Provide overrides for skinStyles to modules that support that. + * + * This MUST be called after self::setName(). + * + * @since 1.37 + * @see $wgResourceModuleSkinStyles + * @param array $moduleSkinStyles + */ + public function setSkinStylesOverride( array $moduleSkinStyles ): void { + // Stub, only supported by FileModule currently. + } + + /** + * Inject the functions that load/save the indirect file path dependency list from storage + * + * @param callable $loadCallback Function of (module name, variant) + * @param callable $saveCallback Function of (module name, variant, current paths, stored paths) + * @since 1.35 + */ + public function setDependencyAccessCallbacks( callable $loadCallback, callable $saveCallback ) { + $this->depLoadCallback = $loadCallback; + $this->depSaveCallback = $saveCallback; + } + + /** + * 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 Context $context + * @return bool + */ + public function getFlip( Context $context ) { + return MediaWikiServices::getInstance()->getContentLanguage()->getDir() !== + $context->getDirection(); + } + + /** + * Get JS representing deprecation information for the current module if available + * + * @param Context $context + * @return string JavaScript code + */ + public function getDeprecationInformation( Context $context ) { + $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 'mw.log.warn(' . $context->encodeJson( $warning ) . ');'; + } else { + return ''; + } + } + + /** + * Get all JS for this module for a given language and skin. + * Includes all relevant JS except loader scripts. + * + * For "plain" script modules, this should return a string with JS code. For multi-file modules + * where require() is used to load one file from another file, this should return an array + * structured as follows: + * [ + * 'files' => [ + * 'file1.js' => [ 'type' => 'script', 'content' => 'JS code' ], + * 'file2.js' => [ 'type' => 'script', 'content' => 'JS code' ], + * 'data.json' => [ 'type' => 'data', 'content' => array ] + * ], + * 'main' => 'file1.js' + * ] + * + * @stable to override + * @param Context $context + * @return string|array JavaScript code (string), or multi-file structure described above (array) + */ + public function getScript( Context $context ) { + // Stub, override expected + return ''; + } + + /** + * Takes named templates by the module and returns an array mapping. + * + * @stable to override + * @return string[] 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 ) { + throw new RuntimeException( 'Config accessed before it is set' ); + } + + return $this->config; + } + + /** + * @param Config $config + * @since 1.24 + */ + public function setConfig( Config $config ) { + $this->config = $config; + } + + /** + * @since 1.27 + * @param LoggerInterface $logger + */ + 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; + } + + /** + * @internal For use only by ResourceLoader::getModule + * @param HookContainer $hookContainer + */ + public function setHookContainer( HookContainer $hookContainer ): void { + $this->hookRunner = new HookRunner( $hookContainer ); + } + + /** + * Get a HookRunner for running core hooks. + * + * @internal For use only within core ResourceLoaderModule subclasses. Hook interfaces may be removed + * without notice. + * @return HookRunner + */ + protected function getHookRunner(): HookRunner { + return $this->hookRunner; + } + + /** + * Get alternative script URLs for legacy debug mode. + * + * The default behavior is to return a `load.php?only=scripts&module=<name>` URL. + * + * Module classes that merely wrap one or more other script files in production mode, may + * override this method to return an array of raw URLs for those underlying scripts, + * if those are individually web-accessible. + * + * The mw.loader client will load and execute each URL consecutively. This has the caveat of + * executing legacy debug scripts in the global scope, which is why non-package file modules + * tend to use file closures (T50886). + * + * This function MUST NOT be called, unless all the following are true: + * + * 1. We're in debug mode, + * 2. There is no `only=` parameter in the context, + * 3. self::supportsURLLoading() has returned true. + * + * Point 2 prevents an infinite loop since we use the `only=` mechanism in the return value. + * Overrides must similarly return with `only`, or return or a non-load.php URL. + * + * @stable to override + * @param Context $context + * @return string[] + */ + public function getScriptURLsForDebug( Context $context ) { + $rl = $context->getResourceLoader(); + $derivative = new DerivativeContext( $context ); + $derivative->setModules( [ $this->getName() ] ); + $derivative->setOnly( 'scripts' ); + + $url = $rl->createLoaderURL( + $this->getSource(), + $derivative + ); + + // Expand debug URL in case we are another wiki's module source (T255367) + $url = $rl->expandUrl( $this->getConfig()->get( MainConfigNames::Server ), $url ); + + 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. + * + * @stable to override + * @return bool + */ + public function supportsURLLoading() { + return true; + } + + /** + * Get all CSS for this module for a given skin. + * + * @stable to override + * @param Context $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( Context $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 + * + * This function must only be called when: + * + * 1. We're in debug mode, + * 2. There is no `only=` parameter and, + * 3. self::supportsURLLoading() returns true. + * + * See also getScriptURLsForDebug(). + * + * @stable to override + * @param Context $context + * @return array [ mediaType => [ URL1, URL2, ... ], ... ] + */ + public function getStyleURLsForDebug( Context $context ) { + $resourceLoader = $context->getResourceLoader(); + $derivative = new DerivativeContext( $context ); + $derivative->setModules( [ $this->getName() ] ); + $derivative->setOnly( 'styles' ); + + $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() + * + * @stable to override + * @return string[] List of message keys. Keys may occur more than once + */ + public function getMessages() { + // Stub, override expected + return []; + } + + /** + * Get the group this module is in. + * + * @stable to override + * @return string|null Group name + */ + public function getGroup() { + // Stub, override expected + return null; + } + + /** + * Get the source of this module. Should only be overridden for foreign modules. + * + * @stable to override + * @return string Source name, 'local' for local modules + */ + public function getSource() { + // Stub, override expected + return 'local'; + } + + /** + * 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. + * + * @stable to override + * @param Context|null $context + * @return string[] List of module names as strings + */ + public function getDependencies( Context $context = null ) { + // Stub, override expected + return []; + } + + /** + * Get target(s) for the module, eg ['desktop'] or ['desktop', 'mobile'] + * + * @stable to override + * @return string[] + */ + public function getTargets() { + return $this->targets; + } + + /** + * Get list of skins for which this module must be available to load. + * + * By default, modules are available to all skins. + * + * This information may be used by the startup module to optimise registrations + * based on the current skin. + * + * @stable to override + * @since 1.39 + * @return string[]|null + */ + public function getSkins(): ?array { + return $this->skins; + } + + /** + * Get the module's load type. + * + * @stable to override + * @since 1.28 + * @return string Module 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. + * + * @stable to override + * @return string|null A JavaScript function body returning a boolean value, or null + */ + public function getSkipFunction() { + return null; + } + + /** + * Whether the module requires ES6 support in the client. + * + * If the client does not support ES6, attempting to load a module that requires ES6 will + * result in an error. + * + * @stable to override + * @since 1.36 + * @return bool + */ + public function requiresES6() { + return false; + } + + /** + * Get the indirect dependencies for this module pursuant to the skin/language context + * + * These are only image files referenced by the module's stylesheet + * + * If neither setFileDependencies() nor setDependencyLoadCallback() was called, this + * will simply return a placeholder with an empty file list + * + * @see ResourceLoader::setFileDependencies() + * @see ResourceLoader::saveFileDependencies() + * + * @param Context $context + * @return string[] List of absolute file paths + * @throws RuntimeException When setFileDependencies() has not yet been called + */ + protected function getFileDependencies( Context $context ) { + $variant = self::getVary( $context ); + + if ( !isset( $this->fileDeps[$variant] ) ) { + if ( $this->depLoadCallback ) { + $this->fileDeps[$variant] = + call_user_func( $this->depLoadCallback, $this->getName(), $variant ); + } else { + $this->getLogger()->info( __METHOD__ . ": no callback registered" ); + $this->fileDeps[$variant] = []; + } + } + + return $this->fileDeps[$variant]; + } + + /** + * Set the indirect dependencies for this module pursuant to the skin/language context + * + * These are only image files referenced by the module's stylesheet + * + * @see ResourceLoader::getFileDependencies() + * @see ResourceLoader::saveFileDependencies() + * + * @param Context $context + * @param string[] $paths List of absolute file paths + */ + public function setFileDependencies( Context $context, array $paths ) { + $variant = self::getVary( $context ); + $this->fileDeps[$variant] = $paths; + } + + /** + * Save the indirect dependencies for this module pursuant to the skin/language context + * + * @param Context $context + * @param string[] $curFileRefs List of newly computed indirect file dependencies + * @since 1.27 + */ + protected function saveFileDependencies( Context $context, array $curFileRefs ) { + if ( !$this->depSaveCallback ) { + $this->getLogger()->info( __METHOD__ . ": no callback registered" ); + + return; + } + + try { + // Pitfalls and performance considerations: + // 1. Don't keep updating the tracked paths due to duplicates or sorting. + // 2. Use relative paths to avoid ghost entries when $IP changes. (T111481) + // 3. Don't needlessly replace tracked paths 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. + call_user_func( + $this->depSaveCallback, + $this->getName(), + self::getVary( $context ), + self::getRelativePaths( $curFileRefs ), + self::getRelativePaths( $this->getFileDependencies( $context ) ) + ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + $this->getLogger()->warning( + __METHOD__ . ": failed to update dependencies: {$e->getMessage()}", + [ 'exception' => $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( static 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( static function ( $path ) use ( $IP ) { + return RelPath::joinPath( $IP, $path ); + }, $filePaths ); + } + + /** + * Get the hash of the message blob. + * + * @stable to override + * @since 1.27 + * @param Context $context + * @return string|null JSON blob or null if module has no messages + */ + protected function getMessageBlob( Context $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 Context $context + * @return string[] Array of HTTP response headers + */ + final public function getHeaders( Context $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 Module::getHeaders + * + * @stable to override + * @since 1.30 + * @param Context $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( Context $context ) { + return []; + } + + /** + * Get module-specific LESS variables, if any. + * + * @stable to override + * @since 1.27 + * @param Context $context + * @return array Module-specific LESS variables. + */ + protected function getLessVars( Context $context ) { + return []; + } + + /** + * Get an array of this module's resources. Ready for serving to the web. + * + * @since 1.26 + * @param Context $context + * @return array + */ + public function getModuleContent( Context $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 Context $context + * @return array + */ + final protected function buildContent( Context $context ) { + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $statStart = microtime( true ); + + // This MUST build both scripts and styles, regardless of whether $context->getOnly() + // is 'scripts' or 'styles' because the result is used by getVersionHash which + // must be consistent regardless of the 'only' filter on the current request. + // Also, when introducing new module content resources (e.g. templates, headers), + // these should only be included in the array when they are non-empty so that + // existing modules not using them do not get their cache invalidated. + $content = []; + + // Scripts + if ( $context->getDebug() === $context::DEBUG_LEGACY && !$context->getOnly() && $this->supportsURLLoading() ) { + // In legacy debug mode, let supporting modules like FileModule replace the bundled + // script closure with an array of alternative script URLs to consecutively load instead. + // See self::getScriptURLsForDebug() more details. + $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 ) ) { + $scripts = ResourceLoader::ensureNewline( $scripts ); + } + } + $content['scripts'] = $scripts; + + $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' => ResourceLoader::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 caches, + * both server-side (by a CDN, or other HTTP cache), and client-side (in `mw.loader.store`, + * and in the browser's own HTTP cache). + * + * The underlying methods called here for any given module should be quick because this + * is called for potentially thousands of module bundles in the same request as part of the + * StartUpModule, which is how we invalidate caches and propagate changes to clients. + * + * @since 1.26 + * @see self::getDefinitionSummary for how to customize version computation. + * @param Context $context + * @return string Hash formatted by ResourceLoader::makeHash + */ + final public function getVersionHash( Context $context ) { + if ( $context->getDebug() ) { + // In debug mode, make uncached startup module extra fast by not computing any hashes. + // Server responses from load.php for individual modules already have no-cache so + // we don't need them. This also makes breakpoint debugging easier, as each module + // gets its own consistent URL. (T235672) + return ''; + } + + // 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 by hashing the module contents. + $str = json_encode( $this->getModuleContent( $context ) ); + } else { + // Infer changes based on definition and other metrics + $summary = $this->getDefinitionSummary( $context ); + if ( !isset( $summary['_class'] ) ) { + throw new LogicException( 'getDefinitionSummary must call parent method' ); + } + $str = json_encode( $summary ); + } + + $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. + * + * @stable to override + * @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 data that + * should influence the module's version hash. + * + * Subclasses must call the parent getDefinitionSummary() and add to the + * returned array. It is recommended that each subclass appends its own array, + * to prevent clashes or accidental overwrites of array keys from the parent + * class. This gives each subclass a clean scope. + * + * @code + * $summary = parent::getDefinitionSummary( $context ); + * $summary[] = [ + * 'foo' => 123, + * 'bar' => 'quux', + * ]; + * return $summary; + * @endcode + * + * Return an array that contains all significant properties that define the + * module. The returned data should be deterministic and only change when + * the generated module response would change. Prefer content hashes over + * modified timestamps because timestamps may change for unrelated reasons + * and are not deterministic (T102578). For example, because timestamps are + * not stored in Git, each branch checkout would cause all files to appear as + * new. Timestamps also tend to not match between servers causing additional + * ever-lasting churning of the version hash. + * + * Be careful not to normalise the data too much in an effort to be deterministic. + * For example, if a module concatenates files together (order is significant), + * then the definition summary could be a list of file names, and a list of + * file hashes. These lists should not be sorted as that would mean the cache + * is not invalidated when the order changes (T39812). + * + * This data structure must exclusively contain primitive "scalar" values, + * as it will be serialised using `json_encode`. + * + * @stable to override + * @since 1.23 + * @param Context $context + * @return array|null + */ + public function getDefinitionSummary( Context $context ) { + return [ + '_class' => static::class, + // Make sure that when filter cache for minification is invalidated, + // we also change the HTTP urls and mw.loader.store keys (T176884). + '_cacheVersion' => ResourceLoader::CACHE_VERSION, + ]; + } + + /** + * 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. + * + * @stable to override + * @param Context $context + * @return bool + */ + public function isKnownEmpty( Context $context ) { + return false; + } + + /** + * Check whether this module should be embedded rather than linked + * + * Modules returning true here will be embedded rather than loaded by + * ClientHtml. + * + * @since 1.30 + * @stable to override + * @param Context $context + * @return bool + */ + public function shouldEmbedModule( Context $context ) { + return $this->getGroup() === self::GROUP_PRIVATE; + } + + /** + * Validate a user-provided JavaScript blob. + * + * @param string $fileName + * @param string $contents JavaScript code + * @return string JavaScript code, either the original content or a replacement + * that uses `mw.log.error()` to communicate a syntax error. + */ + protected function validateScriptFile( $fileName, $contents ) { + $error = null; + + if ( $this->getConfig()->get( MainConfigNames::ResourceLoaderValidateJS ) ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + // Cache potentially slow parsing of JavaScript code during the + // critical path. This happens lazily when responding to requests + // for modules=site, modules=user, and Gadgets. + $error = $cache->getWithSetCallback( + $cache->makeKey( + 'resourceloader-userjsparse', + self::USERJSPARSE_CACHE_VERSION, + md5( $contents ), + $fileName + ), + $cache::TTL_WEEK, + static function () use ( $contents, $fileName ) { + $parser = new JSParser(); + try { + // Ignore compiler warnings (T77169) + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + @$parser->parse( $contents, $fileName, 1 ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + return $e->getMessage(); + } + // Cache success as null + return null; + } + ); + } + + if ( $error ) { + // Send the error to the browser console client-side. + // By returning this as replacement for the actual script, + // we ensure user-provided scripts are safe to include in a batch + // request, without risk of a syntax error in this blob breaking + // the response itself. + return 'mw.log.error(' . + json_encode( + 'JavaScript parse error (scripts need to be valid ECMAScript 5): ' . + $error + ) . + ');'; + } else { + return $contents; + } + } + + /** + * 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 + * @return string Hash + */ + protected static function safeFileHash( $filePath ) { + return FileContentsHasher::getFileContentsHash( $filePath ); + } + + /** + * Get vary string. + * + * @internal For internal use only. + * @param Context $context + * @return string + */ + public static function getVary( Context $context ) { + return implode( '|', [ + $context->getSkin(), + $context->getLanguage(), + ] ); + } +} + +/** @deprecated since 1.39 */ +class_alias( Module::class, 'ResourceLoaderModule' ); diff --git a/includes/ResourceLoader/MwUrlModule.php b/includes/ResourceLoader/MwUrlModule.php new file mode 100644 index 000000000000..7603f17cbc7e --- /dev/null +++ b/includes/ResourceLoader/MwUrlModule.php @@ -0,0 +1,46 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +/** + * @ingroup ResourceLoader + * @internal + */ +class MwUrlModule { + /** + * @param string $content JavaScript RegExp content with additional whitespace + * and named capturing group allowed, which will be stripped. + * @return string JavaScript code + */ + public static function makeJsFromExtendedRegExp( string $content ): string { + // Remove whitespace. + $content = preg_replace( '/\s+/', '', $content ); + // Remove named capturing groups. + // This allows long regexes to be self-documenting, which we allow for + // developer convenience, but this syntax is invalid JavaScript ES5. + $content = preg_replace( '/\?<\w+?>/', '', $content ); + // Format as a valid JavaScript import. + return 'module.exports = /' . strtr( $content, [ '/' => '\/' ] ) . '/;'; + } +} + +/** @deprecated since 1.39 */ +class_alias( MwUrlModule::class, 'ResourceLoaderMwUrlModule' ); diff --git a/includes/ResourceLoader/OOUIFileModule.php b/includes/ResourceLoader/OOUIFileModule.php new file mode 100644 index 000000000000..37b16eec2f4a --- /dev/null +++ b/includes/ResourceLoader/OOUIFileModule.php @@ -0,0 +1,119 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +/** + * Module which magically loads the right skinScripts and skinStyles for every + * skin, using the specified OOUI theme for each. + * + * @ingroup ResourceLoader + * @internal + */ +class OOUIFileModule extends FileModule { + use OOUIModule; + + /** @var array<string,string|FilePath> */ + private $themeStyles = []; + + public function __construct( array $options = [] ) { + if ( isset( $options['themeScripts'] ) ) { + $skinScripts = $this->getSkinSpecific( $options['themeScripts'], 'scripts' ); + $options['skinScripts'] = $this->extendSkinSpecific( $options['skinScripts'] ?? [], $skinScripts ); + } + if ( isset( $options['themeStyles'] ) ) { + $this->themeStyles = $this->getSkinSpecific( $options['themeStyles'], 'styles' ); + } + + parent::__construct( $options ); + } + + public function setSkinStylesOverride( array $moduleSkinStyles ): void { + parent::setSkinStylesOverride( $moduleSkinStyles ); + + $this->skinStyles = $this->extendSkinSpecific( $this->skinStyles, $this->themeStyles ); + } + + /** + * 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<string,string|FilePath> + */ + private function getSkinSpecific( $module, $which ): array { + $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 theme-specific resources on behalf of the skin. + * + * The expected order of styles and scripts in the output is: + * + * 1. Theme-specific resources for a given skin. + * + * 2. Module-defined resources for a specific skin, + * falling back to module-defined "default" skin resources. + * + * 3. Skin-defined resources for a specific module, which can either + * append to or replace the "default" (via ResourceModuleSkinStyles in skin.json) + * Note that skins can only define resources for a module if that + * module does not already explicitly provide resources for that skin. + * + * @param array $skinSpecific Module-defined 'skinScripts' or 'skinStyles'. + * @param array $themeSpecific + * @return array Modified $skinSpecific + */ + private function extendSkinSpecific( array $skinSpecific, array $themeSpecific ): array { + // If the module or skin already set skinStyles/skinScripts, prepend ours + foreach ( $skinSpecific as $skin => $files ) { + if ( !is_array( $files ) ) { + $files = [ $files ]; + } + if ( isset( $themeSpecific[$skin] ) ) { + $skinSpecific[$skin] = array_merge( [ $themeSpecific[$skin] ], $files ); + } elseif ( isset( $themeSpecific['default'] ) ) { + $skinSpecific[$skin] = array_merge( [ $themeSpecific['default'] ], $files ); + } + } + // If the module has no skinStyles/skinScripts for a skin, then set ours + foreach ( $themeSpecific as $skin => $file ) { + if ( !isset( $skinSpecific[$skin] ) ) { + $skinSpecific[$skin] = [ $file ]; + } + } + return $skinSpecific; + } +} + +/** @deprecated since 1.39 */ +class_alias( OOUIFileModule::class, 'ResourceLoaderOOUIFileModule' ); diff --git a/includes/ResourceLoader/OOUIIconPackModule.php b/includes/ResourceLoader/OOUIIconPackModule.php new file mode 100644 index 000000000000..687a8d7eaa30 --- /dev/null +++ b/includes/ResourceLoader/OOUIIconPackModule.php @@ -0,0 +1,90 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use InvalidArgumentException; + +/** + * Allows loading arbitrary sets of OOUI icons. + * + * @ingroup ResourceLoader + * @since 1.34 + */ +class OOUIIconPackModule extends OOUIImageModule { + public function __construct( array $options = [], $localBasePath = null ) { + parent::__construct( $options, $localBasePath ); + + if ( !isset( $this->definition['icons'] ) || !$this->definition['icons'] ) { + throw new InvalidArgumentException( "Parameter 'icons' must be given." ); + } + + // A few things check for the "icons" prefix on this value, so specify it even though + // we don't use it for actually loading the data, like in the other modules. + $this->definition['themeImages'] = 'icons'; + } + + private function getIcons(): array { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Checked in the constructor + return $this->definition['icons']; + } + + protected function loadOOUIDefinition( $theme, $unused ): array { + // This is shared between instances of this class, so we only have to load the JSON files once + static $data = []; + + if ( !isset( $data[$theme] ) ) { + $data[$theme] = []; + // Load and merge the JSON data for all "icons-foo" modules + foreach ( self::$knownImagesModules as $module ) { + if ( substr( $module, 0, 5 ) === 'icons' ) { + $moreData = $this->readJSONFile( $this->getThemeImagesPath( $theme, $module ) ); + if ( $moreData ) { + $data[$theme] = array_replace_recursive( $data[$theme], $moreData ); + } + } + } + } + + $definition = $data[$theme]; + + // Filter out the data for all other icons, leaving only the ones we want for this module + $iconsNames = $this->getIcons(); + foreach ( array_keys( $definition['images'] ) as $iconName ) { + if ( !in_array( $iconName, $iconsNames ) ) { + unset( $definition['images'][$iconName] ); + } + } + + return $definition; + } + + public static function extractLocalBasePath( array $options, $localBasePath = null ) { + global $IP; + if ( $localBasePath === null ) { + $localBasePath = $IP; + } + // Ignore any 'localBasePath' present in $options, this always refers to files in MediaWiki core + return $localBasePath; + } +} + +/** @deprecated since 1.39 */ +class_alias( OOUIIconPackModule::class, 'ResourceLoaderOOUIIconPackModule' ); diff --git a/includes/ResourceLoader/OOUIImageModule.php b/includes/ResourceLoader/OOUIImageModule.php new file mode 100644 index 000000000000..0e342a28c5c2 --- /dev/null +++ b/includes/ResourceLoader/OOUIImageModule.php @@ -0,0 +1,162 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Exception; + +/** + * Loads the module definition from JSON files in the format that OOUI uses, converting it to the + * format we use. (Previously known as secret special sauce.) + * + * @since 1.26 + */ +class OOUIImageModule extends ImageModule { + use OOUIModule; + + 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 = $this->definition['themeImages'] ?? ''; + + $definition = []; + foreach ( $themes as $skin => $theme ) { + $data = $this->loadOOUIDefinition( $theme, $module ); + + if ( !$data ) { + // If there's no file for this module of this theme, that's okay, it will just use the defaults + continue; + } + + // Convert into a definition compatible with the parent vanilla ImageModule + 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] = $value; + break; + + // Other options must be identical for each theme (or only defined in the default one) + default: + if ( !isset( $definition[$key] ) ) { + $definition[$key] = $value; + } elseif ( $definition[$key] !== $value ) { + 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(); + } + + /** + * Load the module definition from the JSON file(s) for the given theme and module. + * + * @since 1.34 + * @param string $theme + * @param string $module + * @return array|false + * @suppress PhanTypeArraySuspiciousNullable + */ + protected function loadOOUIDefinition( $theme, $module ) { + // Find the path to the JSON file which contains the actual image definitions for this theme + if ( $module ) { + $dataPath = $this->getThemeImagesPath( $theme, $module ); + if ( !$dataPath ) { + return []; + } + } else { + // Backwards-compatibility for things that probably shouldn't have used this class... + $dataPath = + $this->definition['rootPath'] . '/' . + strtolower( $theme ) . '/' . + $this->definition['name'] . '.json'; + } + + return $this->readJSONFile( $dataPath ); + } + + /** + * Read JSON from a file, and transform all paths in it to be relative to the module's base path. + * + * @since 1.34 + * @param string $dataPath Path relative to the module's base bath + * @return array|false + */ + protected function readJSONFile( $dataPath ) { + $localDataPath = $this->getLocalPath( $dataPath ); + + if ( !file_exists( $localDataPath ) ) { + return false; + } + + $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 = static function ( &$path ) use ( $dataPath ) { + if ( $dataPath instanceof FilePath ) { + $path = new FilePath( + dirname( $dataPath->getPath() ) . '/' . $path, + $dataPath->getLocalBasePath(), + $dataPath->getRemoteBasePath() + ); + } else { + $path = dirname( $dataPath ) . '/' . $path; + } + }; + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + array_walk( $data['images'], static function ( &$value ) use ( $fixPath ) { + if ( is_string( $value['file'] ) ) { + $fixPath( $value['file'] ); + } elseif ( is_array( $value['file'] ) ) { + array_walk_recursive( $value['file'], $fixPath ); + } + } ); + + return $data; + } +} + +/** @deprecated since 1.39 */ +class_alias( OOUIImageModule::class, 'ResourceLoaderOOUIImageModule' ); diff --git a/includes/ResourceLoader/OOUIModule.php b/includes/ResourceLoader/OOUIModule.php new file mode 100644 index 000000000000..48d10539b21b --- /dev/null +++ b/includes/ResourceLoader/OOUIModule.php @@ -0,0 +1,184 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use ExtensionRegistry; +use InvalidArgumentException; + +/** + * Convenience methods for dealing with OOUI themes and their relations to MW skins. + * + * @ingroup ResourceLoader + * @internal + */ +trait OOUIModule { + protected static $knownScriptsModules = [ 'core' ]; + protected static $knownStylesModules = [ 'core', 'widgets', 'toolbars', 'windows' ]; + protected static $knownImagesModules = [ + 'indicators', + // Extra icons + 'icons-accessibility', + 'icons-alerts', + 'icons-content', + 'icons-editing-advanced', + 'icons-editing-citation', + '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', + ]; + + /** @var string[] Note that keys must be lowercase, values TitleCase. */ + protected static $builtinSkinThemeMap = [ + 'default' => 'WikimediaUI', + ]; + + /** @var string[][] Note that keys must be TitleCase. */ + protected static $builtinThemePaths = [ + 'WikimediaUI' => [ + 'scripts' => 'resources/lib/ooui/oojs-ui-wikimediaui.js', + 'styles' => 'resources/lib/ooui/oojs-ui-{module}-wikimediaui.css', + 'images' => 'resources/lib/ooui/themes/wikimediaui/{module}.json', + ], + 'Apex' => [ + 'scripts' => 'resources/lib/ooui/oojs-ui-apex.js', + 'styles' => 'resources/lib/ooui/oojs-ui-{module}-apex.css', + 'images' => 'resources/lib/ooui/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 paths. Paths may be strings or FilePaths. + * + * Additionally, the string '{module}' in paths represents the name of the module to load. + * + * @return array + */ + protected static function getThemePaths() { + $themePaths = self::$builtinThemePaths; + $themePaths += ExtensionRegistry::getInstance()->getAttribute( 'OOUIThemePaths' ); + + list( $defaultLocalBasePath, $defaultRemoteBasePath ) = + FileModule::extractBasePaths(); + + // Allow custom themes' paths to be relative to the skin/extension that defines them, + // like with ResourceModuleSkinStyles + foreach ( $themePaths as $theme => &$paths ) { + list( $localBasePath, $remoteBasePath ) = + FileModule::extractBasePaths( $paths ); + if ( $localBasePath !== $defaultLocalBasePath || $remoteBasePath !== $defaultRemoteBasePath ) { + foreach ( $paths as &$path ) { + $path = new FilePath( $path, $localBasePath, $remoteBasePath ); + } + } + } + + return $themePaths; + } + + /** + * Return a path to load given module of given theme from. + * + * The file at this path may not exist. This should be handled by the caller (throwing an error or + * falling back to default theme). + * + * @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|FilePath + */ + protected function getThemePath( $theme, $kind, $module ) { + $paths = self::getThemePaths(); + $path = $paths[$theme][$kind]; + if ( $path instanceof FilePath ) { + $path = new FilePath( + str_replace( '{module}', $module, $path->getPath() ), + $path->getLocalBasePath(), + $path->getRemoteBasePath() + ); + } else { + $path = str_replace( '{module}', $module, $path ); + } + return $path; + } + + /** + * @param string $theme See getThemePath() + * @param string $module See getThemePath() + * @return string|FilePath + */ + 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|FilePath + */ + 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|FilePath + */ + 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 ); + } +} + +/** @deprecated since 1.39 */ +class_alias( OOUIModule::class, 'ResourceLoaderOOUIModule' ); diff --git a/includes/ResourceLoader/ResourceLoader.php b/includes/ResourceLoader/ResourceLoader.php new file mode 100644 index 000000000000..ba942e09713b --- /dev/null +++ b/includes/ResourceLoader/ResourceLoader.php @@ -0,0 +1,2060 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @author Roan Kattouw + * @author Trevor Parscal + */ + +namespace MediaWiki\ResourceLoader; + +use BagOStuff; +use CommentStore; +use Config; +use DeferredUpdates; +use Exception; +use ExtensionRegistry; +use HashBagOStuff; +use Hooks; +use Html; +use HttpStatus; +use InvalidArgumentException; +use Less_Parser; +use MediaWiki\HeaderCallback; +use MediaWiki\HookContainer\HookContainer; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use MWException; +use MWExceptionHandler; +use MWExceptionRenderer; +use Net_URL2; +use ObjectCache; +use OutputPage; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use ResourceFileCache; +use RuntimeException; +use stdClass; +use Throwable; +use Title; +use UnexpectedValueException; +use WebRequest; +use WikiMap; +use Wikimedia\DependencyStore\DependencyStore; +use Wikimedia\DependencyStore\KeyValueDependencyStore; +use Wikimedia\Minify\CSSMin; +use Wikimedia\Minify\JavaScriptMinifier; +use Wikimedia\Rdbms\DBConnectionError; +use Wikimedia\RequestTimeout\TimeoutException; +use Wikimedia\ScopedCallback; +use Wikimedia\Timestamp\ConvertibleTimestamp; +use Wikimedia\WrappedString; +use Xml; +use XmlJsCode; + +/** + * @defgroup ResourceLoader ResourceLoader + * + * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>. + */ + +/** + * @defgroup ResourceLoaderHooks ResourceLoader Hooks + * @ingroup ResourceLoader + * @ingroup Hooks + */ + +/** + * PHP 7.2 hack to work around the issue described at https://phabricator.wikimedia.org/T166010#5962098 + * Load the Context class when ResourceLoader is loaded. + * phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound + * phpcs:disable MediaWiki.Files.ClassMatchesFilename.NotMatch + */ +class Context72Hack extends Context { +} + +/** + * ResourceLoader is a loading system for JavaScript and CSS resources. + * + * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>. + * + * @ingroup ResourceLoader + * @since 1.17 + */ +class ResourceLoader implements LoggerAwareInterface { + /** @var int */ + public const CACHE_VERSION = 9; + /** @var string JavaScript / CSS pragma to disable minification. * */ + public const FILTER_NOMIN = '/*@nomin*/'; + + /** @var string */ + private const RL_DEP_STORE_PREFIX = 'ResourceLoaderModule'; + /** @var int How long to preserve indirect dependency metadata in our backend store. */ + private const RL_MODULE_DEP_TTL = BagOStuff::TTL_WEEK; + + /** @var int|null */ + protected static $debugMode = null; + + /** @var Config */ + private $config; + /** @var MessageBlobStore */ + private $blobStore; + /** @var DependencyStore */ + private $depStore; + /** @var LoggerInterface */ + private $logger; + /** @var HookContainer */ + private $hookContainer; + /** @var HookRunner */ + private $hookRunner; + + /** @var Module[] Map of (module name => ResourceLoaderModule) */ + private $modules = []; + /** @var array[] Map of (module name => associative info array) */ + private $moduleInfos = []; + /** @var string[] List of module names that contain QUnit test suites */ + private $testSuiteModuleNames = []; + /** @var string[] Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */ + private $sources = []; + /** @var array Errors accumulated during a respond() call. Exposed for testing. */ + protected $errors = []; + /** + * @var string[] Buffer for extra response headers during a makeModuleResponse() call. + * Exposed for testing. + */ + protected $extraHeaders = []; + /** @var array Map of (module-variant => buffered DependencyStore updates) */ + private $depStoreUpdateBuffer = []; + /** + * @var array Styles that are skin-specific and supplement or replace the + * default skinStyles of a FileModule. See $wgResourceModuleSkinStyles. + */ + private $moduleSkinStyles = []; + + /** + * @param Config $config Required configuration: + * - EnableJavaScriptTest + * - LoadScript + * - ResourceLoaderMaxage + * - UseFileCache + * @param LoggerInterface|null $logger [optional] + * @param DependencyStore|null $tracker [optional] + */ + public function __construct( + Config $config, + LoggerInterface $logger = null, + DependencyStore $tracker = null + ) { + $this->config = $config; + $this->logger = $logger ?: new NullLogger(); + + $services = MediaWikiServices::getInstance(); + $this->hookContainer = $services->getHookContainer(); + $this->hookRunner = new HookRunner( $this->hookContainer ); + + // Add 'local' source first + $this->addSource( 'local', $config->get( MainConfigNames::LoadScript ) ); + + // Special module that always exists + $this->register( 'startup', [ 'class' => StartUpModule::class ] ); + + $this->setMessageBlobStore( + new MessageBlobStore( $this, $this->logger, $services->getMainWANObjectCache() ) + ); + + $tracker = $tracker ?: new KeyValueDependencyStore( new HashBagOStuff() ); + $this->setDependencyStore( $tracker ); + } + + /** + * @return Config + */ + public function getConfig() { + return $this->config; + } + + /** + * @since 1.26 + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @since 1.27 + * @return LoggerInterface + */ + public function getLogger() { + return $this->logger; + } + + /** + * @since 1.26 + * @return MessageBlobStore + */ + public function getMessageBlobStore() { + return $this->blobStore; + } + + /** + * @since 1.25 + * @param MessageBlobStore $blobStore + */ + public function setMessageBlobStore( MessageBlobStore $blobStore ) { + $this->blobStore = $blobStore; + } + + /** + * @since 1.35 + * @param DependencyStore $tracker + */ + public function setDependencyStore( DependencyStore $tracker ) { + $this->depStore = $tracker; + } + + /** + * @internal For use by ServiceWiring.php + * @param array $moduleSkinStyles + */ + public function setModuleSkinStyles( array $moduleSkinStyles ) { + $this->moduleSkinStyles = $moduleSkinStyles; + } + + /** + * Register a module with the ResourceLoader system. + * + * @see $wgResourceModules for the available options. + * @param string|array[] $name Module name as a string or, array of module info arrays + * keyed by name. + * @param array|null $info Module info array. When using the first parameter to register + * multiple modules at once, this parameter is optional. + * @throws InvalidArgumentException If a module name contains illegal characters (pipes or commas) + * @throws InvalidArgumentException If the module info is not an array + */ + public function register( $name, array $info = null ) { + // Allow multiple modules to be registered in one call + $registrations = is_array( $name ) ? $name : [ $name => $info ]; + foreach ( $registrations as $name => $info ) { + // Warn on duplicate registrations + if ( isset( $this->moduleInfos[$name] ) ) { + // A module has already been registered by this name + $this->logger->warning( + 'ResourceLoader duplicate registration warning. ' . + 'Another module has already been registered as ' . $name + ); + } + + // Check validity + if ( !self::isValidModuleName( $name ) ) { + throw new InvalidArgumentException( "ResourceLoader module name '$name' is invalid, " + . "see ResourceLoader::isValidModuleName()" ); + } + if ( !is_array( $info ) ) { + throw new InvalidArgumentException( + 'Invalid module info for "' . $name . '": expected array, got ' . gettype( $info ) + ); + } + + // Attach module + $this->moduleInfos[$name] = $info; + } + } + + /** + * @internal For use by ServiceWiring only + * @codeCoverageIgnore + */ + public function registerTestModules(): void { + global $IP; + + if ( $this->config->get( MainConfigNames::EnableJavaScriptTest ) !== true ) { + throw new MWException( 'Attempt to register JavaScript test modules ' + . 'but <code>$wgEnableJavaScriptTest</code> is false. ' + . 'Edit your <code>LocalSettings.php</code> to enable it.' ); + } + + // This has a 'qunit' key for compat with the below hook. + $testModulesMeta = [ 'qunit' => [] ]; + + $this->hookRunner->onResourceLoaderTestModules( $testModulesMeta, $this ); + $extRegistry = ExtensionRegistry::getInstance(); + // In case of conflict, the deprecated hook has precedence. + $testModules = $testModulesMeta['qunit'] + + $extRegistry->getAttribute( 'QUnitTestModules' ); + + $testSuiteModuleNames = []; + foreach ( $testModules as $name => &$module ) { + // Turn any single-module dependency into an array + if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) { + $module['dependencies'] = [ $module['dependencies'] ]; + } + + // Ensure the testrunner loads before any test suites + $module['dependencies'][] = 'mediawiki.qunit-testrunner'; + + // Keep track of the test suites to load on SpecialJavaScriptTest + $testSuiteModuleNames[] = $name; + } + + // Core test suites (their names have further precedence). + $testModules = ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules; + $testSuiteModuleNames[] = 'test.MediaWiki'; + + $this->register( $testModules ); + $this->testSuiteModuleNames = $testSuiteModuleNames; + } + + /** + * Add a foreign source of modules. + * + * Source IDs are typically the same as the Wiki ID or database name (e.g. lowercase a-z). + * + * @param array|string $sources Source ID (string), or [ id1 => loadUrl, id2 => loadUrl, ... ] + * @param string|array|null $loadUrl load.php url (string), or array with loadUrl key for + * backwards-compatibility. + * @throws InvalidArgumentException If array-form $loadUrl lacks a 'loadUrl' key. + */ + public function addSource( $sources, $loadUrl = null ) { + if ( !is_array( $sources ) ) { + $sources = [ $sources => $loadUrl ]; + } + foreach ( $sources as $id => $source ) { + // Disallow duplicates + if ( isset( $this->sources[$id] ) ) { + throw new RuntimeException( 'Cannot register source ' . $id . ' twice' ); + } + + // Support: MediaWiki 1.24 and earlier + if ( is_array( $source ) ) { + if ( !isset( $source['loadScript'] ) ) { + throw new InvalidArgumentException( 'Each source must have a "loadScript" key' ); + } + $source = $source['loadScript']; + } + + $this->sources[$id] = $source; + } + } + + /** + * @return string[] + */ + public function getModuleNames() { + return array_keys( $this->moduleInfos ); + } + + /** + * Get a list of module names with QUnit test suites. + * + * @internal For use by SpecialJavaScriptTest only + * @return string[] + * @codeCoverageIgnore + */ + public function getTestSuiteModuleNames() { + return $this->testSuiteModuleNames; + } + + /** + * Check whether a ResourceLoader module is registered + * + * @since 1.25 + * @param string $name + * @return bool + */ + public function isModuleRegistered( $name ) { + return isset( $this->moduleInfos[$name] ); + } + + /** + * Get the Module object for a given module name. + * + * If an array of module parameters exists but a Module 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 Module|null If module has been registered, return a + * Module instance. Otherwise, return null. + */ + public function getModule( $name ) { + if ( !isset( $this->modules[$name] ) ) { + if ( !isset( $this->moduleInfos[$name] ) ) { + // No such module + return null; + } + // Construct the requested module object + $info = $this->moduleInfos[$name]; + if ( isset( $info['factory'] ) ) { + /** @var Module $object */ + $object = call_user_func( $info['factory'], $info ); + } else { + $class = $info['class'] ?? FileModule::class; + /** @var Module $object */ + $object = new $class( $info ); + } + $object->setConfig( $this->getConfig() ); + $object->setLogger( $this->logger ); + $object->setHookContainer( $this->hookContainer ); + $object->setName( $name ); + $object->setDependencyAccessCallbacks( + [ $this, 'loadModuleDependenciesInternal' ], + [ $this, 'saveModuleDependenciesInternal' ] + ); + $object->setSkinStylesOverride( $this->moduleSkinStyles ); + $this->modules[$name] = $object; + } + + return $this->modules[$name]; + } + + /** + * Load information stored in the database and dependency tracking store about modules + * + * @param string[] $moduleNames + * @param Context $context ResourceLoader-specific context of the request + */ + public function preloadModuleInfo( array $moduleNames, Context $context ) { + // Load all tracked indirect file dependencies for the modules + $vary = Module::getVary( $context ); + $entitiesByModule = []; + foreach ( $moduleNames as $moduleName ) { + $entitiesByModule[$moduleName] = "$moduleName|$vary"; + } + $depsByEntity = $this->depStore->retrieveMulti( + self::RL_DEP_STORE_PREFIX, + $entitiesByModule + ); + // Inject the indirect file dependencies for all the modules + foreach ( $moduleNames as $moduleName ) { + $module = $this->getModule( $moduleName ); + if ( $module ) { + $entity = $entitiesByModule[$moduleName]; + $deps = $depsByEntity[$entity]; + $paths = Module::expandRelativePaths( $deps['paths'] ); + $module->setFileDependencies( $context, $paths ); + } + } + + // Batched version of ResourceLoaderWikiModule::getTitleInfo + $dbr = wfGetDB( DB_REPLICA ); + WikiModule::preloadTitleInfo( $context, $dbr, $moduleNames ); + + // Prime in-object cache for message blobs for modules with messages + $modulesWithMessages = []; + foreach ( $moduleNames as $moduleName ) { + $module = $this->getModule( $moduleName ); + if ( $module && $module->getMessages() ) { + $modulesWithMessages[$moduleName] = $module; + } + } + // Prime in-object cache for message blobs for modules with messages + $lang = $context->getLanguage(); + $store = $this->getMessageBlobStore(); + $blobs = $store->getBlobs( $modulesWithMessages, $lang ); + foreach ( $blobs as $moduleName => $blob ) { + $modulesWithMessages[$moduleName]->setMessageBlob( $blob, $lang ); + } + } + + /** + * @internal Exposed for letting getModule() pass the callable to DependencyStore + * @param string $moduleName + * @param string $variant Language/skin variant + * @return string[] List of absolute file paths + */ + public function loadModuleDependenciesInternal( $moduleName, $variant ) { + $deps = $this->depStore->retrieve( self::RL_DEP_STORE_PREFIX, "$moduleName|$variant" ); + + return Module::expandRelativePaths( $deps['paths'] ); + } + + /** + * @internal Exposed for letting getModule() pass the callable to DependencyStore + * @param string $moduleName + * @param string $variant Language/skin variant + * @param string[] $paths List of relative paths referenced during computation + * @param string[] $priorPaths List of relative paths tracked in the dependency store + */ + public function saveModuleDependenciesInternal( $moduleName, $variant, $paths, $priorPaths ) { + $hasPendingUpdate = (bool)$this->depStoreUpdateBuffer; + $entity = "$moduleName|$variant"; + + if ( array_diff( $paths, $priorPaths ) || array_diff( $priorPaths, $paths ) ) { + // Dependency store needs to be updated with the new path list + if ( $paths ) { + $deps = $this->depStore->newEntityDependencies( $paths, time() ); + $this->depStoreUpdateBuffer[$entity] = $deps; + } else { + $this->depStoreUpdateBuffer[$entity] = null; + } + } elseif ( $priorPaths ) { + // Dependency store needs to store the existing path list for longer + $this->depStoreUpdateBuffer[$entity] = '*'; + } + + // Use a DeferrableUpdate to flush the buffered dependency updates... + if ( !$hasPendingUpdate ) { + DeferredUpdates::addCallableUpdate( function () { + $updatesByEntity = $this->depStoreUpdateBuffer; + $this->depStoreUpdateBuffer = []; // consume + $cache = ObjectCache::getLocalClusterInstance(); + + $scopeLocks = []; + $depsByEntity = []; + $entitiesUnreg = []; + $entitiesRenew = []; + foreach ( $updatesByEntity as $entity => $update ) { + $lockKey = $cache->makeKey( 'rl-deps', $entity ); + $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 ); + if ( !$scopeLocks[$entity] ) { + // avoid duplicate write request slams (T124649) + // the lock must be specific to the current wiki (T247028) + continue; + } + if ( $update === null ) { + $entitiesUnreg[] = $entity; + } elseif ( $update === '*' ) { + $entitiesRenew[] = $entity; + } else { + $depsByEntity[$entity] = $update; + } + } + + $ttl = self::RL_MODULE_DEP_TTL; + $this->depStore->storeMulti( self::RL_DEP_STORE_PREFIX, $depsByEntity, $ttl ); + $this->depStore->remove( self::RL_DEP_STORE_PREFIX, $entitiesUnreg ); + $this->depStore->renew( self::RL_DEP_STORE_PREFIX, $entitiesRenew, $ttl ); + } ); + } + } + + /** + * Get the list of sources. + * + * @return array Like [ id => load.php url, ... ] + */ + public function getSources() { + return $this->sources; + } + + /** + * Get the URL to the load.php endpoint for the given ResourceLoader source. + * + * @since 1.24 + * @param string $source Source ID + * @return string + * @throws UnexpectedValueException If the source ID was not registered + */ + public function getLoadScript( $source ) { + if ( !isset( $this->sources[$source] ) ) { + throw new UnexpectedValueException( "Unknown source '$source'" ); + } + return $this->sources[$source]; + } + + /** + * @internal For use by StartUpModule only. + */ + public const HASH_LENGTH = 5; + + /** + * Create a hash for module versioning purposes. + * + * This hash is used in three ways: + * + * - To differentiate between the current version and a past version + * of a module by the same name. + * + * In the cache key of localStorage in the browser (mw.loader.store). + * This store keeps only one version of any given module. As long as the + * next version the client encounters has a different hash from the last + * version it saw, it will correctly discard it in favour of a network fetch. + * + * A browser may evict a site's storage container for any reason (e.g. when + * the user hasn't visited a site for some time, and/or when the device is + * low on storage space). Anecdotally it seems devices rarely keep unused + * storage beyond 2 weeks on mobile devices and 4 weeks on desktop. + * But, there is no hard limit or expiration on localStorage. + * ResourceLoader's Client also clears localStorage when the user changes + * their language preference or when they (temporarily) use Debug Mode. + * + * The only hard factors that reduce the range of possible versions are + * 1) the name and existence of a given module, and + * 2) the TTL for mw.loader.store, and + * 3) the `$wgResourceLoaderStorageVersion` configuration variable. + * + * - To identify a batch response of modules from load.php in an HTTP cache. + * + * When fetching modules in a batch from load.php, a combined hash + * is created by the JS code, and appended as query parameter. + * + * In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache, + * these urls are used to identify other previously cached responses. + * The range of possible versions a given version has to be unique amongst + * is determined by the maximum duration each response is stored for, which + * is controlled by `$wgResourceLoaderMaxage['versioned']`. + * + * - To detect race conditions between multiple web servers in a MediaWiki + * deployment of which some have the newer version and some still the older + * version. + * + * An HTTP request from a browser for the Startup manifest may be responded + * to by a server with the newer version. The browser may then use that to + * request a given module, which may then be responded to by a server with + * the older version. To avoid caching this for too long (which would pollute + * all other users without repairing itself), the combined hash that the JS + * client adds to the url is verified by the server (in ::sendResponseHeaders). + * If they don't match, we instruct cache proxies and clients to not cache + * this response as long as they normally would. This is also the reason + * that the algorithm used here in PHP must match the one used in JS. + * + * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and + * needs up to 7 chars in base 36. + * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga), + * (but with fnv132 we'd use very little of this range, mostly padding). + * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga). + * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega). + * + * @since 1.26 + * @param string $value + * @return string Hash + */ + public static function makeHash( $value ) { + $hash = hash( 'fnv132', $value ); + // The base_convert will pad it (if too short), + // then substr() will trim it (if too long). + return substr( + \Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ), + 0, + self::HASH_LENGTH + ); + } + + /** + * Add an error to the 'errors' array and log it. + * + * @internal For use by StartUpModule. + * @since 1.29 + * @param Exception $e + * @param string $msg + * @param array $context + */ + public function outputErrorAndLog( Exception $e, $msg, array $context = [] ) { + MWExceptionHandler::logException( $e ); + $this->logger->warning( + $msg, + $context + [ 'exception' => $e ] + ); + $this->errors[] = self::formatExceptionNoComment( $e ); + } + + /** + * Helper method to get and combine versions of multiple modules. + * + * @since 1.26 + * @param Context $context + * @param string[] $moduleNames List of known module names + * @return string Hash + */ + public function getCombinedVersion( Context $context, array $moduleNames ) { + if ( !$moduleNames ) { + return ''; + } + $hashes = array_map( function ( $module ) use ( $context ) { + try { + return $this->getModule( $module )->getVersionHash( $context ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + // If modules fail to compute a version, don't fail the request (T152266) + // and still compute versions of other modules. + $this->outputErrorAndLog( $e, + 'Calculating version for "{module}" failed: {exception}', + [ + 'module' => $module, + ] + ); + return ''; + } + }, $moduleNames ); + return self::makeHash( implode( '', $hashes ) ); + } + + /** + * Get the expected value of the 'version' query parameter. + * + * This is used by respond() to set a short Cache-Control header for requests with + * information newer than the current server has. This avoids pollution of edge caches. + * Typically during deployment. (T117587) + * + * This MUST match return value of `mw.loader#getCombinedVersion()` client-side. + * + * @since 1.28 + * @param Context $context + * @param string[] $modules + * @return string Hash + */ + public function makeVersionQuery( Context $context, array $modules ) { + // As of MediaWiki 1.28, the server and client use the same algorithm for combining + // version hashes. There is no technical reason for this to be same, and for years the + // implementations differed. If getCombinedVersion in PHP (used for StartupModule and + // E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version' + // query parameter), then this method must continue to match the JS one. + $filtered = []; + foreach ( $modules as $name ) { + if ( !$this->getModule( $name ) ) { + // If a versioned request contains a missing module, the version is a mismatch + // as the client considered a module (and version) we don't have. + return ''; + } + $filtered[] = $name; + } + return $this->getCombinedVersion( $context, $filtered ); + } + + /** + * Output a response to a load request, including the content-type header. + * + * @param Context $context Context in which a response should be formed + */ + public function respond( Context $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(); + + $responseTime = $this->measureResponseTime(); + + $response = ''; + try { // TimeoutException + // 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() === Module::GROUP_PRIVATE ) { + // Not a serious error, just means something is trying to access it (T101806) + $this->logger->debug( "Request for private module '$name' denied" ); + $this->errors[] = "Cannot build private module \"$name\""; + continue; + } + $modules[$name] = $module; + } else { + $missing[] = $name; + } + } + + try { + // Preload for getCombinedVersion() and for batch makeModuleResponse() + $this->preloadModuleInfo( array_keys( $modules ), $context ); + } + catch ( TimeoutException $e ) { + throw $e; + } + 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 ( TimeoutException $e ) { + throw $e; + } + 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( MainConfigNames::UseFileCache ) ) { + $fileCache = ResourceFileCache::newFromContext( $context ); + if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) { + return; // output handled + } + } else { + $fileCache = null; + } + + // Generate a response + $response = $this->makeModuleResponse( $context, $modules, $missing ); + + // Capture any PHP warnings from the output buffer and append them to the + // error list if we're in debug mode. + if ( $context->getDebug() ) { + $warnings = ob_get_contents(); + if ( strlen( $warnings ) ) { + $this->errors[] = $warnings; + } + } + + // Consider saving the response to file cache (unless there are errors). + if ( $fileCache && !$this->errors && $missing === [] && + ResourceFileCache::useFileCache( $context ) ) { + if ( $fileCache->isCacheWorthy() ) { + // There were enough hits, save the response to the cache + $fileCache->saveText( $response ); + } else { + $fileCache->incrMissesRecent( $context->getRequest() ); + } + } + } catch ( TimeoutException $e ) { + $this->outputErrorAndLog( $e, "Request timed out" ); + } + + $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders ); + + // Remove the output buffer and output the response + ob_end_clean(); + + if ( $context->getImageObj() && $this->errors ) { + // We can't show both the error messages and the response when it's an image. + $response = implode( "\n\n", $this->errors ); + } elseif ( $this->errors ) { + $errorText = implode( "\n\n", $this->errors ); + $errorResponse = self::makeComment( $errorText ); + if ( $context->shouldIncludeScripts() ) { + $errorResponse .= 'if (window.console && console.error) { console.error(' + . $context->encodeJson( $errorText ) + . "); }\n"; + } + + // Prepend error info to the response + $response = $errorResponse . $response; + } + + $this->errors = []; + // @phan-suppress-next-line SecurityCheck-XSS + echo $response; + } + + /** + * Send stats about the time used to build the response + * @return ScopedCallback + */ + protected function measureResponseTime() { + $statStart = $_SERVER['REQUEST_TIME_FLOAT']; + return new ScopedCallback( static function () use ( $statStart ) { + $statTiming = microtime( true ) - $statStart; + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $stats->timing( 'resourceloader.responseTime', $statTiming * 1000 ); + } ); + } + + /** + * Send main response headers to the client. + * + * Deals with Content-Type, CORS (for stylesheets), and caching. + * + * @param Context $context + * @param string $etag ETag header value + * @param bool $errors Whether there are errors in the response + * @param string[] $extra Array of extra HTTP response headers + */ + protected function sendResponseHeaders( + Context $context, $etag, $errors, array $extra = [] + ): void { + HeaderCallback::warnIfHeadersSent(); + $rlMaxage = $this->config->get( MainConfigNames::ResourceLoaderMaxage ); + // Use a short cache expiry so that updates propagate to clients quickly, if: + // - No version specified (shared resources, e.g. stylesheets) + // - There were errors (recover quickly) + // - Version mismatch (T117587, T47877) + if ( $context->getVersion() === null + || $errors + || $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() ) + ) { + $maxage = $rlMaxage['unversioned']; + // If a version was specified we can use a longer expiry time since changing + // version numbers causes cache misses + } else { + $maxage = $rlMaxage['versioned']; + } + if ( $context->getImageObj() ) { + // Output different headers if we're outputting textual errors. + if ( $errors ) { + header( 'Content-Type: text/plain; charset=utf-8' ); + } else { + $context->getImageObj()->sendResponseHeaders( $context ); + } + } elseif ( $context->getOnly() === 'styles' ) { + header( 'Content-Type: text/css; charset=utf-8' ); + header( 'Access-Control-Allow-Origin: *' ); + } else { + header( 'Content-Type: text/javascript; charset=utf-8' ); + } + // See RFC 2616 § 14.19 ETag + // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19 + header( 'ETag: ' . $etag ); + if ( $context->getDebug() ) { + // Do not cache debug responses + header( 'Cache-Control: private, no-cache, must-revalidate' ); + header( 'Pragma: no-cache' ); + } else { + header( "Cache-Control: public, max-age=$maxage, s-maxage=$maxage" ); + header( 'Expires: ' . ConvertibleTimestamp::convert( TS_RFC2822, time() + $maxage ) ); + } + foreach ( $extra as $header ) { + header( $header ); + } + } + + /** + * Respond with HTTP 304 Not Modified if appropriate. + * + * 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 Context $context + * @param string $etag ETag header value + * @return bool True if HTTP 304 was sent and output handled + */ + protected function tryRespondNotModified( Context $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 Context $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, + Context $context, + $etag + ) { + $rlMaxage = $this->config->get( MainConfigNames::ResourceLoaderMaxage ); + // Buffer output to catch warnings. + ob_start(); + // Get the maximum age the cache can be + $maxage = $context->getVersion() === null + ? $rlMaxage['unversioned'] + : $rlMaxage['versioned']; + // Minimum timestamp the cache file must have + $minTime = time() - $maxage; + $good = $fileCache->isCacheGood( ConvertibleTimestamp::convert( TS_MW, $minTime ) ); + if ( !$good ) { + try { // RL always hits the DB on file cache miss... + wfGetDB( DB_REPLICA ); + } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache + $good = $fileCache->isCacheGood(); // cache existence check + } + } + if ( $good ) { + $ts = $fileCache->cacheTimestamp(); + // Send content type and cache headers + $this->sendResponseHeaders( $context, $etag, false ); + $response = $fileCache->fetchText(); + // Capture any PHP warnings from the output buffer and append them to the + // response in a comment if we're in debug mode. + if ( $context->getDebug() ) { + $warnings = ob_get_contents(); + if ( strlen( $warnings ) ) { + $response = self::makeComment( $warnings ) . $response; + } + } + // Remove the output buffer and output the response + ob_end_clean(); + echo $response . "\n/* Cached {$ts} */"; + return true; // cache hit + } + // Clear buffer + ob_end_clean(); + + return false; // cache miss + } + + /** + * Generate a CSS or JS comment block. + * + * Only use this for public data, not error message details. + * + * @param string $text + * @return string + */ + public static function makeComment( $text ) { + $encText = str_replace( '*/', '* /', $text ); + return "/*\n$encText\n*/\n"; + } + + /** + * Handle exception display. + * + * @param Throwable $e Exception to be shown to the user + * @return string Sanitized text in a CSS/JS comment that can be returned to the user + */ + public static function formatException( Throwable $e ) { + return self::makeComment( self::formatExceptionNoComment( $e ) ); + } + + /** + * Handle exception display. + * + * @since 1.25 + * @param Throwable $e Exception to be shown to the user + * @return string Sanitized text that can be returned to the user + */ + protected static function formatExceptionNoComment( Throwable $e ) { + if ( !MWExceptionRenderer::shouldShowExceptionDetails() ) { + 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 Context $context Context in which to generate a response + * @param Module[] $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( Context $context, + array $modules, array $missing = [] + ) { + $out = ''; + $states = []; + + if ( $modules === [] && $missing === [] ) { + return <<<MESSAGE +/* This file is the Web entry point for MediaWiki's ResourceLoader: + <https://www.mediawiki.org/wiki/ResourceLoader>. In this request, + no modules were requested. Max made me put this here. */ +MESSAGE; + } + + $image = $context->getImageObj(); + if ( $image ) { + $data = $image->getImageData( $context ); + if ( $data === false ) { + $data = ''; + $this->errors[] = 'Image generation failed'; + } + return $data; + } + + foreach ( $missing as $name ) { + $states[$name] = 'missing'; + } + + $only = $context->getOnly(); + $filter = $only === 'styles' ? 'minify-css' : 'minify-js'; + $debug = (bool)$context->getDebug(); + + 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 ( $only ) { + case 'scripts': + $scripts = $content['scripts']; + if ( is_string( $scripts ) ) { + // Load scripts raw... + $strContent = $scripts; + } elseif ( is_array( $scripts ) ) { + // ...except when $scripts is an array of URLs or an associative array + $strContent = self::makeLoaderImplementScript( + $context, + $implementKey, + $scripts, + [], + [], + [] + ); + } + break; + case 'styles': + $styles = $content['styles']; + // We no longer separate into media, they are all combined now with + // custom media type groups into @media .. {} sections as part of the css string. + // Module returns either an empty array or a numerical array with css strings. + $strContent = isset( $styles['css'] ) ? implode( '', $styles['css'] ) : ''; + break; + default: + $scripts = $content['scripts'] ?? ''; + if ( is_string( $scripts ) ) { + if ( $name === 'site' || $name === 'user' ) { + // Legacy scripts that run in the global scope without a closure. + // mw.loader.implement will use eval 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 ( !$debug ) { + $scripts = self::filter( 'minify-js', $scripts ); // T107377 + } + } else { + $scripts = new XmlJsCode( $scripts ); + } + } + $strContent = self::makeLoaderImplementScript( + $context, + $implementKey, + $scripts, + $content['styles'] ?? [], + isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : [], + $content['templates'] ?? [] + ); + break; + } + + if ( !$debug ) { + $strContent = self::filter( $filter, $strContent, [ + // Important: Do not cache minifications of embedded modules + // This is especially for the private 'user.options' module, + // which varies on every pageview and would explode the cache (T84960) + 'cache' => !$module->shouldEmbedModule( $context ) + ] ); + } else { + // In debug mode, separate each response by a new line. + // For example, between 'mw.loader.implement();' statements. + $strContent = self::ensureNewline( $strContent ); + } + + if ( $only === 'scripts' ) { + // Use a linebreak between module scripts (T162719) + $out .= self::ensureNewline( $strContent ); + } else { + $out .= $strContent; + } + + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + $this->outputErrorAndLog( $e, 'Generating module package failed: {exception}' ); + + // Respond to client with error-state instead of module implementation + $states[$name] = 'error'; + unset( $modules[$name] ); + } + } + + // Update module states + if ( $context->shouldIncludeScripts() && !$context->getRaw() ) { + if ( $modules && $only === 'scripts' ) { + // Set the state of modules loaded as only scripts to ready as + // they don't have an mw.loader.implement wrapper that sets the state + foreach ( $modules as $name => $module ) { + $states[$name] = 'ready'; + } + } + + // Set the state of modules we didn't respond to with mw.loader.implement + if ( $states ) { + $stateScript = self::makeLoaderStateScript( $context, $states ); + if ( !$debug ) { + $stateScript = self::filter( 'minify-js', $stateScript ); + } + // Use a linebreak between module script and state script (T162719) + $out = self::ensureNewline( $out ) . $stateScript; + } + } elseif ( $states ) { + $this->errors[] = 'Problematic modules: ' + . $context->encodeJson( $states ); + } + + return $out; + } + + /** + * Ensure the string is either empty or ends in a line break + * @internal + * @param string $str + * @return string + */ + public static function ensureNewline( $str ) { + $end = substr( $str, -1 ); + if ( $end === false || $end === '' || $end === "\n" ) { + return $str; + } + return $str . "\n"; + } + + /** + * Get names of modules that use a certain message. + * + * @param string $messageKey + * @return string[] List of module names + */ + public function getModulesByMessage( $messageKey ) { + $moduleNames = []; + foreach ( $this->getModuleNames() as $moduleName ) { + $module = $this->getModule( $moduleName ); + if ( in_array( $messageKey, $module->getMessages() ) ) { + $moduleNames[] = $moduleName; + } + } + return $moduleNames; + } + + /** + * Return JS code that calls mw.loader.implement with given module properties. + * + * @param Context $context + * @param string $name Module name or implement key (format "`[name]@[version]`") + * @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure), + * list of URLs to JavaScript files, string of JavaScript for eval, or array with + * 'files' and 'main' properties (see ResourceLoaderModule::getScript()) + * @param mixed $styles Array of CSS strings keyed by media type, or an array of lists of URLs + * to CSS files keyed by media type + * @param mixed $messages List of messages associated with this module. May either be an + * associative array mapping message key to value, or a JSON-encoded message blob containing + * the same data, wrapped in an XmlJsCode object. + * @param array $templates Keys are name of templates and values are the source of + * the template. + * @return string JavaScript code + */ + private static function makeLoaderImplementScript( + Context $context, $name, $scripts, $styles, $messages, $templates + ) { + if ( $scripts instanceof XmlJsCode ) { + if ( $scripts->value === '' ) { + $scripts = null; + } else { + $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" ); + } + } elseif ( is_array( $scripts ) && isset( $scripts['files'] ) ) { + $files = $scripts['files']; + foreach ( $files as $path => &$file ) { + // $file is changed (by reference) from a descriptor array to the content of the file + // All of these essentially do $file = $file['content'];, some just have wrapping around it + if ( $file['type'] === 'script' ) { + // Ensure that the script has a newline at the end to close any comment in the + // last line. + $content = self::ensureNewline( $file['content'] ); + // Provide CJS `exports` (in addition to CJS2 `module.exports`) to package modules (T284511). + // $/jQuery are simply used as globals instead. + // TODO: Remove $/jQuery param from traditional module closure too (and bump caching) + $file = new XmlJsCode( "function ( require, module, exports ) {\n$content}" ); + } else { + $file = $file['content']; + } + } + $scripts = XmlJsCode::encodeObject( [ + 'main' => $scripts['main'], + 'files' => XmlJsCode::encodeObject( $files, true ) + ], true ); + } elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) { + throw new InvalidArgumentException( 'Script must be a string or an array of URLs' ); + } + + // mw.loader.implement requires 'styles', 'messages' and 'templates' to be objects (not + // arrays). json_encode considers empty arrays to be numerical and outputs "[]" instead + // of "{}". Force them to objects. + $module = [ + $name, + $scripts, + (object)$styles, + (object)$messages, + (object)$templates + ]; + self::trimArray( $module ); + + // We use pretty output unconditionally to make this method simpler. + // Minification is taken care of closer to the output. + return Xml::encodeJsCall( 'mw.loader.implement', $module, true ); + } + + /** + * Returns JS code which, when called, will register a given list of messages. + * + * @param mixed $messages Associative array mapping message key to value. + * @return string JavaScript code + */ + public static function makeMessageSetScript( $messages ) { + return 'mw.messages.set(' + . self::encodeJsonForScript( (object)$messages ) + . ');'; + } + + /** + * Combines an associative array mapping media type to CSS into a + * single stylesheet with "@media" blocks. + * + * @param array<string,string|string[]> $stylePairs Map from media type to CSS string(s) + * @return string[] CSS strings + */ + public static function makeCombinedStyles( array $stylePairs ) { + $out = []; + foreach ( $stylePairs as $media => $styles ) { + // FileModule::getStyle can return the styles as a string or an + // array of strings. This is to allow separation in the front-end. + $styles = (array)$styles; + foreach ( $styles as $style ) { + $style = trim( $style ); + // Don't output an empty "@media print { }" block (T42498) + if ( $style !== '' ) { + // Transform the media type based on request params and config + // The way that this relies on $wgRequest to propagate request params is slightly evil + $media = OutputPage::transformCssMedia( $media ); + + if ( $media === '' || $media == 'all' ) { + $out[] = $style; + } elseif ( is_string( $media ) ) { + $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}"; + } + // else: skip + } + } + } + return $out; + } + + /** + * Wrapper around json_encode that avoids needless escapes, + * and pretty-prints in debug mode. + * + * @internal For use within ResourceLoader classes only + * @since 1.32 + * @param mixed $data + * @return string|false JSON string, false on error + */ + public static function encodeJsonForScript( $data ) { + // Keep output as small as possible by disabling needless escape modes + // that PHP uses by default. + // However, while most module scripts are only served on HTTP responses + // for JavaScript, some modules can also be embedded in the HTML as inline + // scripts. This, and the fact that we sometimes need to export strings + // containing user-generated content and labels that may genuinely contain + // a sequences like "</script>", we need to encode either '/' or '<'. + // By default PHP escapes '/'. Let's escape '<' instead which is less common + // and allows URLs to mostly remain readable. + $jsonFlags = JSON_UNESCAPED_SLASHES | + JSON_UNESCAPED_UNICODE | + JSON_HEX_TAG | + JSON_HEX_AMP; + if ( self::inDebugMode() ) { + $jsonFlags |= JSON_PRETTY_PRINT; + } + return json_encode( $data, $jsonFlags ); + } + + /** + * Returns a JS call to mw.loader.state, which sets the state of modules + * to a given value: + * + * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ): + * Set the state of modules with the given names to the given states + * + * @internal For use by StartUpModule + * @param Context $context + * @param array<string,string> $states + * @return string JavaScript code + */ + public static function makeLoaderStateScript( + Context $context, array $states + ) { + return 'mw.loader.state(' + . $context->encodeJson( $states ) + . ');'; + } + + private static function isEmptyObject( stdClass $obj ) { + foreach ( $obj as $key => $value ) { + return false; + } + return true; + } + + /** + * Remove empty values from the end of an array. + * + * Values considered empty: + * + * - null + * - [] + * - new XmlJsCode( '{}' ) + * - new stdClass() + * - (object)[] + * + * @param array &$array + */ + private static function trimArray( array &$array ): void { + $i = count( $array ); + while ( $i-- ) { + if ( $array[$i] === null + || $array[$i] === [] + || ( $array[$i] instanceof XmlJsCode && $array[$i]->value === '{}' ) + || ( $array[$i] instanceof stdClass && self::isEmptyObject( $array[$i] ) ) + ) { + unset( $array[$i] ); + } else { + break; + } + } + } + + /** + * Format JS code which calls `mw.loader.register()` with the given parameters. + * + * @par Example + * @code + * + * ResourceLoader::makeLoaderRegisterScript( $context, [ + * [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ], + * [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ], + * ... + * ] ): + * @endcode + * + * @internal For use by StartUpModule only + * @param Context $context + * @param array[] $modules Array of module registration arrays, each containing + * - string: module name + * - string: module version + * - array|null: List of dependencies (optional) + * - string|null: Module group (optional) + * - string|null: Name of foreign module source, or 'local' (optional) + * - string|null: Script body of a skip function (optional) + * @phan-param array<int,array{0:string,1:string,2?:?array,3?:?string,4?:?string,5?:?string}> $modules + * @return string JavaScript code + */ + public static function makeLoaderRegisterScript( + Context $context, array $modules + ) { + // Optimisation: Transform dependency names into indexes when possible + // to produce smaller output. They are expanded by mw.loader.register on + // the other end. + $index = []; + foreach ( $modules as $i => &$module ) { + // Build module name index + $index[$module[0]] = $i; + } + foreach ( $modules as &$module ) { + if ( isset( $module[2] ) ) { + foreach ( $module[2] as &$dependency ) { + if ( isset( $index[$dependency] ) ) { + // Replace module name in dependency list with index + $dependency = $index[$dependency]; + } + } + } + } + + array_walk( $modules, [ self::class, 'trimArray' ] ); + + return 'mw.loader.register(' + . $context->encodeJson( $modules ) + . ');'; + } + + /** + * Format JS code which calls `mw.loader.addSource()` with the given parameters. + * + * - ResourceLoader::makeLoaderSourcesScript( $context, + * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] + * ); + * Register sources with the given IDs and properties. + * + * @internal For use by StartUpModule only + * @param Context $context + * @param array<string,string> $sources + * @return string JavaScript code + */ + public static function makeLoaderSourcesScript( + Context $context, array $sources + ) { + return 'mw.loader.addSource(' + . $context->encodeJson( $sources ) + . ');'; + } + + /** + * Wrap JavaScript code to run after the startup module. + * + * @param string $script JavaScript code + * @return string JavaScript code + */ + public static function makeLoaderConditionalScript( $script ) { + // Adds a function to lazy-created RLQ + return '(RLQ=window.RLQ||[]).push(function(){' . + trim( $script ) . '});'; + } + + /** + * Wrap JavaScript code to run after a required module. + * + * @since 1.32 + * @param string|string[] $modules Module name(s) + * @param string $script JavaScript code + * @return string JavaScript code + */ + public static function makeInlineCodeWithModule( $modules, $script ) { + // Adds an array to lazy-created RLQ + return '(RLQ=window.RLQ||[]).push([' + . self::encodeJsonForScript( $modules ) . ',' + . 'function(){' . trim( $script ) . '}' + . ']);'; + } + + /** + * Make an HTML script that runs given JS code after startup and base modules. + * + * The code will be wrapped in a closure, and it will be executed by ResourceLoader's + * startup module if the client has adequate support for MediaWiki JavaScript code. + * + * @param string $script JavaScript code + * @param string|null $nonce Content-Security-Policy nonce + * (from `OutputPage->getCSP()->getNonce()`) + * @return string|WrappedString HTML + */ + public static function makeInlineScript( $script, $nonce = null ) { + $js = self::makeLoaderConditionalScript( $script ); + $escNonce = ''; + if ( $nonce === null ) { + wfWarn( __METHOD__ . " did not get nonce. Will break CSP" ); + } elseif ( $nonce !== false ) { + // If it was false, CSP is disabled, so no nonce attribute. + // Nonce should be only base64 characters, so should be safe, + // but better to be safely escaped than sorry. + $escNonce = ' nonce="' . htmlspecialchars( $nonce ) . '"'; + } + + return new WrappedString( + Html::inlineScript( $js, $nonce ), + "<script$escNonce>(RLQ=window.RLQ||[]).push(function(){", + '});</script>' + ); + } + + /** + * Return JS code which will set the MediaWiki configuration array to + * the given value. + * + * @param array $configuration List of configuration values keyed by variable name + * @return string JavaScript code + * @throws Exception + */ + public static function makeConfigSetScript( array $configuration ) { + $json = self::encodeJsonForScript( $configuration ); + if ( $json === false ) { + $e = new Exception( + 'JSON serialization of config data failed. ' . + 'This usually means the config data is not valid UTF-8.' + ); + MWExceptionHandler::logException( $e ); + return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');'; + } + return "mw.config.set($json);"; + } + + /** + * Convert an array of module names to a packed query string. + * + * For example, `[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ]` + * becomes `'foo.bar,baz|bar.baz,quux'`. + * + * This process is reversed by ResourceLoader::expandModuleNames(). + * See also mw.loader#buildModulesString() which is a port of this, used + * on the client-side. + * + * @param string[] $modules List of module names (strings) + * @return string Packed query string + */ + public static function makePackedModulesString( array $modules ) { + $moduleMap = []; // [ prefix => [ suffixes ] ] + foreach ( $modules as $module ) { + $pos = strrpos( $module, '.' ); + $prefix = $pos === false ? '' : substr( $module, 0, $pos ); + $suffix = $pos === false ? $module : substr( $module, $pos + 1 ); + $moduleMap[$prefix][] = $suffix; + } + + $arr = []; + foreach ( $moduleMap as $prefix => $suffixes ) { + $p = $prefix === '' ? '' : $prefix . '.'; + $arr[] = $p . implode( ',', $suffixes ); + } + return implode( '|', $arr ); + } + + /** + * Expand a string of the form `jquery.foo,bar|jquery.ui.baz,quux` to + * an array of module names like `[ 'jquery.foo', 'jquery.bar', + * 'jquery.ui.baz', 'jquery.ui.quux' ]`. + * + * This process is reversed by ResourceLoader::makePackedModulesString(). + * + * @since 1.33 + * @param string $modules Packed module name list + * @return string[] Array of module names + */ + public static function expandModuleNames( $modules ) { + $retval = []; + $exploded = explode( '|', $modules ); + foreach ( $exploded as $group ) { + if ( strpos( $group, ',' ) === false ) { + // This is not a set of modules in foo.bar,baz notation + // but a single module + $retval[] = $group; + } else { + // This is a set of modules in foo.bar,baz notation + $pos = strrpos( $group, '.' ); + if ( $pos === false ) { + // Prefixless modules, i.e. without dots + $retval = array_merge( $retval, explode( ',', $group ) ); + } else { + // We have a prefix and a bunch of suffixes + $prefix = substr( $group, 0, $pos ); // 'foo' + $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // [ 'bar', 'baz' ] + foreach ( $suffixes as $suffix ) { + $retval[] = "$prefix.$suffix"; + } + } + } + } + return $retval; + } + + /** + * Determine whether debug mode is on. + * + * Order of priority is: + * - 1) Request parameter, + * - 2) Cookie, + * - 3) Site configuration. + * + * @return int + */ + public static function inDebugMode() { + if ( self::$debugMode === null ) { + global $wgRequest; + $resourceLoaderDebug = MediaWikiServices::getInstance()->getMainConfig()->get( + MainConfigNames::ResourceLoaderDebug ); + $str = $wgRequest->getRawVal( 'debug', + $wgRequest->getCookie( 'resourceLoaderDebug', '', $resourceLoaderDebug ? 'true' : '' ) + ); + self::$debugMode = Context::debugFromString( $str ); + } + return self::$debugMode; + } + + /** + * Reset static members used for caching. + * + * Global state and $wgRequest are evil, but we're using it right + * now and sometimes we need to be able to force ResourceLoader to + * re-evaluate the context because it has changed (e.g. in the test suite). + * + * @internal For use by unit tests + * @codeCoverageIgnore + */ + public static function clearCache() { + self::$debugMode = null; + } + + /** + * Build a load.php URL + * + * @since 1.24 + * @param string $source Name of the ResourceLoader source + * @param Context $context + * @param array $extraQuery + * @return string URL to load.php. May be protocol-relative if $wgLoadScript is, too. + */ + public function createLoaderURL( $source, Context $context, + array $extraQuery = [] + ) { + $query = self::createLoaderQuery( $context, $extraQuery ); + $script = $this->getLoadScript( $source ); + + return wfAppendQuery( $script, $query ); + } + + /** + * Helper for createLoaderURL() + * + * @since 1.24 + * @see makeLoaderQuery + * @param Context $context + * @param array $extraQuery + * @return array + */ + protected static function createLoaderQuery( + Context $context, array $extraQuery = [] + ) { + return self::makeLoaderQuery( + $context->getModules(), + $context->getLanguage(), + $context->getSkin(), + $context->getUser(), + $context->getVersion(), + $context->getDebug(), + $context->getOnly(), + $context->getRequest()->getBool( 'printable' ), + null, + $extraQuery + ); + } + + /** + * Build a query array (array representation of query string) for load.php. Helper + * function for createLoaderURL(). + * + * @param string[] $modules + * @param string $lang + * @param string $skin + * @param string|null $user + * @param string|null $version + * @param int $debug + * @param string|null $only + * @param bool $printable + * @param bool|null $handheld Unused as of MW 1.38 + * @param array $extraQuery + * @return array + */ + public static function makeLoaderQuery( array $modules, $lang, $skin, $user = null, + $version = null, $debug = Context::DEBUG_OFF, $only = null, + $printable = false, $handheld = null, array $extraQuery = [] + ) { + $query = [ + 'modules' => self::makePackedModulesString( $modules ), + ]; + // Keep urls short by omitting query parameters that + // match the defaults assumed by Context. + // Note: This relies on the defaults either being insignificant or forever constant, + // as otherwise cached urls could change in meaning when the defaults change. + if ( $lang !== Context::DEFAULT_LANG ) { + $query['lang'] = $lang; + } + if ( $skin !== Context::DEFAULT_SKIN ) { + $query['skin'] = $skin; + } + if ( $debug !== Context::DEBUG_OFF ) { + $query['debug'] = strval( $debug ); + } + if ( $user !== null ) { + $query['user'] = $user; + } + if ( $version !== null ) { + $query['version'] = $version; + } + if ( $only !== null ) { + $query['only'] = $only; + } + if ( $printable ) { + $query['printable'] = 1; + } + $query += $extraQuery; + + // Make queries uniform in order + ksort( $query ); + return $query; + } + + /** + * Check a module name for validity. + * + * Module names may not contain pipes (|), commas (,) or exclamation marks (!) and can be + * at most 255 bytes. + * + * @param string $moduleName Module name to check + * @return bool Whether $moduleName is a valid module name + */ + public static function isValidModuleName( $moduleName ) { + $len = strlen( $moduleName ); + return $len <= 255 && strcspn( $moduleName, '!,|', 0, $len ) === $len; + } + + /** + * Return a LESS compiler that is set up for use with MediaWiki. + * + * @since 1.27 + * @param array $vars Associative array of variables that should be used + * for compilation. Since 1.32, this method no longer automatically includes + * global LESS vars from ResourceLoader::getLessVars (T191937). + * @param array $importDirs Additional directories to look in for @import (since 1.36) + * @throws MWException + * @return Less_Parser + */ + public function getLessCompiler( array $vars = [], array $importDirs = [] ) { + global $IP; + // When called from the installer, it is possible that a required PHP extension + // is missing (at least for now; see T49564). If this is the case, throw an + // exception (caught by the installer) to prevent a fatal error later on. + if ( !class_exists( Less_Parser::class ) ) { + throw new MWException( 'MediaWiki requires the less.php parser' ); + } + + $importDirs[] = "$IP/resources/src/mediawiki.less"; + + $parser = new Less_Parser; + $parser->ModifyVars( $vars ); + // SetImportDirs expects an array like [ 'path1' => '', 'path2' => '' ] + $parser->SetImportDirs( array_fill_keys( $importDirs, '' ) ); + $parser->SetOption( 'relativeUrls', false ); + + return $parser; + } + + /** + * Resolve a possibly relative URL against a base URL. + * + * The base URL must have a server and should have a protocol. + * A protocol-relative base expands to HTTPS. + * + * This is a standalone version of MediaWiki's wfExpandUrl (T32956). + * + * @internal For use by core ResourceLoader classes only + * @param string $base + * @param string $url + * @return string URL + */ + public function expandUrl( string $base, string $url ): string { + // Net_URL2::resolve() doesn't allow protocol-relative URLs, but we do. + $isProtoRelative = strpos( $base, '//' ) === 0; + if ( $isProtoRelative ) { + $base = "https:$base"; + } + // Net_URL2::resolve() takes care of throwing if $base doesn't have a server. + $baseUrl = new Net_URL2( $base ); + $ret = $baseUrl->resolve( $url ); + if ( $isProtoRelative ) { + $ret->setScheme( false ); + } + return $ret->getURL(); + } + + /** + * Run JavaScript or CSS data through a filter, caching the filtered result for future calls. + * + * Available filters are: + * + * - minify-js + * - minify-css + * + * If $data is empty, only contains whitespace or the filter was unknown, + * $data is returned unmodified. + * + * @param string $filter Name of filter to run + * @param string $data Text to filter, such as JavaScript or CSS text + * @param array<string,bool> $options Keys: + * - (bool) cache: Whether to allow caching this data. Default: true. + * @return string Filtered data or unfiltered data + */ + public static function filter( $filter, $data, array $options = [] ) { + if ( strpos( $data, self::FILTER_NOMIN ) !== false ) { + return $data; + } + + if ( isset( $options['cache'] ) && $options['cache'] === false ) { + return self::applyFilter( $filter, $data ) ?? $data; + } + + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING ); + + $key = $cache->makeGlobalKey( + 'resourceloader-filter', + $filter, + self::CACHE_VERSION, + md5( $data ) + ); + + $incKey = "resourceloader_cache.$filter.hit"; + $result = $cache->getWithSetCallback( + $key, + BagOStuff::TTL_DAY, + function () use ( $filter, $data, &$incKey ) { + $incKey = "resourceloader_cache.$filter.miss"; + return self::applyFilter( $filter, $data ); + } + ); + $stats->increment( $incKey ); + if ( $result === null ) { + // Cached failure + $result = $data; + } + + return $result; + } + + /** + * @param string $filter + * @param string $data + * @return string|null + */ + private static function applyFilter( $filter, $data ) { + $data = trim( $data ); + if ( $data ) { + try { + $data = ( $filter === 'minify-css' ) + ? CSSMin::minify( $data ) + : JavaScriptMinifier::minify( $data ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + MWExceptionHandler::logException( $e ); + return null; + } + } + return $data; + } + + /** + * Get user default options to expose to JavaScript on all pages via `mw.user.options`. + * + * @internal Exposed for use from Resources.php + * @param Context $context + * @return array + */ + public static function getUserDefaults( Context $context ): array { + // TODO inject + $defaultOptions = MediaWikiServices::getInstance()->getUserOptionsLookup()->getDefaultOptions(); + $keysToExclude = []; + $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ); + $hookRunner->onResourceLoaderExcludeUserOptions( $keysToExclude, $context ); + foreach ( $keysToExclude as $excludedKey ) { + unset( $defaultOptions[ $excludedKey ] ); + } + return $defaultOptions; + } + + /** + * Get site configuration settings to expose to JavaScript on all pages via `mw.config`. + * + * @internal Exposed for use from Resources.php + * @param Context $context + * @param Config $conf + * @return array + */ + public static function getSiteConfigSettings( + Context $context, Config $conf + ): array { + // Namespace related preparation + // - wgNamespaceIds: Key-value pairs of all localized, canonical and aliases for namespaces. + // - wgCaseSensitiveNamespaces: Array of namespaces that are case-sensitive. + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $namespaceIds = $contLang->getNamespaceIds(); + $caseSensitiveNamespaces = []; + $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); + foreach ( $nsInfo->getCanonicalNamespaces() as $index => $name ) { + $namespaceIds[$contLang->lc( $name )] = $index; + if ( !$nsInfo->isCapitalized( $index ) ) { + $caseSensitiveNamespaces[] = $index; + } + } + + $illegalFileChars = $conf->get( MainConfigNames::IllegalFileChars ); + + // Build list of variables + $skin = $context->getSkin(); + + // Start of supported and stable config vars (for use by extensions/gadgets). + $vars = [ + 'debug' => $context->getDebug(), + 'skin' => $skin, + 'stylepath' => $conf->get( MainConfigNames::StylePath ), + 'wgArticlePath' => $conf->get( MainConfigNames::ArticlePath ), + 'wgScriptPath' => $conf->get( MainConfigNames::ScriptPath ), + 'wgScript' => $conf->get( MainConfigNames::Script ), + 'wgSearchType' => $conf->get( MainConfigNames::SearchType ), + 'wgVariantArticlePath' => $conf->get( MainConfigNames::VariantArticlePath ), + 'wgServer' => $conf->get( MainConfigNames::Server ), + 'wgServerName' => $conf->get( MainConfigNames::ServerName ), + 'wgUserLanguage' => $context->getLanguage(), + 'wgContentLanguage' => $contLang->getCode(), + 'wgVersion' => MW_VERSION, + 'wgFormattedNamespaces' => $contLang->getFormattedNamespaces(), + 'wgNamespaceIds' => $namespaceIds, + 'wgContentNamespaces' => $nsInfo->getContentNamespaces(), + 'wgSiteName' => $conf->get( MainConfigNames::Sitename ), + 'wgDBname' => $conf->get( MainConfigNames::DBname ), + 'wgWikiID' => WikiMap::getCurrentWikiId(), + 'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces, + 'wgCommentCodePointLimit' => CommentStore::COMMENT_CHARACTER_LIMIT, + 'wgExtensionAssetsPath' => $conf->get( MainConfigNames::ExtensionAssetsPath ), + ]; + // End of stable config vars. + + // Internal variables for use by MediaWiki core and/or ResourceLoader. + $vars += [ + // @internal For mediawiki.widgets + 'wgUrlProtocols' => wfUrlProtocols(), + // @internal For mediawiki.page.watch + // Force object to avoid "empty" associative array from + // becoming [] instead of {} in JS (T36604) + 'wgActionPaths' => (object)$conf->get( MainConfigNames::ActionPaths ), + // @internal For mediawiki.language + 'wgTranslateNumerals' => $conf->get( MainConfigNames::TranslateNumerals ), + // @internal For mediawiki.Title + 'wgExtraSignatureNamespaces' => $conf->get( MainConfigNames::ExtraSignatureNamespaces ), + 'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ), + 'wgIllegalFileChars' => Title::convertByteClassToUnicodeClass( $illegalFileChars ), + ]; + + Hooks::runner()->onResourceLoaderGetConfigVars( $vars, $skin, $conf ); + + return $vars; + } +} + +class_alias( ResourceLoader::class, 'ResourceLoader' ); diff --git a/includes/ResourceLoader/SiteModule.php b/includes/ResourceLoader/SiteModule.php new file mode 100644 index 000000000000..080f001fef0b --- /dev/null +++ b/includes/ResourceLoader/SiteModule.php @@ -0,0 +1,64 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MainConfigNames; + +/** + * Module for site customizations. + * + * @ingroup ResourceLoader + * @internal + */ +class SiteModule extends WikiModule { + /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */ + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Get list of pages used by this module + * + * @param Context $context + * @return array[] + */ + protected function getPages( Context $context ) { + $pages = []; + if ( $this->getConfig()->get( MainConfigNames::UseSiteJs ) ) { + $skin = $context->getSkin(); + $pages['MediaWiki:Common.js'] = [ 'type' => 'script' ]; + $pages['MediaWiki:' . ucfirst( $skin ) . '.js'] = [ 'type' => 'script' ]; + $this->getHookRunner()->onResourceLoaderSiteModulePages( $skin, $pages ); + } + return $pages; + } + + /** + * @param Context|null $context + * @return array + */ + public function getDependencies( Context $context = null ) { + return [ 'site.styles' ]; + } +} + +/** @deprecated since 1.39 */ +class_alias( SiteModule::class, 'ResourceLoaderSiteModule' ); diff --git a/includes/ResourceLoader/SiteStylesModule.php b/includes/ResourceLoader/SiteStylesModule.php new file mode 100644 index 000000000000..57fce7536206 --- /dev/null +++ b/includes/ResourceLoader/SiteStylesModule.php @@ -0,0 +1,71 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MainConfigNames; + +/** + * Module for site style customizations. + * + * @ingroup ResourceLoader + * @internal + */ +class SiteStylesModule extends WikiModule { + /** @var string[] What client platforms the module targets (e.g. desktop, mobile) */ + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * Get list of pages used by this module + * + * @param Context $context + * @return array[] + */ + protected function getPages( Context $context ) { + $pages = []; + if ( $this->getConfig()->get( MainConfigNames::UseSiteCss ) ) { + $skin = $context->getSkin(); + $pages['MediaWiki:Common.css'] = [ 'type' => 'style' ]; + $pages['MediaWiki:' . ucfirst( $skin ) . '.css'] = [ 'type' => 'style' ]; + $pages['MediaWiki:Print.css'] = [ 'type' => 'style', 'media' => 'print' ]; + $this->getHookRunner()->onResourceLoaderSiteStylesModulePages( $skin, $pages ); + } + return $pages; + } + + /** + * @return string + */ + public function getType() { + return self::LOAD_STYLES; + } + + /** + * @return string + */ + public function getGroup() { + return self::GROUP_SITE; + } +} + +/** @deprecated since 1.39 */ +class_alias( SiteStylesModule::class, 'ResourceLoaderSiteStylesModule' ); diff --git a/includes/ResourceLoader/SkinModule.php b/includes/ResourceLoader/SkinModule.php new file mode 100644 index 000000000000..63211cc6cae2 --- /dev/null +++ b/includes/ResourceLoader/SkinModule.php @@ -0,0 +1,724 @@ +<?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 + */ +namespace MediaWiki\ResourceLoader; + +use Config; +use ConfigException; +use InvalidArgumentException; +use MediaWiki\MainConfigNames; +use OutputPage; +use Wikimedia\Minify\CSSMin; + +/** + * Module for skin stylesheets. + * + * @ingroup ResourceLoader + * @internal + */ +class SkinModule extends LessVarFileModule { + /** + * All skins are assumed to be compatible with mobile + */ + public $targets = [ 'desktop', 'mobile' ]; + + /** + * Every skin should define which features it would like to reuse for core inside a + * ResourceLoader module that has set the class to SkinModule. + * For a feature to be valid it must be listed here along with the associated resources + * + * The following features are available: + * + * "accessibility": + * Adds universal accessibility rules. + * + * "logo": + * Adds CSS to style an element with class `mw-wiki-logo` using the value of wgLogos['1x']. + * This is enabled by default if no features are added. + * + * "normalize": + * Styles needed to normalize rendering across different browser rendering engines. + * All to address bugs and common browser inconsistencies for skins and extensions. + * Inspired by necolas' normalize.css. This is meant to be kept lean, + * basic styling beyond normalization should live in one of the following modules. + * + * "elements": + * The base level that only contains the most basic of common skin styles. + * Only styles for single elements are included, no styling for complex structures like the + * TOC is present. This level is for skins that want to implement the entire style of even + * content area structures like the TOC themselves. + * + * "content": + * Deprecated. Alias for "content-media". + * + * "content-thumbnails": + * Deprecated. Alias for "content-media". + * + * "content-media": + * Styles for thumbnails and floated elements. + * Will add styles for the new media structure on wikis where $wgParserEnableLegacyMediaDOM is disabled, + * or $wgUseContentMediaStyles is enabled. + * See https://www.mediawiki.org/wiki/Parsing/Media_structure + * + * "content-links": + * The skin will apply optional styling rules for links that should be styled differently + * to the rules in `elements` and `normalize`. It provides support for .mw-selflink, + * a.new (red links), a.stub (stub links) and some basic styles for external links. + * It also provides rules supporting the underline user preference. + * + * "content-links-external": + * The skin will apply optional styling rules to links to provide icons for different file types. + * + * "content-body": + * Styles for the mw-parser-output class. + * + * "content-tables": + * Styles .wikitable style tables. + * + * "interface": + * The highest level, this stylesheet contains extra common styles for classes like + * .firstHeading, #contentSub, et cetera which are not outputted by MediaWiki but are common + * to skins like MonoBook, Vector, etc... Essentially this level is for styles that are + * common to MonoBook clones. + * + * "interface-category": + * Styles used for styling the categories in a horizontal bar at the bottom of the content. + * + * "interface-message-box": + * Styles for message boxes. + * + * "i18n-ordered-lists": + * Styles for ordered lists elements that support mixed language content. + * + * "i18n-all-lists-margins": + * Styles for margins of list elements where LTR and RTL are mixed. + * + * "i18n-headings": + * Styles for line-heights of headings across different languages. + * + * "toc" + * Styling rules for the table of contents. + * + * NOTE: The order of the keys defines the order in which the styles are output. + */ + private const FEATURE_FILES = [ + 'accessibility' => [ + 'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ], + ], + 'normalize' => [ + 'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ], + ], + 'logo' => [ + // Applies the logo and ensures it downloads prior to printing. + 'all' => [ 'resources/src/mediawiki.skinning/logo.less' ], + // Reserves whitespace for the logo in a pseudo element. + 'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ], + ], + 'content-media' => [ + 'all' => [ 'resources/src/mediawiki.skinning/content.thumbnails-common.less' ], + 'screen' => [ 'resources/src/mediawiki.skinning/content.thumbnails-screen.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/content.thumbnails-print.less' ], + ], + 'content-links' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ] + ], + 'content-links-external' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ] + ], + 'content-body' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ], + ], + 'content-tables' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ] + ], + 'interface' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ], + ], + 'interface-category' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ], + ], + 'interface-message-box' => [ + 'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ], + ], + 'elements' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ], + ], + // The styles of the legacy feature was removed in 1.39. This can be removed when no skins are referencing it + // (Dropping this line will trigger InvalidArgumentException: Feature 'legacy' is not recognised) + 'legacy' => [], + 'i18n-ordered-lists' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ], + ], + 'i18n-all-lists-margins' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ], + ], + 'i18n-headings' => [ + 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ], + ], + 'toc' => [ + 'all' => [ 'resources/src/mediawiki.skinning/toc/common.css' ], + 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ], + 'print' => [ 'resources/src/mediawiki.skinning/toc/print.css' ], + ], + ]; + + /** @var string[] */ + private $features; + + /** + * Defaults for when a 'features' parameter is specified. + * + * When these apply, they are the merged into the specified options. + * + * @var array<string,bool> + */ + private const DEFAULT_FEATURES_SPECIFIED = [ + 'accessibility' => true, + 'content-body' => true, + 'toc' => true, + ]; + + /** + * Default for when the 'features' parameter is absent. + * + * For backward-compatibility, when the parameter is not declared + * only 'logo' styles are loaded. + * + * @var string[] + */ + private const DEFAULT_FEATURES_ABSENT = [ + 'logo', + ]; + + private const LESS_MESSAGES = [ + // `toc` feature, used in screen.less + 'hidetoc', + 'showtoc', + ]; + + /** + * @param array $options + * - features: Map from feature keys to boolean indicating whether to load + * or not include the associated styles. + * Keys not specified get their default from self::DEFAULT_FEATURES_SPECIFIED. + * + * If this is set to a list of strings, then the defaults do not apply. + * Use this at your own risk as it means you opt-out from backwards compatibility + * provided through these defaults. For example, when features are migrated + * to the SkinModule system from other parts of MediaWiki, those new feature keys + * may be enabled by default, and opting out means you may be missing some styles + * after an upgrade until you enable them or implement them by other means. + * + * - lessMessages: Interface message keys to export as LESS variables. + * See also ResourceLoaderLessVarFileModule. + * + * @param string|null $localBasePath + * @param string|null $remoteBasePath + * @see Additonal options at $wgResourceModules + */ + public function __construct( + array $options = [], + $localBasePath = null, + $remoteBasePath = null + ) { + $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT; + $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 ); + + $messages = ''; + // NOTE: Compatibility is only applied when features are provided + // in map-form. The list-form does not currently get these. + $features = $listMode ? self::applyFeaturesCompatibility( + array_fill_keys( $features, true ), false, $messages + ) : self::applyFeaturesCompatibility( $features, true, $messages ); + + foreach ( $features as $key => $enabled ) { + if ( !isset( self::FEATURE_FILES[$key] ) ) { + throw new InvalidArgumentException( "Feature '$key' is not recognised" ); + } + } + + $this->features = $listMode + ? array_keys( array_filter( $features ) ) + : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) ); + + // Only the `toc` feature makes use of interface messages. + // For skins not using the `toc` feature, make sure LocalisationCache + // remains untouched (T270027). + if ( in_array( 'toc', $this->features ) ) { + $options['lessMessages'] = array_merge( + $options['lessMessages'] ?? [], + self::LESS_MESSAGES + ); + } + + if ( $messages !== '' ) { + $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. '; + $options['deprecated'] = $messages; + } + parent::__construct( $options, $localBasePath, $remoteBasePath ); + } + + /** + * @internal + * @param array $features + * @param bool $addUnspecifiedFeatures whether to add new features if missing + * @param string &$messages to report deprecations + * @return array + */ + protected static function applyFeaturesCompatibility( + array $features, bool $addUnspecifiedFeatures = true, &$messages = '' + ): array { + // The `content` feature is mapped to `content-media`. + if ( isset( $features[ 'content' ] ) ) { + $features[ 'content-media' ] = $features[ 'content' ]; + unset( $features[ 'content' ] ); + $messages .= '[1.37] The use of the `content` feature with ResourceLoaderSkinModule' + . ' is deprecated. Use `content-media` instead. '; + } + + // The `content-thumbnails` feature is mapped to `content-media`. + if ( isset( $features[ 'content-thumbnails' ] ) ) { + $features[ 'content-media' ] = $features[ 'content-thumbnails' ]; + $messages .= '[1.37] The use of the `content-thumbnails` feature with ResourceLoaderSkinModule' + . ' is deprecated. Use `content-media` instead. '; + unset( $features[ 'content-thumbnails' ] ); + } + + // If `content-links` feature is set but no preference for `content-links-external` is set + if ( $addUnspecifiedFeatures && isset( $features[ 'content-links' ] ) + && !isset( $features[ 'content-links-external' ] ) + ) { + // Assume the same true/false preference for both. + $features[ 'content-links-external' ] = $features[ 'content-links' ]; + } + + // The legacy feature no longer exists (T89981) but to avoid fatals in skins is retained. + if ( isset( $features['legacy'] ) && $features['legacy'] ) { + $messages .= '[1.37] The use of the `legacy` feature with ResourceLoaderSkinModule is deprecated' + . '(T89981) and is a NOOP since 1.39 (T304325). This should be urgently omited to retain compatibility ' + . 'with future MediaWiki versions'; + } + + // The `content-links` feature was split out from `elements`. + // Make sure skins asking for `elements` also get these by default. + if ( $addUnspecifiedFeatures && isset( $features[ 'element' ] ) && !isset( $features[ 'content-links' ] ) ) { + $features[ 'content-links' ] = $features[ 'element' ]; + } + + // `content-parser-output` was renamed to `content-body`. + // No need to go through deprecation process here since content-parser-output added and removed in 1.36. + // Remove this check when no matches for + // https://codesearch.wmcloud.org/search/?q=content-parser-output&i=nope&files=&excludeFiles=&repos= + if ( isset( $features[ 'content-parser-output' ] ) ) { + $features[ 'content-body' ] = $features[ 'content-parser-output' ]; + unset( $features[ 'content-parser-output' ] ); + } + + return $features; + } + + /** + * Get styles defined in the module definition, plus any enabled feature styles. + * + * @param Context $context + * @return string[][] + */ + public function getStyleFiles( Context $context ) { + $styles = parent::getStyleFiles( $context ); + + // Bypass the current module paths so that these files are served from core, + // instead of the individual skin's module directory. + list( $defaultLocalBasePath, $defaultRemoteBasePath ) = + FileModule::extractBasePaths( + [], + null, + $this->getConfig()->get( MainConfigNames::ResourceBasePath ) + ); + + $featureFilePaths = []; + + foreach ( self::FEATURE_FILES as $feature => $featureFiles ) { + if ( in_array( $feature, $this->features ) ) { + foreach ( $featureFiles as $mediaType => $files ) { + foreach ( $files as $filepath ) { + $featureFilePaths[$mediaType][] = new FilePath( + $filepath, + $defaultLocalBasePath, + $defaultRemoteBasePath + ); + } + } + if ( $feature === 'content-media' && ( + !$this->getConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM ) || + $this->getConfig()->get( MainConfigNames::UseContentMediaStyles ) + ) ) { + $featureFilePaths['all'][] = new FilePath( + 'resources/src/mediawiki.skinning/content.media-common.less', + $defaultLocalBasePath, + $defaultRemoteBasePath + ); + $featureFilePaths['screen'][] = new FilePath( + 'resources/src/mediawiki.skinning/content.media-screen.less', + $defaultLocalBasePath, + $defaultRemoteBasePath + ); + $featureFilePaths['print'][] = new FilePath( + 'resources/src/mediawiki.skinning/content.media-print.less', + $defaultLocalBasePath, + $defaultRemoteBasePath + ); + } + } + } + + // Styles defines in options are added to the $featureFilePaths to ensure + // that $featureFilePaths styles precede module defined ones. + // This is particularly important given the `normalize` styles need to be the first + // outputted (see T269618). + foreach ( $styles as $mediaType => $paths ) { + $featureFilePaths[$mediaType] = array_merge( $featureFilePaths[$mediaType] ?? [], $paths ); + } + + return $featureFilePaths; + } + + /** + * @param Context $context + * @return array + */ + public function getStyles( Context $context ) { + $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() ); + $styles = parent::getStyles( $context ); + $this->normalizeStyles( $styles ); + + $isLogoFeatureEnabled = in_array( 'logo', $this->features ); + if ( $isLogoFeatureEnabled ) { + $default = !is_array( $logo ) ? $logo : ( $logo['1x'] ?? null ); + // Can't add logo CSS if no logo defined. + if ( !$default ) { + return $styles; + } + $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: 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-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-resolution: 2dppx), ' . + '(min-resolution: 192dpi)' + ][] = '.mw-wiki-logo { background-image: ' . + CSSMin::buildUrlValue( $logo['2x'] ) . ';' . + 'background-size: 135px auto; }'; + } + } + } + } + + return $styles; + } + + /** + * @param Context $context + * @return array + */ + public function getPreloadLinks( Context $context ): array { + if ( !in_array( 'logo', $this->features ) ) { + return []; + } + + $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() ); + + if ( !is_array( $logo ) ) { + // No media queries required if we only have one variant + return [ $logo => [ 'as' => 'image' ] ]; + } + + if ( isset( $logo['svg'] ) ) { + // No media queries required if we only have a 1x and svg variant + // because all preload-capable browsers support SVGs + return [ $logo['svg'] => [ 'as' => 'image' ] ]; + } + + $logosPerDppx = []; + foreach ( $logo as $dppx => $src ) { + // Keys are in this format: "1.5x" + $dppx = substr( $dppx, 0, -1 ); + $logosPerDppx[$dppx] = $src; + } + + // Because PHP can't have floats as array keys + uksort( $logosPerDppx, static function ( $a, $b ) { + $a = floatval( $a ); + $b = floatval( $b ); + // Sort from smallest to largest (e.g. 1x, 1.5x, 2x) + return $a <=> $b; + } ); + + $logos = []; + foreach ( $logosPerDppx as $dppx => $src ) { + $logos[] = [ + 'dppx' => $dppx, + 'src' => $src + ]; + } + + $logosCount = count( $logos ); + $preloadLinks = []; + // Logic must match SkinModule: + // - 1x applies to resolution < 1.5dppx + // - 1.5x applies to resolution >= 1.5dppx && < 2dppx + // - 2x applies to resolution >= 2dppx + // Note that min-resolution and max-resolution are both inclusive. + for ( $i = 0; $i < $logosCount; $i++ ) { + if ( $i === 0 ) { + // Smallest dppx + // min-resolution is ">=" (larger than or equal to) + // "not min-resolution" is essentially "<" + $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)'; + } elseif ( $i !== $logosCount - 1 ) { + // In between + // Media query expressions can only apply "not" to the entire expression + // (e.g. can't express ">= 1.5 and not >= 2). + // Workaround: Use <= 1.9999 in place of < 2. + $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001; + $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . + 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)'; + } else { + // Largest dppx + $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)'; + } + + $preloadLinks[$logos[$i]['src']] = [ + 'as' => 'image', + 'media' => $media_query + ]; + } + + return $preloadLinks; + } + + /** + * Ensure all media keys use array values. + * + * Normalises arrays returned by the FileModule::getStyles() method. + * + * @param array &$styles Associative array, keys are strings (media queries), + * values are strings or arrays + */ + private function normalizeStyles( array &$styles ): void { + foreach ( $styles as $key => $val ) { + if ( !is_array( $val ) ) { + $styles[$key] = [ $val ]; + } + } + } + + /** + * Modifies configured logo width/height to ensure they are present and scalable + * with different font-sizes. + * @param array $logoElement with width, height and src keys. + * @return array modified version of $logoElement + */ + private static function getRelativeSizedLogo( array $logoElement ) { + $width = $logoElement['width']; + $height = $logoElement['height']; + $widthRelative = $width / 16; + $heightRelative = $height / 16; + // Allow skins to scale the wordmark with browser font size (T207789) + $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;'; + return $logoElement; + } + + /** + * Return an array of all available logos that a skin may use. + * @since 1.35 + * @param Config $conf + * @param string|null $lang Language code for logo variant, since 1.39 + * @return array with the following keys: + * - 1x(string): a square logo composing the `icon` and `wordmark` (required) + * - 2x (string): a square logo for HD displays (optional) + * - wordmark (object): a rectangle logo (wordmark) for print media and skins which desire + * horizontal logo (optional). Must declare width and height fields, defined in pixels + * which will be converted to ems based on 16px font-size. + * - tagline (object): replaces `tagline` message in certain skins. Must declare width and + * height fields defined in pixels, which are converted to ems based on 16px font-size. + * - icon (string): a square logo similar to 1x, but without the wordmark. SVG recommended. + */ + public static function getAvailableLogos( Config $conf, string $lang = null ): array { + $logos = $conf->get( MainConfigNames::Logos ); + if ( $logos === false ) { + // no logos were defined... this will either + // 1. Load from wgLogo and wgLogoHD + // 2. Trigger runtime exception if those are not defined. + $logos = []; + } + if ( $lang && isset( $logos['variants'][$lang] ) ) { + foreach ( $logos['variants'][$lang] as $type => $value ) { + $logos[$type] = $value; + } + } + + // If logos['1x'] is not defined, see if we can use wgLogo + if ( !isset( $logos[ '1x' ] ) ) { + $logo = $conf->get( MainConfigNames::Logo ); + if ( $logo ) { + $logos['1x'] = $logo; + } + } + + try { + $logoHD = $conf->get( MainConfigNames::LogoHD ); + // make sure not false + if ( $logoHD ) { + // wfDeprecated( __METHOD__ . ' with $wgLogoHD set instead of $wgLogos', '1.35', false, 1 ); + $logos += $logoHD; + } + } catch ( ConfigException $e ) { + // no backwards compatibility changes needed. + } + + // @todo: Note the beta cluster and other wikis may be using + // unsupported configuration where these values are set to false. + // The boolean check can be removed when this has been addressed. + if ( isset( $logos['wordmark'] ) && $logos['wordmark'] ) { + // Allow skins to scale the wordmark with browser font size (T207789) + $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] ); + } + + // @todo: Note the beta cluster and other wikis may be using + // unsupported configuration where these values are set to false. + // The boolean check can be removed when this has been addressed. + if ( isset( $logos['tagline'] ) && $logos['tagline'] ) { + $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] ); + } + // return the modified logos! + return $logos; + } + + /** + * @since 1.31 + * @param Config $conf + * @param string|null $lang Language code for logo variant, since 1.39 + * @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. + */ + protected function getLogoData( Config $conf, string $lang = null ) { + $logoHD = self::getAvailableLogos( $conf, $lang ); + $logo = $logoHD['1x']; + + $logo1Url = OutputPage::transformResourcePath( $conf, $logo ); + + $logoUrls = [ + '1x' => $logo1Url, + ]; + + if ( isset( $logoHD['svg'] ) ) { + $logoUrls['svg'] = OutputPage::transformResourcePath( + $conf, + $logoHD['svg'] + ); + } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) { + // 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'] + ); + } + } else { + // Return a string rather than a one-element array, getLogoPreloadlinks depends on this + return $logo1Url; + } + + return $logoUrls; + } + + /** + * @param Context $context + * @return bool + */ + public function isKnownEmpty( Context $context ) { + // Regardless of whether the files are specified, we always + // provide mw-wiki-logo styles. + return false; + } + + /** + * Get language-specific LESS variables for this module. + * + * @param Context $context + * @return array + */ + protected function getLessVars( Context $context ) { + $lessVars = parent::getLessVars( $context ); + $logos = self::getAvailableLogos( $this->getConfig() ); + + if ( isset( $logos['wordmark'] ) ) { + $logo = $logos['wordmark']; + $lessVars[ 'logo-enabled' ] = true; + $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] ); + $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] ); + $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] ); + } else { + $lessVars[ 'logo-enabled' ] = false; + } + return $lessVars; + } + + public function getDefinitionSummary( Context $context ) { + $summary = parent::getDefinitionSummary( $context ); + $summary[] = [ + 'logos' => self::getAvailableLogos( $this->getConfig() ), + ]; + return $summary; + } +} + +/** @deprecated since 1.39 */ +class_alias( SkinModule::class, 'ResourceLoaderSkinModule' ); diff --git a/includes/ResourceLoader/StartUpModule.php b/includes/ResourceLoader/StartUpModule.php new file mode 100644 index 000000000000..6949749045ca --- /dev/null +++ b/includes/ResourceLoader/StartUpModule.php @@ -0,0 +1,453 @@ +<?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 + */ +namespace MediaWiki\ResourceLoader; + +use Exception; +use MediaWiki\MainConfigNames; +use Wikimedia\RequestTimeout\TimeoutException; + +/** + * Module for ResourceLoader initialization. + * + * See also <https://www.mediawiki.org/wiki/ResourceLoader/Features#Startup_Module> + * + * The startup module, as being called only from ClientHtml, has + * the ability to vary based extra query parameters, in addition to those + * from Context: + * + * - target: Only register modules in the client intended for this target. + * Default: "desktop". + * See also: OutputPage::setTarget(), Module::getTargets(). + * + * - safemode: Only register modules that have ORIGIN_CORE as their origin. + * This disables ORIGIN_USER modules and mw.loader.store. (T185303, T145498) + * See also: OutputPage::disallowUserJs() + * + * @ingroup ResourceLoader + * @internal + */ +class StartUpModule extends Module { + protected $targets = [ 'desktop', 'mobile' ]; + + private $groupIds = [ + // These reserved numbers MUST start at 0 and not skip any. These are preset + // for forward compatibility so that they can be safely referenced by mediawiki.js, + // even when the code is cached and the order of registrations (and implicit + // group ids) changes between versions of the software. + self::GROUP_USER => 0, + self::GROUP_PRIVATE => 1, + ]; + + /** + * Recursively get all explicit and implicit dependencies for to the given module. + * + * @param array $registryData + * @param string $moduleName + * @param string[] $handled Internal parameter for recursion. (Optional) + * @return array + * @throws CircularDependencyError + */ + protected static function getImplicitDependencies( + array $registryData, + string $moduleName, + array $handled = [] + ): array { + static $dependencyCache = []; + + // No modules will be added or changed server-side after this point, + // so we can safely cache parts of the tree for re-use. + if ( !isset( $dependencyCache[$moduleName] ) ) { + if ( !isset( $registryData[$moduleName] ) ) { + // Unknown module names are allowed here, this is only an optimisation. + // Checks for illegal and unknown dependencies happen as PHPUnit structure tests, + // and also client-side at run-time. + $flat = []; + } else { + $data = $registryData[$moduleName]; + $flat = $data['dependencies']; + + // Prevent recursion + $handled[] = $moduleName; + foreach ( $data['dependencies'] as $dependency ) { + if ( in_array( $dependency, $handled, true ) ) { + // If we encounter a circular dependency, then stop the optimiser and leave the + // original dependencies array unmodified. Circular dependencies are not + // supported in ResourceLoader. Awareness of them exists here so that we can + // optimise the registry when it isn't broken, and otherwise transport the + // registry unchanged. The client will handle this further. + throw new CircularDependencyError(); + } else { + // Recursively add the dependencies of the dependencies + $flat = array_merge( + $flat, + self::getImplicitDependencies( $registryData, $dependency, $handled ) + ); + } + } + } + + $dependencyCache[$moduleName] = $flat; + } + + 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' + * @phan-param array<string,array{version:string,dependencies:array,group:?string,source:string}> &$registryData + */ + public static function compileUnresolvedDependencies( array &$registryData ): void { + foreach ( $registryData as $name => &$data ) { + $dependencies = $data['dependencies']; + try { + foreach ( $data['dependencies'] as $dependency ) { + $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency ); + $dependencies = array_diff( $dependencies, $implicitDependencies ); + } + } catch ( CircularDependencyError $err ) { + // Leave unchanged + $dependencies = $data['dependencies']; + } + + // Rebuild keys + $data['dependencies'] = array_values( $dependencies ); + } + } + + /** + * Get registration code for all modules. + * + * @param Context $context + * @return string JavaScript code for registering all modules with the client loader + */ + public function getModuleRegistrations( Context $context ): string { + $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' ); + $safemode = $context->getRequest()->getRawVal( 'safemode' ) === '1'; + $skin = $context->getSkin(); + // 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( MainConfigNames::EnableJavaScriptTest ) && $target === 'test'; + + $out = ''; + $states = []; + $registryData = []; + $moduleNames = $resourceLoader->getModuleNames(); + + // Preload with a batch so that the below calls to getVersionHash() for each module + // don't require on-demand loading of more information. + try { + $resourceLoader->preloadModuleInfo( $moduleNames, $context ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + // Don't fail the request (T152266) + // Also print the error in the main output + $resourceLoader->outputErrorAndLog( $e, + 'Preloading module info from startup failed: {exception}', + [ 'exception' => $e ] + ); + } + + // Get registry data + foreach ( $moduleNames as $name ) { + $module = $resourceLoader->getModule( $name ); + $moduleTargets = $module->getTargets(); + $moduleSkins = $module->getSkins(); + if ( + ( !$byPassTargetFilter && !in_array( $target, $moduleTargets ) ) + || ( $safemode && $module->getOrigin() > Module::ORIGIN_CORE_INDIVIDUAL ) + || ( $moduleSkins !== null && !in_array( $skin, $moduleSkins ) ) + ) { + continue; + } + + if ( $module instanceof StartUpModule ) { + // Don't register 'startup' to the client because loading it lazily or depending + // on it doesn't make sense, because the startup module *is* the client. + // Registering would be a waste of bandwidth and memory and risks somehow causing + // it to load a second time. + + // ATTENTION: Because of the line below, this is not going to cause infinite recursion. + // Think carefully before making changes to this code! + // The below code is going to call Module::getVersionHash() for every module. + // For StartUpModule (this module) the hash is computed based on the manifest content, + // which is the very thing we are computing right here. As such, this must skip iterating + // over 'startup' itself. + continue; + } + + // Optimization: Exclude modules in the `noscript` group. These are only ever used + // directly by HTML without use of JavaScript (T291735). + if ( $module->getGroup() === self::GROUP_NOSCRIPT ) { + continue; + } + + try { + // The version should be formatted by ResourceLoader::makeHash and be of + // length ResourceLoader::HASH_LENGTH (or empty string). + // The getVersionHash method is final and is covered by tests, as is makeHash(). + $versionHash = $module->getVersionHash( $context ); + } catch ( TimeoutException $e ) { + throw $e; + } catch ( Exception $e ) { + // Don't fail the request (T152266) + // Also print the error in the main output + $resourceLoader->outputErrorAndLog( $e, + 'Calculating version for "{module}" failed: {exception}', + [ + 'module' => $name, + 'exception' => $e, + ] + ); + $versionHash = ''; + $states[$name] = 'error'; + } + + $skipFunction = $module->getSkipFunction(); + if ( $skipFunction !== null && !$context->getDebug() ) { + $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction ); + } + + $registryData[$name] = [ + 'version' => $versionHash, + 'dependencies' => $module->getDependencies( $context ), + 'es6' => $module->requiresES6(), + 'group' => $this->getGroupId( $module->getGroup() ), + 'source' => $module->getSource(), + 'skip' => $skipFunction, + ]; + } + + self::compileUnresolvedDependencies( $registryData ); + + // Register sources + $out .= ResourceLoader::makeLoaderSourcesScript( $context, $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, + // HACK: signify ES6 with a ! added at the end of the version + // This avoids having to add another register() parameter, and generating + // a bunch of nulls for ES6-only modules + $data['version'] . ( $data['es6'] ? '!' : '' ), + $data['dependencies'], + $data['group'], + // Swap default (local) for null + $data['source'] === 'local' ? null : $data['source'], + $data['skip'] + ]; + } + + // Register modules + $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $context, $registrations ); + + if ( $states ) { + $out .= "\n" . ResourceLoader::makeLoaderStateScript( $context, $states ); + } + + return $out; + } + + private function getGroupId( $groupName ): ?int { + if ( $groupName === null ) { + return null; + } + + if ( !array_key_exists( $groupName, $this->groupIds ) ) { + $this->groupIds[$groupName] = count( $this->groupIds ); + } + + return $this->groupIds[$groupName]; + } + + /** + * Base modules implicitly available to all modules. + * + * @return array + */ + private function getBaseModules(): array { + return [ 'jquery', 'mediawiki.base' ]; + } + + /** + * Get the localStorage key for the entire module store. The key references + * $wgDBname to prevent clashes between wikis under the same web domain. + * + * @return string localStorage item key for JavaScript + */ + private function getStoreKey(): string { + return 'MediaWikiModuleStore:' . $this->getConfig()->get( MainConfigNames::DBname ); + } + + /** + * @see $wgResourceLoaderMaxQueryLength + * @return int + */ + private function getMaxQueryLength(): int { + $len = $this->getConfig()->get( MainConfigNames::ResourceLoaderMaxQueryLength ); + // - Ignore -1, which in MW 1.34 and earlier was used to mean "unlimited". + // - Ignore invalid values, e.g. non-int or other negative values. + if ( $len === false || $len < 0 ) { + // Default + $len = 2000; + } + return $len; + } + + /** + * Get the key on which the JavaScript module cache (mw.loader.store) will vary. + * + * @param Context $context + * @return string String of concatenated vary conditions + */ + private function getStoreVary( Context $context ): string { + return implode( ':', [ + $context->getSkin(), + $this->getConfig()->get( MainConfigNames::ResourceLoaderStorageVersion ), + $context->getLanguage(), + ] ); + } + + /** + * @param Context $context + * @return string JavaScript code + */ + public function getScript( Context $context ): string { + global $IP; + $conf = $this->getConfig(); + + if ( $context->getOnly() !== 'scripts' ) { + return '/* Requires only=scripts */'; + } + + $startupCode = file_get_contents( "$IP/resources/src/startup/startup.js" ); + + // The files read here MUST be kept in sync with maintenance/jsduck/eg-iframe.html. + $mwLoaderCode = file_get_contents( "$IP/resources/src/startup/mediawiki.js" ) . + file_get_contents( "$IP/resources/src/startup/mediawiki.loader.js" ) . + file_get_contents( "$IP/resources/src/startup/mediawiki.requestIdleCallback.js" ); + if ( $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ) ) { + $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" ); + } + + // Perform replacements for mediawiki.js + $mwLoaderPairs = [ + // This should always be an object, even if the base vars are empty + // (such as when using the default lang/skin). + '$VARS.reqBase' => $context->encodeJson( (object)$context->getReqBase() ), + '$VARS.baseModules' => $context->encodeJson( $this->getBaseModules() ), + '$VARS.maxQueryLength' => $context->encodeJson( + // In debug mode (except legacy debug mode), let the client fetch each module in + // its own dedicated request (T85805). + // This is effectively the equivalent of ClientHtml::makeLoad, + // which does this for stylesheets. + ( !$context->getDebug() || $context->getDebug() === $context::DEBUG_LEGACY ) ? + $this->getMaxQueryLength() : + 0 + ), + '$VARS.storeEnabled' => $context->encodeJson( + $conf->get( MainConfigNames::ResourceLoaderStorageEnabled ) + && !$context->getDebug() + && $context->getRequest()->getRawVal( 'safemode' ) !== '1' + ), + '$VARS.storeKey' => $context->encodeJson( $this->getStoreKey() ), + '$VARS.storeVary' => $context->encodeJson( $this->getStoreVary( $context ) ), + '$VARS.groupUser' => $context->encodeJson( $this->getGroupId( self::GROUP_USER ) ), + '$VARS.groupPrivate' => $context->encodeJson( $this->getGroupId( self::GROUP_PRIVATE ) ), + // Only expose private mw.loader.isES6ForTest in test mode. + '$CODE.test( isES6Supported )' => $conf->get( MainConfigNames::EnableJavaScriptTest ) ? + '(mw.loader.isES6ForTest !== undefined ? mw.loader.isES6ForTest : isES6Supported)' : + 'isES6Supported', + // Only expose private mw.redefineFallbacksForTest in test mode. + '$CODE.maybeRedefineFallbacksForTest();' => $conf->get( MainConfigNames::EnableJavaScriptTest ) ? + 'mw.redefineFallbacksForTest = defineFallbacks;' : + '', + ]; + $profilerStubs = [ + '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );', + '$CODE.profileExecuteEnd();' => 'mw.loader.profiler.onExecuteEnd( module );', + '$CODE.profileScriptStart();' => 'mw.loader.profiler.onScriptStart( module );', + '$CODE.profileScriptEnd();' => 'mw.loader.profiler.onScriptEnd( module );', + ]; + $debugStubs = [ + '$CODE.consoleLog();' => 'console.log.apply( console, arguments );', + ]; + // When profiling is enabled, insert the calls. When disabled (by default), insert nothing. + $mwLoaderPairs += $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ) + ? $profilerStubs + : array_fill_keys( array_keys( $profilerStubs ), '' ); + $mwLoaderPairs += $context->getDebug() + ? $debugStubs + : array_fill_keys( array_keys( $debugStubs ), '' ); + $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs ); + + // Perform string replacements for startup.js + $pairs = [ + // Raw JavaScript code (not JSON) + '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ), + '$CODE.defineLoader();' => $mwLoaderCode, + ]; + $startupCode = strtr( $startupCode, $pairs ); + + return $startupCode; + } + + /** + * @return bool + */ + public function supportsURLLoading(): bool { + return false; + } + + /** + * @return bool + */ + public function enableModuleContentVersion(): bool { + // Enabling this means that ResourceLoader::getVersionHash will simply call getScript() + // and hash it to determine the version (as used by E-Tag HTTP response header). + return true; + } +} + +/** @deprecated since 1.39 */ +class_alias( StartUpModule::class, 'ResourceLoaderStartUpModule' ); diff --git a/includes/ResourceLoader/UserModule.php b/includes/ResourceLoader/UserModule.php new file mode 100644 index 000000000000..2fd0f3a8644a --- /dev/null +++ b/includes/ResourceLoader/UserModule.php @@ -0,0 +1,91 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use TitleValue; + +/** + * Module for user customizations scripts. + * + * @ingroup ResourceLoader + * @internal + */ +class UserModule extends WikiModule { + protected $origin = self::ORIGIN_USER_INDIVIDUAL; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param Context $context + * @return array[] + */ + protected function getPages( Context $context ) { + $user = $context->getUserIdentity(); + if ( !$user || !$user->isRegistered() ) { + return []; + } + + $config = $this->getConfig(); + $pages = []; + + if ( $config->get( MainConfigNames::AllowUserJs ) ) { + $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); + // Use localised/normalised variant to ensure $excludepage matches + $userPage = $titleFormatter->getPrefixedDBkey( new TitleValue( NS_USER, $user->getName() ) ); + $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( MainConfigNames::UseSiteJs ) ) { + $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager(); + foreach ( $userGroupManager->getUserEffectiveGroups( $user ) as $group ) { + if ( $group == '*' ) { + continue; + } + $pages["MediaWiki:Group-$group.js"] = [ 'type' => 'script' ]; + } + } + + // This is obsolete since 1.32 (T112474). It was formerly used by + // OutputPage to implement previewing of user CSS and JS. + // @todo: Remove it once we're sure nothing else is using the parameter + $excludepage = $context->getRequest()->getVal( 'excludepage' ); + unset( $pages[$excludepage] ); + + return $pages; + } + + /** + * Get group name + * + * @return string + */ + public function getGroup() { + return self::GROUP_USER; + } +} + +/** @deprecated since 1.39 */ +class_alias( UserModule::class, 'ResourceLoaderUserModule' ); diff --git a/includes/ResourceLoader/UserOptionsModule.php b/includes/ResourceLoader/UserOptionsModule.php new file mode 100644 index 000000000000..8b1e7a6f11ef --- /dev/null +++ b/includes/ResourceLoader/UserOptionsModule.php @@ -0,0 +1,96 @@ +<?php + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MediaWikiServices; +use MediaWiki\User\UserOptionsLookup; + +/** + * 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 per-user private data that is transmitted on all HTML web responses. + * + * This module is embedded by ClientHtml and sent to the browser + * by OutputPage as part of the HTML `<head>`. + * + * @ingroup ResourceLoader + * @internal + */ +class UserOptionsModule extends Module { + + protected $origin = self::ORIGIN_CORE_INDIVIDUAL; + + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param Context $context + * @return string JavaScript code + */ + public function getScript( Context $context ) { + $user = $context->getUserObj(); + + $tokens = [ + // Replacement is tricky - T287542 + 'patrolToken' => $user->getEditToken( 'patrol' ), + 'watchToken' => $user->getEditToken( 'watch' ), + 'csrfToken' => $user->getEditToken(), + ]; + $script = 'mw.user.tokens.set(' . $context->encodeJson( $tokens ) . ');' . "\n"; + + $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup(); + + // Optimisation: Exclude the defaults, which we load separately and allow the browser + // to cache across page views. The defaults are loaded before this code executes, + // as part of the "mediawiki.base" module. + $options = $userOptionsLookup->getOptions( $user, UserOptionsLookup::EXCLUDE_DEFAULTS ); + + $keysToExclude = []; + $this->getHookRunner()->onResourceLoaderExcludeUserOptions( $keysToExclude, $context ); + foreach ( $keysToExclude as $excludedKey ) { + unset( $options[ $excludedKey ] ); + } + + // Optimisation: Only output this function call if the user has non-default settings. + if ( $options ) { + $script .= 'mw.user.options.set(' . $context->encodeJson( $options ) . ');' . "\n"; + } + + return $script; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + return false; + } + + /** + * @return string + */ + public function getGroup() { + return self::GROUP_PRIVATE; + } +} + +/** @deprecated since 1.39 */ +class_alias( UserOptionsModule::class, 'ResourceLoaderUserOptionsModule' ); diff --git a/includes/ResourceLoader/UserStylesModule.php b/includes/ResourceLoader/UserStylesModule.php new file mode 100644 index 000000000000..9b9e8692eca0 --- /dev/null +++ b/includes/ResourceLoader/UserStylesModule.php @@ -0,0 +1,100 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use TitleValue; + +/** + * Module for user customizations styles. + * + * @ingroup ResourceLoader + * @internal + */ +class UserStylesModule extends WikiModule { + + protected $origin = self::ORIGIN_USER_INDIVIDUAL; + protected $targets = [ 'desktop', 'mobile' ]; + + /** + * @param Context $context + * @return array[] + */ + protected function getPages( Context $context ) { + $user = $context->getUserIdentity(); + if ( !$user || !$user->isRegistered() ) { + return []; + } + + $config = $this->getConfig(); + $pages = []; + + if ( $config->get( MainConfigNames::AllowUserCss ) ) { + $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter(); + // Use localised/normalised variant to ensure $excludepage matches + $userPage = $titleFormatter->getPrefixedDBkey( new TitleValue( NS_USER, $user->getName() ) ); + $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( MainConfigNames::UseSiteCss ) ) { + $effectiveGroups = MediaWikiServices::getInstance()->getUserGroupManager() + ->getUserEffectiveGroups( $user ); + foreach ( $effectiveGroups as $group ) { + if ( $group == '*' ) { + continue; + } + $pages["MediaWiki:Group-$group.css"] = [ 'type' => 'style' ]; + } + } + + // This is obsolete since 1.32 (T112474). It was formerly used by + // OutputPage to implement previewing of user CSS and JS. + // @todo: Remove it once we're sure nothing else is using the parameter + $excludepage = $context->getRequest()->getVal( 'excludepage' ); + unset( $pages[$excludepage] ); + + return $pages; + } + + /** + * @return string + */ + public function getType() { + return self::LOAD_STYLES; + } + + /** + * Get group name + * + * @return string + */ + public function getGroup() { + return self::GROUP_USER; + } +} + +/** @deprecated since 1.39 */ +class_alias( UserStylesModule::class, 'ResourceLoaderUserStylesModule' ); diff --git a/includes/ResourceLoader/VueComponentParser.php b/includes/ResourceLoader/VueComponentParser.php new file mode 100644 index 000000000000..12a6a1a11958 --- /dev/null +++ b/includes/ResourceLoader/VueComponentParser.php @@ -0,0 +1,321 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @author Roan Kattouw + */ + +namespace MediaWiki\ResourceLoader; + +use DOMDocument; +use DOMElement; +use DOMNode; +use Exception; +use Wikimedia\RemexHtml\DOM\DOMBuilder; +use Wikimedia\RemexHtml\HTMLData; +use Wikimedia\RemexHtml\Serializer\HtmlFormatter; +use Wikimedia\RemexHtml\Serializer\Serializer; +use Wikimedia\RemexHtml\Serializer\SerializerNode; +use Wikimedia\RemexHtml\Tokenizer\Attributes; +use Wikimedia\RemexHtml\Tokenizer\Tokenizer; +use Wikimedia\RemexHtml\TreeBuilder\Dispatcher; +use Wikimedia\RemexHtml\TreeBuilder\Element; +use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder; + +/** + * Parser for Vue single file components (.vue files). See parse() for usage. + * + * @ingroup ResourceLoader + * @internal For use within FileModule. + */ +class VueComponentParser { + /** + * Parse a Vue single file component, and extract the script, template and style parts. + * + * Returns an associative array with the following keys: + * - 'script': The JS code in the <script> tag + * - 'template': The HTML in the <template> tag + * - 'style': The CSS/LESS styles in the <style> tag, or null if the <style> tag was missing + * - 'styleLang': The language used for 'style'; either 'css' or 'less', or null if no <style> tag + * + * The following options can be passed in the $options parameter: + * - 'minifyTemplate': Whether to minify the HTML in the template tag. This removes + * HTML comments and strips whitespace. Default: false + * + * @param string $html HTML with <script>, <template> and <style> tags at the top level + * @param array $options Associative array of options + * @return array + * @throws Exception If the input is invalid + */ + public function parse( string $html, array $options = [] ): array { + $dom = $this->parseHTML( $html ); + // Remex wraps everything in <html><head>, unwrap that + $head = $dom->getElementsByTagName( 'head' )->item( 0 ); + + // Find the <script>, <template> and <style> tags. They can appear in any order, but they + // must be at the top level, and there can only be one of each. + if ( !$head ) { + throw new Exception( 'Parsed DOM did not contain a <head> tag' ); + } + $nodes = $this->findUniqueTags( $head, [ 'script', 'template', 'style' ] ); + + // Throw an error if we didn't find a <script> or <template> tag. <style> is optional. + foreach ( [ 'script', 'template' ] as $requiredTag ) { + if ( !isset( $nodes[ $requiredTag ] ) ) { + throw new Exception( "No <$requiredTag> tag found" ); + } + } + + $this->validateAttributes( $nodes['script'], [] ); + $this->validateAttributes( $nodes['template'], [] ); + if ( isset( $nodes['style'] ) ) { + $this->validateAttributes( $nodes['style'], [ 'lang' ] ); + } + + $styleData = isset( $nodes['style'] ) ? $this->getStyleAndLang( $nodes['style'] ) : null; + $template = $this->getTemplateHtml( $html, $options['minifyTemplate'] ?? false ); + + return [ + 'script' => trim( $nodes['script']->nodeValue ), + 'template' => $template, + 'style' => $styleData ? $styleData['style'] : null, + 'styleLang' => $styleData ? $styleData['lang'] : null + ]; + } + + /** + * Parse HTML to DOM using RemexHtml + * @param string $html + * @return DOMDocument + */ + private function parseHTML( $html ): DOMDocument { + $domBuilder = new DOMBuilder( [ 'suppressHtmlNamespace' => true ] ); + $treeBuilder = new TreeBuilder( $domBuilder, [ 'ignoreErrors' => true ] ); + $tokenizer = new Tokenizer( new Dispatcher( $treeBuilder ), $html, [ 'ignoreErrors' => true ] ); + $tokenizer->execute(); + // @phan-suppress-next-line PhanTypeMismatchReturnSuperType + return $domBuilder->getFragment(); + } + + /** + * Find occurrences of specified tags in a DOM node, expecting at most one occurrence of each. + * This method only looks at the top-level children of $rootNode, it doesn't descend into them. + * + * @param DOMNode $rootNode Node whose children to look at + * @param string[] $tagNames Tag names to look for (must be all lowercase) + * @return DOMElement[] Associative arrays whose keys are tag names and values are DOM nodes + */ + private function findUniqueTags( DOMNode $rootNode, array $tagNames ): array { + $nodes = []; + foreach ( $rootNode->childNodes as $node ) { + $tagName = strtolower( $node->nodeName ); + if ( in_array( $tagName, $tagNames ) ) { + if ( isset( $nodes[ $tagName ] ) ) { + throw new Exception( "More than one <$tagName> tag found" ); + } + $nodes[ $tagName ] = $node; + } + } + return $nodes; + } + + /** + * Verify that a given node only has a given set of attributes, and no others. + * @param DOMNode $node Node to check + * @param array $allowedAttributes Attributes the node is allowed to have + * @throws Exception If the node has an attribute it's not allowed to have + */ + private function validateAttributes( DOMNode $node, array $allowedAttributes ): void { + if ( $allowedAttributes ) { + foreach ( $node->attributes as $attr ) { + if ( !in_array( $attr->name, $allowedAttributes ) ) { + throw new Exception( "<{$node->nodeName}> may not have the " . + "{$attr->name} attribute" ); + } + } + } elseif ( $node->attributes->length > 0 ) { + throw new Exception( "<{$node->nodeName}> may not have any attributes" ); + } + } + + /** + * Get the contents and language of the <style> tag. The language can be 'css' or 'less'. + * @param DOMElement $styleNode The <style> tag. + * @return array [ 'style' => string, 'lang' => string ] + * @throws Exception If an invalid language is used, or if the 'scoped' attribute is set. + */ + private function getStyleAndLang( DOMElement $styleNode ): array { + $style = trim( $styleNode->nodeValue ); + $styleLang = $styleNode->hasAttribute( 'lang' ) ? + $styleNode->getAttribute( 'lang' ) : 'css'; + if ( $styleLang !== 'css' && $styleLang !== 'less' ) { + throw new Exception( "<style lang=\"$styleLang\"> is invalid," . + " lang must be \"css\" or \"less\"" ); + } + return [ + 'style' => $style, + 'lang' => $styleLang, + ]; + } + + /** + * Get the HTML contents of the <template> tag, optionally minifed. + * + * To work around a bug in PHP's DOMDocument where attributes like @click get mangled, + * we re-parse the entire file using a Remex parse+serialize pipeline, with a custom dispatcher + * to zoom in on just the contents of the <template> tag, and a custom formatter for minification. + * Keeping everything in Remex and never converting it to DOM avoids the attribute mangling issue. + * + * @param string $html HTML that contains a <template> tag somewhere + * @param bool $minify Whether to minify the output (remove comments, strip whitespace) + * @return string HTML contents of the template tag + */ + private function getTemplateHtml( $html, $minify ) { + $serializer = new Serializer( $this->newTemplateFormatter( $minify ) ); + $tokenizer = new Tokenizer( + $this->newFilteringDispatcher( + new TreeBuilder( $serializer, [ 'ignoreErrors' => true ] ), + 'template' + ), + $html, [ 'ignoreErrors' => true ] + ); + $tokenizer->execute( [ 'fragmentNamespace' => HTMLData::NS_HTML, 'fragmentName' => 'template' ] ); + return trim( $serializer->getResult() ); + } + + /** + * Custom HtmlFormatter subclass that optionally removes comments and strips whitespace. + * If $minify=false, this formatter falls through to HtmlFormatter for everything (except that + * it strips the <!doctype html> tag). + * + * @param bool $minify If true, remove comments and strip whitespace + * @return HtmlFormatter + */ + private function newTemplateFormatter( $minify ) { + return new class( $minify ) extends HtmlFormatter { + private $minify; + + public function __construct( $minify ) { + $this->minify = $minify; + } + + public function startDocument( $fragmentNamespace, $fragmentName ) { + // Remove <!doctype html> + return ''; + } + + public function comment( SerializerNode $parent, $text ) { + if ( $this->minify ) { + // Remove all comments + return ''; + } + return parent::comment( $parent, $text ); + } + + public function characters( SerializerNode $parent, $text, $start, $length ) { + if ( + $this->minify && ( + // Don't touch <pre>/<listing>/<textarea> nodes + $parent->namespace !== HTMLData::NS_HTML || + !isset( $this->prefixLfElements[ $parent->name ] ) + ) + ) { + $text = substr( $text, $start, $length ); + // Collapse runs of adjacent whitespace, and convert all whitespace to spaces + $text = preg_replace( '/[ \r\n\t]+/', ' ', $text ); + $start = 0; + $length = strlen( $text ); + } + return parent::characters( $parent, $text, $start, $length ); + } + + public function element( SerializerNode $parent, SerializerNode $node, $contents ) { + if ( + $this->minify && ( + // Don't touch <pre>/<listing>/<textarea> nodes + $node->namespace !== HTMLData::NS_HTML || + !isset( $this->prefixLfElements[ $node->name ] ) + ) + ) { + // Remove leading and trailing whitespace + $contents = preg_replace( '/(^[ \r\n\t]+)|([\r\n\t ]+$)/', '', $contents ); + } + return parent::element( $parent, $node, $contents ); + } + }; + } + + /** + * Custom Dispatcher subclass that only dispatches tree events inside a tag with a certain name. + * This effectively filters the tree to only the contents of that tag. + * + * @param TreeBuilder $treeBuilder + * @param string $nodeName Tag name to filter for + * @return Dispatcher + */ + private function newFilteringDispatcher( TreeBuilder $treeBuilder, $nodeName ) { + return new class( $treeBuilder, $nodeName ) extends Dispatcher { + private $nodeName; + private $nodeDepth = 0; + private $seenTag = false; + + public function __construct( TreeBuilder $treeBuilder, $nodeName ) { + $this->nodeName = $nodeName; + parent::__construct( $treeBuilder ); + } + + public function startTag( $name, Attributes $attrs, $selfClose, $sourceStart, $sourceLength ) { + if ( $this->nodeDepth ) { + parent::startTag( $name, $attrs, $selfClose, $sourceStart, $sourceLength ); + } + + if ( $name === $this->nodeName ) { + if ( $this->nodeDepth === 0 && $this->seenTag ) { + // This is the second opening tag, not nested in the first one + throw new Exception( "More than one <{$this->nodeName}> tag found" ); + } + $this->nodeDepth++; + $this->seenTag = true; + } + } + + public function endTag( $name, $sourceStart, $sourceLength ) { + if ( $name === $this->nodeName ) { + $this->nodeDepth--; + } + if ( $this->nodeDepth ) { + parent::endTag( $name, $sourceStart, $sourceLength ); + } + } + + public function characters( $text, $start, $length, $sourceStart, $sourceLength ) { + if ( $this->nodeDepth ) { + parent::characters( $text, $start, $length, $sourceStart, $sourceLength ); + } + } + + public function comment( $text, $sourceStart, $sourceLength ) { + if ( $this->nodeDepth ) { + parent::comment( $text, $sourceStart, $sourceLength ); + } + } + }; + } +} + +/** @deprecated since 1.39 */ +class_alias( VueComponentParser::class, 'VueComponentParser' ); diff --git a/includes/ResourceLoader/WikiModule.php b/includes/ResourceLoader/WikiModule.php new file mode 100644 index 000000000000..172b24801ddb --- /dev/null +++ b/includes/ResourceLoader/WikiModule.php @@ -0,0 +1,723 @@ +<?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 + */ + +namespace MediaWiki\ResourceLoader; + +use Content; +use CSSJanus; +use FormatJson; +use MediaWiki\Linker\LinkTarget; +use MediaWiki\MainConfigNames; +use MediaWiki\MediaWikiServices; +use MediaWiki\Page\PageIdentity; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; +use MemoizedCallable; +use Title; +use TitleValue; +use Wikimedia\Minify\CSSMin; +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Timestamp\ConvertibleTimestamp; + +/** + * 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() + * + * @ingroup ResourceLoader + * @since 1.17 + */ +class WikiModule extends Module { + /** @var string Origin defaults to users with sitewide authority */ + protected $origin = self::ORIGIN_USER_SITEWIDE; + + /** + * In-process cache for title info, structured as an array + * [ + * <batchKey> // Pipe-separated list of sorted keys from getPages + * => [ + * <titleKey> => [ // Normalised title key + * 'page_len' => .., + * 'page_latest' => .., + * 'page_touched' => .., + * ] + * ] + * ] + * @see self::fetchTitleInfo() + * @see self::makeTitleKey() + * @var array + */ + protected $titleInfo = []; + + /** @var array List of page names that contain CSS */ + protected $styles = []; + + /** @var array List of page names that contain JavaScript */ + protected $scripts = []; + + /** @var array List of page names that contain JSON */ + protected $datas = []; + + /** @var string|null Group of module */ + protected $group; + + /** + * @param array|null $options For back-compat, this can be omitted in favour of overwriting + * getPages. + */ + public function __construct( array $options = null ) { + if ( $options === null ) { + return; + } + + foreach ( $options as $member => $option ) { + switch ( $member ) { + case 'styles': + case 'scripts': + case 'datas': + 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 JSON files, 'type' should be 'data'. + * 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 Context $context + * @return array[] + * @phan-return array<string,array{type:string,media?:string}> + */ + protected function getPages( Context $context ) { + $config = $this->getConfig(); + $pages = []; + + // Filter out pages from origins not allowed by the current wiki configuration. + if ( $config->get( MainConfigNames::UseSiteJs ) ) { + foreach ( $this->scripts as $script ) { + $pages[$script] = [ 'type' => 'script' ]; + } + foreach ( $this->datas as $data ) { + $pages[$data] = [ 'type' => 'data' ]; + } + } + + if ( $config->get( MainConfigNames::UseSiteCss ) ) { + foreach ( $this->styles as $style ) { + $pages[$style] = [ 'type' => 'style' ]; + } + } + + return $pages; + } + + /** + * Get group name + * + * @return string|null + */ + public function getGroup() { + return $this->group; + } + + /** + * Get the Database handle used for computing the module version. + * + * Subclasses may override this to return a foreign database, which would + * allow them to register a module on wiki A that fetches wiki pages from + * wiki B. + * + * The way this works is that the local module is a placeholder that can + * only computer a module version hash. The 'source' of the module must + * be set to the foreign wiki directly. Methods getScript() and getContent() + * will not use this handle and are not valid on the local wiki. + * + * @return IDatabase + */ + protected function getDB() { + return wfGetDB( DB_REPLICA ); + } + + /** + * @param string $titleText + * @param Context $context + * @return null|string + * @since 1.32 added the $context parameter + */ + protected function getContent( $titleText, Context $context ) { + $pageStore = MediaWikiServices::getInstance()->getPageStore(); + $title = $pageStore->getPageByText( $titleText ); + if ( !$title ) { + return null; // Bad title + } + + $content = $this->getContentObj( $title, $context ); + if ( !$content ) { + return null; // No content found + } + + $handler = $content->getContentHandler(); + if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) { + $format = CONTENT_FORMAT_CSS; + } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) { + $format = CONTENT_FORMAT_JAVASCRIPT; + } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JSON ) ) { + $format = CONTENT_FORMAT_JSON; + } else { + return null; // Bad content model + } + + return $content->serialize( $format ); + } + + /** + * @param PageIdentity $page + * @param Context $context + * @param int $maxRedirects Maximum number of redirects to follow. + * Either 0 or 1. + * @return Content|null + * @since 1.32 added the $context and $maxRedirects parameters + * @internal for testing + */ + protected function getContentObj( + PageIdentity $page, Context $context, $maxRedirects = 1 + ) { + $overrideCallback = $context->getContentOverrideCallback(); + $content = $overrideCallback ? call_user_func( $overrideCallback, $page ) : null; + if ( $content ) { + if ( !$content instanceof Content ) { + $this->getLogger()->error( + 'Bad content override for "{title}" in ' . __METHOD__, + [ 'title' => (string)$page ] + ); + return null; + } + } else { + $revision = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getKnownCurrentRevision( $page ); + if ( !$revision ) { + return null; + } + $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); + + if ( !$content ) { + $this->getLogger()->error( + 'Failed to load content of CSS/JS/JSON page "{title}" in ' . __METHOD__, + [ 'title' => (string)$page ] + ); + return null; + } + } + + if ( $maxRedirects > 0 ) { + $newTitle = $content->getRedirectTarget(); + if ( $newTitle ) { + return $this->getContentObj( $newTitle, $context, 0 ); + } + } + + return $content; + } + + /** + * @param Context $context + * @return bool + */ + public function shouldEmbedModule( Context $context ) { + $overrideCallback = $context->getContentOverrideCallback(); + if ( $overrideCallback && $this->getSource() === 'local' ) { + foreach ( $this->getPages( $context ) as $page => $info ) { + $title = Title::newFromText( $page ); + if ( $title && call_user_func( $overrideCallback, $title ) !== null ) { + return true; + } + } + } + + return parent::shouldEmbedModule( $context ); + } + + /** + * @param Context $context + * @return string|array JavaScript code, or a package files array + */ + public function getScript( Context $context ) { + if ( $this->isPackaged() ) { + return $this->getPackageFiles( $context ); + } else { + $scripts = ''; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'script' ) { + continue; + } + $script = $this->getContent( $titleText, $context ); + if ( strval( $script ) !== '' ) { + $script = $this->validateScriptFile( $titleText, $script ); + $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n"; + } + } + return $scripts; + } + } + + /** + * Get whether this module is a packaged module. + * + * If false (the default), JavaScript pages are concatenated and executed as a single + * script. JSON pages are not supported. + * + * If true, the pages are bundled such that each page gets a virtual file name, where only + * the "main" script will be executed at first, and other JS or JSON pages may be be imported + * in client-side code through the `require()` function. + * + * @stable to override + * @since 1.38 + * @return bool + */ + protected function isPackaged(): bool { + // Packaged mode is disabled by default for backwards compatibility. + // Subclasses may opt-in to this feature. + return false; + } + + /** + * @return bool + */ + public function supportsURLLoading() { + // If package files are involved, don't support URL loading + return !$this->isPackaged(); + } + + /** + * Convert a namespace-formatted page title to a virtual package file name. + * + * This determines how the page may be imported in client-side code via `require()`. + * + * @stable to override + * @since 1.38 + * @param string $titleText + * @return string + */ + protected function getRequireKey( string $titleText ): string { + return $titleText; + } + + /** + * @param Context $context + * @return array + * @phan-return array{main:string,files:string[][]} + */ + private function getPackageFiles( Context $context ): array { + $main = null; + + $files = []; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'script' && $options['type'] !== 'data' ) { + continue; + } + $content = $this->getContent( $titleText, $context ); + if ( strval( $content ) !== '' ) { + $fileKey = $this->getRequireKey( $titleText ); + if ( $options['type'] === 'script' ) { + $script = $this->validateScriptFile( $titleText, $content ); + $files[$fileKey] = [ + 'type' => 'script', + 'content' => $script, + ]; + // First script becomes the "main" script + if ( $main === null ) { + $main = $fileKey; + } + } elseif ( $options['type'] === 'data' ) { + $data = FormatJson::decode( $content ); + if ( $data == null ) { + // This is unlikely to happen since we only load JSON from + // wiki pages with a JSON content model, which are validated + // during edit save. + $data = [ 'error' => 'Invalid JSON' ]; + } + $files[$fileKey] = [ + 'type' => 'data', + 'content' => $data, + ]; + } + } + } + + return [ + 'main' => $main, + 'files' => $files, + ]; + } + + /** + * @param Context $context + * @return array + */ + public function getStyles( Context $context ) { + $styles = []; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'style' ) { + continue; + } + $media = $options['media'] ?? 'all'; + $style = $this->getContent( $titleText, $context ); + if ( strval( $style ) === '' ) { + continue; + } + if ( $this->getFlip( $context ) ) { + $style = CSSJanus::transform( $style, true, false ); + } + $remoteDir = $this->getConfig()->get( MainConfigNames::ScriptPath ); + if ( $remoteDir === '' ) { + // When the site is configured with the script path at the + // document root, MediaWiki uses an empty string but that is + // not a valid URI path. Expand to a slash to avoid fatals + // later in CSSMin::resolveUrl(). + // See also FilePath::extractBasePaths, T282280. + $remoteDir = '/'; + } + + $style = MemoizedCallable::call( + [ CSSMin::class, 'remap' ], + [ $style, false, $remoteDir, 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 Context $context + * @return array + */ + public function getDefinitionSummary( Context $context ) { + $summary = parent::getDefinitionSummary( $context ); + $summary[] = [ + 'pages' => $this->getPages( $context ), + // Includes meta data of current revisions + 'titleInfo' => $this->getTitleInfo( $context ), + ]; + return $summary; + } + + /** + * @param Context $context + * @return bool + */ + public function isKnownEmpty( Context $context ) { + // If a module has dependencies it cannot be empty. An empty array will be cast to false + if ( $this->getDependencies() ) { + return false; + } + + // Optimisation: For user modules, don't needlessly load if there are no non-empty pages + // This is worthwhile because unlike most modules, user modules require their own + // separate embedded request (managed by ResourceLoaderClientHtml). + $revisions = $this->getTitleInfo( $context ); + if ( $this->getGroup() === self::GROUP_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 non-user modules (i.e. ones that are called in cached HTML output) only check + // page existence. This ensures that, if some pages in a module are temporarily blanked, + // we don't stop embedding the module's script or link tag on newly cached pages. + return count( $revisions ) === 0; + } + + private function setTitleInfo( $batchKey, array $titleInfo ) { + $this->titleInfo[$batchKey] = $titleInfo; + } + + private static function makeTitleKey( LinkTarget $title ) { + // Used for keys in titleInfo. + return "{$title->getNamespace()}:{$title->getDBkey()}"; + } + + /** + * Get the information about the wiki pages for a given context. + * @param Context $context + * @return array[] Keyed by page name + */ + protected function getTitleInfo( Context $context ) { + $dbr = $this->getDB(); + + $pageNames = array_keys( $this->getPages( $context ) ); + sort( $pageNames ); + $batchKey = implode( '|', $pageNames ); + if ( !isset( $this->titleInfo[$batchKey] ) ) { + $this->titleInfo[$batchKey] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ ); + } + + $titleInfo = $this->titleInfo[$batchKey]; + + // Override the title info from the overrides, if any + $overrideCallback = $context->getContentOverrideCallback(); + if ( $overrideCallback ) { + foreach ( $pageNames as $page ) { + $title = Title::newFromText( $page ); + $content = $title ? call_user_func( $overrideCallback, $title ) : null; + if ( $content !== null ) { + $titleInfo[$title->getPrefixedText()] = [ + 'page_len' => $content->getSize(), + 'page_latest' => 'TBD', // None available + 'page_touched' => ConvertibleTimestamp::now( TS_MW ), + ]; + } + } + } + + return $titleInfo; + } + + /** + * @param IDatabase $db + * @param array $pages + * @param string $fname + * @return array + */ + protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) { + $titleInfo = []; + $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory(); + $batch = $linkBatchFactory->newLinkBatch(); + 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 = new TitleValue( (int)$row->page_namespace, $row->page_title ); + $titleInfo[self::makeTitleKey( $title )] = [ + 'page_len' => $row->page_len, + 'page_latest' => $row->page_latest, + 'page_touched' => $row->page_touched, + ]; + } + } + return $titleInfo; + } + + /** + * @since 1.28 + * @param Context $context + * @param IDatabase $db + * @param string[] $moduleNames + */ + public static function preloadTitleInfo( + Context $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 WikiModule[] $wikiModules */ + $wikiModules = []; + foreach ( $moduleNames as $name ) { + $module = $rl->getModule( $name ); + if ( $module instanceof self ) { + $mDB = $module->getDB(); + // Subclasses may implement getDB differently + if ( $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 = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $allInfo = $cache->getWithSetCallback( + $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ), + $cache::TTL_HOUR, + static 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 $pageName => $unused ) { + $title = Title::newFromText( $pageName ); + if ( $title ) { + $intersect[ self::makeTitleKey( $title ) ] = 1; + } else { + // Page name may be invalid if user-provided (e.g. gadgets) + $rl->getLogger()->info( + 'Invalid wiki page title "{title}" in ' . __METHOD__, + [ 'title' => $pageName ] + ); + } + } + $info = array_intersect_key( $allInfo, $intersect ); + $pageNames = array_keys( $pages ); + sort( $pageNames ); + $batchKey = implode( '|', $pageNames ); + $wikiModule->setTitleInfo( $batchKey, $info ); + } + } + + /** + * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on + * page change if it was a JS or CSS page + * + * @internal + * @param PageIdentity $page + * @param RevisionRecord|null $old Prior page revision + * @param RevisionRecord|null $new New page revision + * @param string $domain Database domain ID + */ + public static function invalidateModuleCache( + PageIdentity $page, + ?RevisionRecord $old, + ?RevisionRecord $new, + string $domain + ) { + static $models = [ CONTENT_MODEL_CSS, CONTENT_MODEL_JAVASCRIPT ]; + + $purge = false; + // TODO: MCR: differentiate between page functionality and content model! + // Not all pages containing CSS or JS have to be modules! [PageType] + if ( $old ) { + $oldModel = $old->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel(); + if ( in_array( $oldModel, $models ) ) { + $purge = true; + } + } + + if ( !$purge && $new ) { + $newModel = $new->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel(); + if ( in_array( $newModel, $models ) ) { + $purge = true; + } + } + + if ( !$purge ) { + $title = Title::castFromPageIdentity( $page ); + $purge = ( $title->isSiteConfigPage() || $title->isUserConfigPage() ); + } + + if ( $purge ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain ); + $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; + } +} + +/** @deprecated since 1.39 */ +class_alias( WikiModule::class, 'ResourceLoaderWikiModule' ); diff --git a/includes/ResourceLoader/dependencystore/DependencyStore.php b/includes/ResourceLoader/dependencystore/DependencyStore.php new file mode 100644 index 000000000000..36b6507b5fc3 --- /dev/null +++ b/includes/ResourceLoader/dependencystore/DependencyStore.php @@ -0,0 +1,126 @@ +<?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 + */ + +namespace Wikimedia\DependencyStore; + +/** + * Class for tracking per-entity dependency path lists that are expensive to mass compute + * + * @internal This should not be used outside of ResourceLoader and ResourceLoader\Module + */ +abstract class DependencyStore { + /** @var string */ + protected const KEY_PATHS = 'paths'; + /** @var string */ + protected const KEY_AS_OF = 'asOf'; + + /** + * @param string[] $paths List of dependency paths + * @param int|null $asOf UNIX timestamp or null + * @return array + */ + public function newEntityDependencies( array $paths = [], $asOf = null ) { + return [ self::KEY_PATHS => $paths, self::KEY_AS_OF => $asOf ]; + } + + /** + * Get the currently tracked dependencies for an entity + * + * The "paths" field contains a sorted list of unique paths + * + * The "asOf" field reflects the last-modified timestamp of the dependency data itself. + * It will be null if there is no tracking data available. Note that if empty path lists + * are never stored (as an optimisation) then it will not be possible to discern whether + * the result is up-to-date. + * + * @param string $type Entity type + * @param string $entity Entity name + * @return array Map of (paths: paths, asOf: UNIX timestamp or null) + * @throws DependencyStoreException + */ + final public function retrieve( $type, $entity ) { + return $this->retrieveMulti( $type, [ $entity ] )[$entity]; + } + + /** + * Get the currently tracked dependencies for a set of entities + * + * @see KeyValueDependencyStore::retrieve() + * + * @param string $type Entity type + * @param string[] $entities Entity names + * @return array[] Map of (entity => (paths: paths, asOf: UNIX timestamp or null)) + * @throws DependencyStoreException + */ + abstract public function retrieveMulti( $type, array $entities ); + + /** + * Set the currently tracked dependencies for an entity + * + * Dependency data should be set to persist as long as anything might rely on it existing + * in order to check the validity of some previously computed work. This can be achieved + * while minimizing storage space under the following scheme: + * - a) computed work has a TTL (time-to-live) + * - b) when work is computed, the dependency data is updated + * - c) the dependency data has a TTL higher enough to accounts for skew/latency + * - d) the TTL of tracked dependency data is renewed upon access + * + * @param string $type Entity type + * @param string $entity Entity name + * @param array $data Map of (paths: paths, asOf: UNIX timestamp or null) + * @param int $ttl New time-to-live in seconds + * @throws DependencyStoreException + */ + final public function store( $type, $entity, array $data, $ttl ) { + $this->storeMulti( $type, [ $entity => $data ], $ttl ); + } + + /** + * Set the currently tracked dependencies for a set of entities + * + * @see KeyValueDependencyStore::store() + * + * @param string $type Entity type + * @param array[] $dataByEntity Map of (entity => (paths: paths, asOf: UNIX timestamp or null)) + * @param int $ttl New time-to-live in seconds + * @throws DependencyStoreException + * + */ + abstract public function storeMulti( $type, array $dataByEntity, $ttl ); + + /** + * Delete the currently tracked dependencies for an entity or set of entities + * + * @param string $type Entity type + * @param string|string[] $entities Entity name(s) + * @throws DependencyStoreException + */ + abstract public function remove( $type, $entities ); + + /** + * Set the expiry for the currently tracked dependencies for an entity or set of entities + * + * @param string $type Entity type + * @param string|string[] $entities Entity name(s) + * @param int $ttl New time-to-live in seconds + * @throws DependencyStoreException + */ + abstract public function renew( $type, $entities, $ttl ); +} diff --git a/includes/ResourceLoader/dependencystore/DependencyStoreException.php b/includes/ResourceLoader/dependencystore/DependencyStoreException.php new file mode 100644 index 000000000000..7f81d57da0c2 --- /dev/null +++ b/includes/ResourceLoader/dependencystore/DependencyStoreException.php @@ -0,0 +1,13 @@ +<?php + +namespace Wikimedia\DependencyStore; + +use RuntimeException; + +/** + * @stable to type + * @since 1.35 + */ +class DependencyStoreException extends RuntimeException { + +} diff --git a/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php b/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php new file mode 100644 index 000000000000..4c510ba92710 --- /dev/null +++ b/includes/ResourceLoader/dependencystore/KeyValueDependencyStore.php @@ -0,0 +1,118 @@ +<?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 + */ + +namespace Wikimedia\DependencyStore; + +use BagOStuff; +use InvalidArgumentException; + +/** + * Lightweight class for tracking path dependencies lists via an object cache instance + * + * This does not throw DependencyStoreException due to I/O errors since it is optimized for + * speed and availability. Read methods return empty placeholders on failure. Write methods + * might issue I/O in the background and return immediately. However, reads methods will at + * least block on the resolution (success/failure) of any such pending writes. + * + * @since 1.35 + */ +class KeyValueDependencyStore extends DependencyStore { + /** @var BagOStuff */ + private $stash; + + /** + * @param BagOStuff $stash Storage backend + */ + public function __construct( BagOStuff $stash ) { + $this->stash = $stash; + } + + public function retrieveMulti( $type, array $entities ) { + $entitiesByKey = []; + foreach ( $entities as $entity ) { + $entitiesByKey[$this->getStoreKey( $type, $entity )] = $entity; + } + + $blobsByKey = $this->stash->getMulti( array_keys( $entitiesByKey ) ); + + $results = []; + foreach ( $entitiesByKey as $key => $entity ) { + $blob = $blobsByKey[$key] ?? null; + $data = is_string( $blob ) ? json_decode( $blob, true ) : null; + $results[$entity] = $this->newEntityDependencies( + $data[self::KEY_PATHS] ?? [], + $data[self::KEY_AS_OF] ?? null + ); + } + + return $results; + } + + public function storeMulti( $type, array $dataByEntity, $ttl ) { + $blobsByKey = []; + foreach ( $dataByEntity as $entity => $data ) { + if ( !is_array( $data[self::KEY_PATHS] ) || !is_int( $data[self::KEY_AS_OF] ) ) { + throw new InvalidArgumentException( "Invalid entry for '$entity'" ); + } + + // Normalize the list by removing duplicates and sorting + $data[self::KEY_PATHS] = array_values( array_unique( $data[self::KEY_PATHS] ) ); + sort( $data[self::KEY_PATHS], SORT_STRING ); + + $blob = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + $blobsByKey[$this->getStoreKey( $type, $entity )] = $blob; + } + + if ( $blobsByKey ) { + $this->stash->setMulti( $blobsByKey, $ttl, BagOStuff::WRITE_BACKGROUND ); + } + } + + public function remove( $type, $entities ) { + $keys = []; + foreach ( (array)$entities as $entity ) { + $keys[] = $this->getStoreKey( $type, $entity ); + } + + if ( $keys ) { + $this->stash->deleteMulti( $keys, BagOStuff::WRITE_BACKGROUND ); + } + } + + public function renew( $type, $entities, $ttl ) { + $keys = []; + foreach ( (array)$entities as $entity ) { + $keys[] = $this->getStoreKey( $type, $entity ); + } + + if ( $keys ) { + $this->stash->changeTTLMulti( $keys, $ttl, BagOStuff::WRITE_BACKGROUND ); + } + } + + /** + * @param string $type + * @param string $entity + * @return string + */ + private function getStoreKey( $type, $entity ) { + return $this->stash->makeKey( "{$type}-dependencies", $entity ); + } +} diff --git a/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php b/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php new file mode 100644 index 000000000000..ffb79573070b --- /dev/null +++ b/includes/ResourceLoader/dependencystore/SqlModuleDependencyStore.php @@ -0,0 +1,225 @@ +<?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 + */ + +namespace Wikimedia\DependencyStore; + +use InvalidArgumentException; +use Wikimedia\Rdbms\DBConnRef; +use Wikimedia\Rdbms\DBError; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\ILoadBalancer; + +/** + * Class for tracking per-entity dependency path lists in the module_deps table + * + * This should not be used outside of ResourceLoader and ResourceLoader\Module + * + * @internal For use with ResourceLoader/ResourceLoader\Module only + * @since 1.35 + */ +class SqlModuleDependencyStore extends DependencyStore { + /** @var ILoadBalancer */ + private $lb; + + /** + * @param ILoadBalancer $lb Storage backend + */ + public function __construct( ILoadBalancer $lb ) { + $this->lb = $lb; + } + + public function retrieveMulti( $type, array $entities ) { + try { + $dbr = $this->getReplicaDb(); + + $depsBlobByEntity = $this->fetchDependencyBlobs( $entities, $dbr ); + + $storedPathsByEntity = []; + foreach ( $depsBlobByEntity as $entity => $depsBlob ) { + $storedPathsByEntity[$entity] = json_decode( $depsBlob, true ); + } + + $results = []; + foreach ( $entities as $entity ) { + $paths = $storedPathsByEntity[$entity] ?? []; + $results[$entity] = $this->newEntityDependencies( $paths, null ); + } + + return $results; + } catch ( DBError $e ) { + throw new DependencyStoreException( $e->getMessage() ); + } + } + + public function storeMulti( $type, array $dataByEntity, $ttl ) { + // Avoid opening a primary DB connection when it's not needed. + // ResourceLoader::saveModuleDependenciesInternal calls this method unconditionally + // with empty values most of the time. + if ( !$dataByEntity ) { + return; + } + try { + $dbw = $this->getPrimaryDB(); + + $depsBlobByEntity = $this->fetchDependencyBlobs( array_keys( $dataByEntity ), $dbw ); + + $rows = []; + foreach ( $dataByEntity as $entity => $data ) { + list( $module, $variant ) = $this->getEntityNameComponents( $entity ); + if ( !is_array( $data[self::KEY_PATHS] ) ) { + throw new InvalidArgumentException( "Invalid entry for '$entity'" ); + } + + // Normalize the list by removing duplicates and sortings + $paths = array_values( array_unique( $data[self::KEY_PATHS] ) ); + sort( $paths, SORT_STRING ); + $blob = json_encode( $paths, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + + $existingBlob = $depsBlobByEntity[$entity] ?? null; + if ( $blob !== $existingBlob ) { + $rows[] = [ + 'md_module' => $module, + 'md_skin' => $variant, + 'md_deps' => $blob + ]; + } + } + + // @TODO: use a single query with VALUES()/aliases support in DB wrapper + // See https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html + foreach ( $rows as $row ) { + $dbw->upsert( + 'module_deps', + $row, + [ [ 'md_module', 'md_skin' ] ], + [ + 'md_deps' => $row['md_deps'], + ], + __METHOD__ + ); + } + } catch ( DBError $e ) { + throw new DependencyStoreException( $e->getMessage() ); + } + } + + public function remove( $type, $entities ) { + // Avoid opening a primary DB connection when it's not needed. + // ResourceLoader::saveModuleDependenciesInternal calls this method unconditionally + // with empty values most of the time. + if ( !$entities ) { + return; + } + try { + $dbw = $this->getPrimaryDB(); + + $disjunctionConds = []; + foreach ( (array)$entities as $entity ) { + list( $module, $variant ) = $this->getEntityNameComponents( $entity ); + $disjunctionConds[] = $dbw->makeList( + [ 'md_skin' => $variant, 'md_module' => $module ], + $dbw::LIST_AND + ); + } + + if ( $disjunctionConds ) { + $dbw->delete( + 'module_deps', + $dbw->makeList( $disjunctionConds, $dbw::LIST_OR ), + __METHOD__ + ); + } + } catch ( DBError $e ) { + throw new DependencyStoreException( $e->getMessage() ); + } + } + + public function renew( $type, $entities, $ttl ) { + // no-op + } + + /** + * @param string[] $entities + * @param IDatabase $db + * @return string[] + */ + private function fetchDependencyBlobs( array $entities, IDatabase $db ) { + $modulesByVariant = []; + foreach ( $entities as $entity ) { + list( $module, $variant ) = $this->getEntityNameComponents( $entity ); + $modulesByVariant[$variant][] = $module; + } + + $disjunctionConds = []; + foreach ( $modulesByVariant as $variant => $modules ) { + $disjunctionConds[] = $db->makeList( + [ 'md_skin' => $variant, 'md_module' => $modules ], + $db::LIST_AND + ); + } + + $depsBlobByEntity = []; + + if ( $disjunctionConds ) { + $res = $db->select( + 'module_deps', + [ 'md_module', 'md_skin', 'md_deps' ], + $db->makeList( $disjunctionConds, $db::LIST_OR ), + __METHOD__ + ); + + foreach ( $res as $row ) { + $entity = "{$row->md_module}|{$row->md_skin}"; + $depsBlobByEntity[$entity] = $row->md_deps; + } + } + + return $depsBlobByEntity; + } + + /** + * @return DBConnRef + */ + private function getReplicaDb() { + return $this->lb + ->getConnectionRef( DB_REPLICA, [], false, ( $this->lb )::CONN_TRX_AUTOCOMMIT ); + } + + /** + * @return DBConnRef + */ + private function getPrimaryDb() { + return $this->lb + ->getConnectionRef( DB_PRIMARY, [], false, ( $this->lb )::CONN_TRX_AUTOCOMMIT ); + } + + /** + * @param string $entity + * @return string[] + */ + private function getEntityNameComponents( $entity ) { + $parts = explode( '|', $entity, 2 ); + if ( count( $parts ) !== 2 ) { + throw new InvalidArgumentException( "Invalid module entity '$entity'" ); + } + + return $parts; + } +} |